mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* feat(senseaudio): BYOK chat with image + video generation tools
Adds SenseAudio as a first-class BYOK chat protocol and wires the daemon's
chat proxy with a tool loop so BYOK users can generate images and videos
without dropping to a CLI agent.
- BYOK protocol: new senseaudio tab + /api/proxy/senseaudio/stream route +
connection-test + provider-models discovery (OpenAI-compatible wire)
- Tool loop: generate_image (synchronous /v1/image/sync) and generate_video
(async /v1/video/create + 5s polling /v1/video/status, 10-min ceiling,
periodic progress log every 30s)
- Settings dropdown + chat-composer dropdown for the BYOK image model
default; generate_image's model enum lets the LLM override per call
- Seed-on-success: a successful BYOK chat call idempotently mirrors the
key into media-config (preserves env-resolved + already-stored keys)
- Generated artifacts land in <projectsRoot>/<projectId>/ so FileViewer,
DesignFilesPanel, and project export pick them up automatically;
legacy /api/byok-image/:id route kept for old conversation links
- Markdown renderer learns  image syntax with a scheme
allowlist (http(s) / data:image/ / blob: / relative paths)
- i18n key settings.byokImageModel across all 19 locales
- 3 SenseAudio image models registered (2.0, 1.0, doubao-seedream-5.0);
1 video model (doubao-seedance-2.0)
- Tests: byok-tools (29), media-senseaudio-image (8), media-config seed
(7), proxy-routes (47), markdown image rendering (8)
* fix(senseaudio): unblock image gen + design file preview switching
- SenseAudio /v1/image/sync rejected the previous size mapping with
`参数错误:size` (1664x936, 936x1664, 1280x960, 960x1280 are not in
the gateway's accepted set). Switched to standard HD / SD sizes that
every aspect bucket can hit: 1024×1024, 1280×720, 720×1280,
1024×768, 768×1024. Kept the byok-tools and media.ts tables in sync
so the BYOK chat tool and the CLI agent path both stop failing on
non-square aspects.
- DesignFilesPanel's <DfPreview> was missing a key prop, so React
reused the same iframe DOM node when the user picked a different
file — the src prop changed but the iframe never navigated. Added
key={previewFile.name} so the previous preview unmounts cleanly.
- Updated byok-tools + media-senseaudio-image tests for the new size
expectations.
* docs(senseaudio): clear stale provider hint + update README
- Settings → Media → SenseAudio: clear the auto-promoted
"Image · TTS · 70+ voices · clone" hint; the provider label alone is
enough now that the BYOK chat surface covers image + video tooling.
- README: list the new senseaudio (and missing ollama) proxy routes so
the BYOK section reflects what the daemon actually serves, and
mention the generate_image / generate_video chat tools that ship
with the SenseAudio path.
* fix(senseaudio): address PR #2065 review feedback
Three non-blocking review notes from @PerishCode on PR #2065:
1. Drop the dead /api/byok-image/:id route. The PR description claimed
it was "legacy fallback for old chat history" but that storage
layout never existed on main, so the route can only ever 400 or
404 — never 200. Removed the handler, the isSafeByokImageId
export, the unused createReadStream / stat / path / Request /
Response imports, and the two byok-image regression tests.
2. Add rejectProxyPluginContext guard to the senseaudio proxy
handler so it matches the invariant the other five proxy paths
already enforce (plugin runs must go through /api/runs for
snapshot pinning). Extended the existing "API fallback rejects
plugin runs" describe to also cover /api/proxy/senseaudio/stream
with the 409 PLUGIN_REQUIRES_DAEMON expectation.
3. Wrap the secondary image / video downloads (the URLs the
SenseAudio gateway hands back in /v1/image/sync .url and
/v1/video/status .video_url) in validateBaseUrlResolved so a
malicious gateway can't point us at 169.254.169.254 (AWS / Azure
metadata) or RFC1918 hosts via the response payload. Also passed
`redirect: 'error'` on both fetches to match the SSRF posture
the primary proxy fetch already uses. The new
assertExternalAssetUrl helper lives next to executeGenerateImage
so future tool downloads can reuse it.
Tests: 120/120 daemon tests pass; guard + typecheck green.
* fix(senseaudio): mirror SSRF guard onto renderSenseAudioImage CLI path
Follow-up to 01b1260a — the chat-tool fix in byok-tools.ts wasn't
mirrored onto the parallel renderSenseAudioImage path in media.ts.
Same attacker-controllable shape (gateway-returned `data.url`),
same one-line fix.
- Hoist assertExternalAssetUrl from byok-tools.ts into
connectionTest.ts next to validateBaseUrlResolved so both call
sites (the BYOK chat tool loop AND the CLI agent media dispatcher)
share one helper. Made the error strings provider-agnostic so a
future caller doesn't get a misleading "senseaudio" attribution
for a Volcengine / Grok / etc. download.
- renderSenseAudioImage now runs the response url through
assertExternalAssetUrl before fetching bytes, and passes
redirect: 'error' to block a 3xx hop into private space.
Scope intentionally limited to the senseaudio path PerishCode
flagged; the other unguarded fetch(entry.url) call sites in
media.ts (OpenAI / Volcengine / Grok / Nano-Banana) are pre-existing
patterns and belong in a separate follow-up if the daemon wants
defense-in-depth across every provider.
Tests: 127/127 daemon tests pass; guard + typecheck green.
---------
Co-authored-by: unknown <mazeliang@sensetime.com>
1027 lines
34 KiB
TypeScript
1027 lines
34 KiB
TypeScript
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
import os, { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
readAliasMap,
|
|
readMaskedConfig,
|
|
resolveModelAlias,
|
|
resolveProviderConfig,
|
|
seedProviderIfMissing,
|
|
writeConfig,
|
|
} from '../src/media-config.js';
|
|
|
|
const TEST_NANOBANANA_BASE_URL = 'https://nano-banana-gateway.example.test';
|
|
|
|
const OPENAI_ENV_KEYS = [
|
|
'OD_OPENAI_API_KEY',
|
|
'OPENAI_API_KEY',
|
|
'AZURE_API_KEY',
|
|
'AZURE_OPENAI_API_KEY',
|
|
];
|
|
|
|
describe('media-config OpenAI OAuth fallback', () => {
|
|
let homeDir: string;
|
|
let projectRoot: string;
|
|
const originalHome = process.env.HOME;
|
|
const originalEnv = Object.fromEntries(
|
|
OPENAI_ENV_KEYS.map((key) => [key, process.env[key]]),
|
|
);
|
|
const originalMediaConfigDir = process.env.OD_MEDIA_CONFIG_DIR;
|
|
const originalDataDir = process.env.OD_DATA_DIR;
|
|
let homedirSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(async () => {
|
|
homeDir = await mkdtemp(path.join(tmpdir(), 'od-media-home-'));
|
|
projectRoot = await mkdtemp(path.join(tmpdir(), 'od-media-project-'));
|
|
process.env.HOME = homeDir;
|
|
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(homeDir);
|
|
for (const key of OPENAI_ENV_KEYS) {
|
|
delete process.env[key];
|
|
}
|
|
delete process.env.OD_MEDIA_CONFIG_DIR;
|
|
delete process.env.OD_DATA_DIR;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (originalHome == null) {
|
|
delete process.env.HOME;
|
|
} else {
|
|
process.env.HOME = originalHome;
|
|
}
|
|
for (const key of OPENAI_ENV_KEYS) {
|
|
if (originalEnv[key] == null) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = originalEnv[key];
|
|
}
|
|
}
|
|
if (originalMediaConfigDir == null) {
|
|
delete process.env.OD_MEDIA_CONFIG_DIR;
|
|
} else {
|
|
process.env.OD_MEDIA_CONFIG_DIR = originalMediaConfigDir;
|
|
}
|
|
if (originalDataDir == null) {
|
|
delete process.env.OD_DATA_DIR;
|
|
} else {
|
|
process.env.OD_DATA_DIR = originalDataDir;
|
|
}
|
|
homedirSpy.mockRestore();
|
|
await rm(homeDir, { recursive: true, force: true });
|
|
await rm(projectRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
async function writeHomeJson(relPath: string, data: unknown) {
|
|
const file = path.join(homeDir, relPath);
|
|
await mkdir(path.dirname(file), { recursive: true });
|
|
await writeFile(file, JSON.stringify(data), 'utf8');
|
|
}
|
|
|
|
async function writeStoredMediaConfig(data: unknown) {
|
|
const file = path.join(projectRoot, '.od', 'media-config.json');
|
|
await mkdir(path.dirname(file), { recursive: true });
|
|
await writeFile(file, JSON.stringify(data), 'utf8');
|
|
}
|
|
|
|
function openaiProvider(masked: { providers: unknown }) {
|
|
return (masked.providers as Record<string, unknown>).openai;
|
|
}
|
|
|
|
it('uses Hermes openai-codex OAuth when no API key is configured', async () => {
|
|
await writeHomeJson('.hermes/auth.json', {
|
|
providers: {
|
|
'openai-codex': {
|
|
tokens: { access_token: 'hermes-oauth-token' },
|
|
},
|
|
},
|
|
});
|
|
|
|
const resolved = await resolveProviderConfig(projectRoot, 'openai');
|
|
const masked = await readMaskedConfig(projectRoot);
|
|
|
|
expect(resolved.apiKey).toBe('hermes-oauth-token');
|
|
expect(openaiProvider(masked)).toMatchObject({
|
|
configured: true,
|
|
source: 'oauth-hermes',
|
|
apiKeyTail: '',
|
|
});
|
|
});
|
|
|
|
it('uses Codex OAuth when Hermes has no OpenAI Codex credential', async () => {
|
|
await writeHomeJson('.codex/auth.json', {
|
|
tokens: { access_token: 'codex-oauth-token' },
|
|
});
|
|
|
|
const resolved = await resolveProviderConfig(projectRoot, 'openai');
|
|
const masked = await readMaskedConfig(projectRoot);
|
|
|
|
expect(resolved.apiKey).toBe('codex-oauth-token');
|
|
expect(openaiProvider(masked)).toMatchObject({
|
|
configured: true,
|
|
source: 'oauth-codex',
|
|
apiKeyTail: '',
|
|
});
|
|
});
|
|
|
|
it('keeps stored provider config ahead of OAuth fallbacks', async () => {
|
|
await writeHomeJson('.hermes/auth.json', {
|
|
providers: {
|
|
'openai-codex': {
|
|
tokens: { access_token: 'hermes-oauth-token' },
|
|
},
|
|
},
|
|
});
|
|
await writeStoredMediaConfig({
|
|
providers: {
|
|
openai: {
|
|
apiKey: 'stored-openai-key',
|
|
baseUrl: 'https://example.test/v1',
|
|
},
|
|
},
|
|
});
|
|
|
|
const resolved = await resolveProviderConfig(projectRoot, 'openai');
|
|
const masked = await readMaskedConfig(projectRoot);
|
|
|
|
expect(resolved).toEqual({
|
|
apiKey: 'stored-openai-key',
|
|
baseUrl: 'https://example.test/v1',
|
|
});
|
|
expect(openaiProvider(masked)).toMatchObject({
|
|
configured: true,
|
|
source: 'stored',
|
|
apiKeyTail: '-key',
|
|
baseUrl: 'https://example.test/v1',
|
|
});
|
|
});
|
|
|
|
it('resolves Nano Banana env and stored model overrides', async () => {
|
|
process.env.OD_NANOBANANA_API_KEY = 'env-nano-key';
|
|
await writeStoredMediaConfig({
|
|
providers: {
|
|
nanobanana: {
|
|
apiKey: 'stored-nano-key',
|
|
baseUrl: TEST_NANOBANANA_BASE_URL,
|
|
model: 'gemini-3.1-flash-image-preview-custom',
|
|
},
|
|
},
|
|
});
|
|
|
|
const resolved = await resolveProviderConfig(projectRoot, 'nanobanana');
|
|
const masked = await readMaskedConfig(projectRoot);
|
|
const provider = (masked.providers as Record<string, unknown>).nanobanana;
|
|
|
|
expect(resolved).toEqual({
|
|
apiKey: 'env-nano-key',
|
|
baseUrl: TEST_NANOBANANA_BASE_URL,
|
|
model: 'gemini-3.1-flash-image-preview-custom',
|
|
});
|
|
expect(provider).toMatchObject({
|
|
configured: true,
|
|
source: 'env',
|
|
apiKeyTail: '-key',
|
|
baseUrl: TEST_NANOBANANA_BASE_URL,
|
|
model: 'gemini-3.1-flash-image-preview-custom',
|
|
});
|
|
|
|
delete process.env.OD_NANOBANANA_API_KEY;
|
|
});
|
|
|
|
it('preserves a stored apiKey when writeConfig updates only non-secret fields', async () => {
|
|
await writeStoredMediaConfig({
|
|
providers: {
|
|
openai: {
|
|
apiKey: 'stored-openai-key',
|
|
baseUrl: 'https://before.example/v1',
|
|
},
|
|
},
|
|
});
|
|
|
|
await writeConfig(projectRoot, {
|
|
providers: {
|
|
openai: {
|
|
preserveApiKey: true,
|
|
baseUrl: 'https://after.example/v1',
|
|
},
|
|
},
|
|
force: true,
|
|
});
|
|
|
|
await expect(resolveProviderConfig(projectRoot, 'openai')).resolves.toEqual({
|
|
apiKey: 'stored-openai-key',
|
|
baseUrl: 'https://after.example/v1',
|
|
});
|
|
});
|
|
|
|
describe('OD_MEDIA_CONFIG_DIR / OD_DATA_DIR storage routing', () => {
|
|
let overrideRoot: string;
|
|
let originalMediaConfigDir: string | undefined;
|
|
let originalDataDir: string | undefined;
|
|
|
|
beforeEach(async () => {
|
|
overrideRoot = await mkdtemp(path.join(tmpdir(), 'od-media-override-'));
|
|
originalMediaConfigDir = process.env.OD_MEDIA_CONFIG_DIR;
|
|
originalDataDir = process.env.OD_DATA_DIR;
|
|
delete process.env.OD_MEDIA_CONFIG_DIR;
|
|
delete process.env.OD_DATA_DIR;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (originalMediaConfigDir == null) {
|
|
delete process.env.OD_MEDIA_CONFIG_DIR;
|
|
} else {
|
|
process.env.OD_MEDIA_CONFIG_DIR = originalMediaConfigDir;
|
|
}
|
|
if (originalDataDir == null) {
|
|
delete process.env.OD_DATA_DIR;
|
|
} else {
|
|
process.env.OD_DATA_DIR = originalDataDir;
|
|
}
|
|
await rm(overrideRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
async function writeProvidersAt(dir: string, data: unknown) {
|
|
await mkdir(dir, { recursive: true });
|
|
await writeFile(
|
|
path.join(dir, 'media-config.json'),
|
|
JSON.stringify(data),
|
|
'utf8',
|
|
);
|
|
}
|
|
|
|
it('reads media-config.json from an absolute OD_MEDIA_CONFIG_DIR', async () => {
|
|
process.env.OD_MEDIA_CONFIG_DIR = overrideRoot;
|
|
await writeProvidersAt(overrideRoot, {
|
|
providers: {
|
|
openai: {
|
|
apiKey: 'absolute-key',
|
|
baseUrl: 'https://absolute.test/v1',
|
|
},
|
|
},
|
|
});
|
|
|
|
const resolved = await resolveProviderConfig(projectRoot, 'openai');
|
|
expect(resolved).toEqual({
|
|
apiKey: 'absolute-key',
|
|
baseUrl: 'https://absolute.test/v1',
|
|
});
|
|
});
|
|
|
|
it('expands a leading ~/ against the user home directory', async () => {
|
|
// Per-test HOME points at a tmpdir (set by outer beforeEach), so the
|
|
// expansion lands somewhere safe to write.
|
|
const subdir = '.od-test';
|
|
process.env.OD_MEDIA_CONFIG_DIR = `~/${subdir}`;
|
|
const expandedDir = path.join(homeDir, subdir);
|
|
await writeProvidersAt(expandedDir, {
|
|
providers: {
|
|
openai: {
|
|
apiKey: 'tilde-key',
|
|
baseUrl: 'https://tilde.test/v1',
|
|
},
|
|
},
|
|
});
|
|
|
|
const resolved = await resolveProviderConfig(projectRoot, 'openai');
|
|
expect(resolved).toEqual({
|
|
apiKey: 'tilde-key',
|
|
baseUrl: 'https://tilde.test/v1',
|
|
});
|
|
});
|
|
|
|
it('resolves a relative override against projectRoot, not process.cwd', async () => {
|
|
// process.cwd() during tests is typically the workspace root, which
|
|
// is unrelated to the per-test projectRoot. A relative override must
|
|
// land inside projectRoot, mirroring how resolveDataDir() in
|
|
// server.ts anchors OD_DATA_DIR.
|
|
const relative = 'config/media';
|
|
process.env.OD_MEDIA_CONFIG_DIR = relative;
|
|
const anchoredDir = path.join(projectRoot, relative);
|
|
await writeProvidersAt(anchoredDir, {
|
|
providers: {
|
|
openai: {
|
|
apiKey: 'relative-key',
|
|
baseUrl: 'https://relative.test/v1',
|
|
},
|
|
},
|
|
});
|
|
|
|
const resolved = await resolveProviderConfig(projectRoot, 'openai');
|
|
expect(resolved).toEqual({
|
|
apiKey: 'relative-key',
|
|
baseUrl: 'https://relative.test/v1',
|
|
});
|
|
});
|
|
|
|
it('falls back to OD_DATA_DIR when OD_MEDIA_CONFIG_DIR is unset', async () => {
|
|
// Packaged daemon (apps/packaged/src/sidecars.ts) and the
|
|
// Home Manager / NixOS modules already set OD_DATA_DIR for the
|
|
// rest of the daemon's runtime state. media-config should
|
|
// co-locate there without needing a second env var.
|
|
process.env.OD_DATA_DIR = overrideRoot;
|
|
await writeProvidersAt(overrideRoot, {
|
|
providers: {
|
|
openai: {
|
|
apiKey: 'datadir-key',
|
|
baseUrl: 'https://datadir.test/v1',
|
|
},
|
|
},
|
|
});
|
|
|
|
const resolved = await resolveProviderConfig(projectRoot, 'openai');
|
|
expect(resolved).toEqual({
|
|
apiKey: 'datadir-key',
|
|
baseUrl: 'https://datadir.test/v1',
|
|
});
|
|
});
|
|
|
|
it('OD_MEDIA_CONFIG_DIR takes precedence over OD_DATA_DIR', async () => {
|
|
const dataDir = await mkdtemp(path.join(tmpdir(), 'od-media-data-'));
|
|
try {
|
|
process.env.OD_DATA_DIR = dataDir;
|
|
process.env.OD_MEDIA_CONFIG_DIR = overrideRoot;
|
|
// Two competing files; only the OD_MEDIA_CONFIG_DIR one should
|
|
// be read.
|
|
await writeProvidersAt(dataDir, {
|
|
providers: {
|
|
openai: { apiKey: 'data-key', baseUrl: 'https://data/v1' },
|
|
},
|
|
});
|
|
await writeProvidersAt(overrideRoot, {
|
|
providers: {
|
|
openai: { apiKey: 'media-key', baseUrl: 'https://media/v1' },
|
|
},
|
|
});
|
|
|
|
const resolved = await resolveProviderConfig(projectRoot, 'openai');
|
|
expect(resolved).toEqual({
|
|
apiKey: 'media-key',
|
|
baseUrl: 'https://media/v1',
|
|
});
|
|
} finally {
|
|
await rm(dataDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('writeConfig creates the override directory tree on first write', async () => {
|
|
// Reproduces the actual user-reported failure mode: the override
|
|
// directory does not exist yet (first launch on a read-only
|
|
// install root), so writeConfig must mkdir -p before writing.
|
|
// Without recursive mkdir + a writable override, this would
|
|
// surface as ENOENT/EROFS to PUT /api/media/config.
|
|
const target = path.join(overrideRoot, 'nested', 'inner');
|
|
process.env.OD_MEDIA_CONFIG_DIR = target;
|
|
|
|
await writeConfig(projectRoot, {
|
|
providers: {
|
|
openai: {
|
|
apiKey: 'fresh-write-key',
|
|
baseUrl: 'https://fresh.test/v1',
|
|
},
|
|
},
|
|
});
|
|
|
|
// File materialised at the override path.
|
|
const onDisk = await readFile(
|
|
path.join(target, 'media-config.json'),
|
|
'utf8',
|
|
);
|
|
expect(JSON.parse(onDisk)).toEqual({
|
|
providers: {
|
|
openai: {
|
|
apiKey: 'fresh-write-key',
|
|
baseUrl: 'https://fresh.test/v1',
|
|
},
|
|
},
|
|
});
|
|
|
|
// And resolveProviderConfig reads it back correctly.
|
|
const resolved = await resolveProviderConfig(projectRoot, 'openai');
|
|
expect(resolved).toEqual({
|
|
apiKey: 'fresh-write-key',
|
|
baseUrl: 'https://fresh.test/v1',
|
|
});
|
|
});
|
|
|
|
// Round 3 review feedback on PR #530.
|
|
// resolveOverrideDir shares expandHomePrefix with resolveDataDir, so
|
|
// OD_DATA_DIR=$HOME/.open-design (and ${HOME}/.open-design) routes
|
|
// both daemon runtime data AND media credentials to the same expanded
|
|
// path. Without this, media-config.json was written under
|
|
// <projectRoot>/$HOME/.open-design and stored provider keys appeared
|
|
// missing on the next read.
|
|
it('expands $HOME/... in OD_DATA_DIR fallback so media-config co-locates with daemon data', async () => {
|
|
const subdir = '.od-test-home';
|
|
process.env.OD_DATA_DIR = `$HOME/${subdir}`;
|
|
const expandedDir = path.join(homeDir, subdir);
|
|
await writeProvidersAt(expandedDir, {
|
|
providers: {
|
|
openai: {
|
|
apiKey: 'home-key',
|
|
baseUrl: 'https://home.test/v1',
|
|
},
|
|
},
|
|
});
|
|
|
|
const resolved = await resolveProviderConfig(projectRoot, 'openai');
|
|
expect(resolved).toEqual({
|
|
apiKey: 'home-key',
|
|
baseUrl: 'https://home.test/v1',
|
|
});
|
|
});
|
|
|
|
it('expands ${HOME}/... in OD_DATA_DIR fallback', async () => {
|
|
const subdir = '.od-test-braced';
|
|
process.env.OD_DATA_DIR = `\${HOME}/${subdir}`;
|
|
const expandedDir = path.join(homeDir, subdir);
|
|
await writeProvidersAt(expandedDir, {
|
|
providers: {
|
|
openai: {
|
|
apiKey: 'braced-key',
|
|
baseUrl: 'https://braced.test/v1',
|
|
},
|
|
},
|
|
});
|
|
|
|
const resolved = await resolveProviderConfig(projectRoot, 'openai');
|
|
expect(resolved).toEqual({
|
|
apiKey: 'braced-key',
|
|
baseUrl: 'https://braced.test/v1',
|
|
});
|
|
});
|
|
|
|
it('expands $HOME/... in OD_MEDIA_CONFIG_DIR (explicit override path)', async () => {
|
|
const subdir = '.od-media-home';
|
|
process.env.OD_MEDIA_CONFIG_DIR = `$HOME/${subdir}`;
|
|
const expandedDir = path.join(homeDir, subdir);
|
|
await writeProvidersAt(expandedDir, {
|
|
providers: {
|
|
openai: {
|
|
apiKey: 'media-home-key',
|
|
baseUrl: 'https://media-home.test/v1',
|
|
},
|
|
},
|
|
});
|
|
|
|
const resolved = await resolveProviderConfig(projectRoot, 'openai');
|
|
expect(resolved).toEqual({
|
|
apiKey: 'media-home-key',
|
|
baseUrl: 'https://media-home.test/v1',
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
const GROK_ENV_KEYS = ['OD_GROK_API_KEY', 'XAI_API_KEY'];
|
|
|
|
describe('media-config Grok / xAI OAuth fallback', () => {
|
|
let homeDir: string;
|
|
let projectRoot: string;
|
|
const originalHome = process.env.HOME;
|
|
const originalEnv = Object.fromEntries(
|
|
GROK_ENV_KEYS.map((key) => [key, process.env[key]]),
|
|
);
|
|
const originalMediaConfigDir = process.env.OD_MEDIA_CONFIG_DIR;
|
|
const originalDataDir = process.env.OD_DATA_DIR;
|
|
let homedirSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
beforeEach(async () => {
|
|
homeDir = await mkdtemp(path.join(tmpdir(), 'od-media-grok-home-'));
|
|
projectRoot = await mkdtemp(path.join(tmpdir(), 'od-media-grok-project-'));
|
|
process.env.HOME = homeDir;
|
|
homedirSpy = vi.spyOn(os, 'homedir').mockReturnValue(homeDir);
|
|
for (const key of GROK_ENV_KEYS) {
|
|
delete process.env[key];
|
|
}
|
|
delete process.env.OD_MEDIA_CONFIG_DIR;
|
|
delete process.env.OD_DATA_DIR;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (originalHome == null) {
|
|
delete process.env.HOME;
|
|
} else {
|
|
process.env.HOME = originalHome;
|
|
}
|
|
for (const key of GROK_ENV_KEYS) {
|
|
if (originalEnv[key] == null) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = originalEnv[key];
|
|
}
|
|
}
|
|
if (originalMediaConfigDir == null) {
|
|
delete process.env.OD_MEDIA_CONFIG_DIR;
|
|
} else {
|
|
process.env.OD_MEDIA_CONFIG_DIR = originalMediaConfigDir;
|
|
}
|
|
if (originalDataDir == null) {
|
|
delete process.env.OD_DATA_DIR;
|
|
} else {
|
|
process.env.OD_DATA_DIR = originalDataDir;
|
|
}
|
|
homedirSpy.mockRestore();
|
|
await rm(homeDir, { recursive: true, force: true });
|
|
await rm(projectRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
async function writeHomeJson(relPath: string, data: unknown) {
|
|
const file = path.join(homeDir, relPath);
|
|
await mkdir(path.dirname(file), { recursive: true });
|
|
await writeFile(file, JSON.stringify(data), 'utf8');
|
|
}
|
|
|
|
async function writeOdXaiTokens(token: {
|
|
accessToken: string;
|
|
refreshToken?: string;
|
|
expiresAt?: number;
|
|
}) {
|
|
const file = path.join(projectRoot, '.od', 'xai-tokens.json');
|
|
await mkdir(path.dirname(file), { recursive: true });
|
|
await writeFile(
|
|
file,
|
|
JSON.stringify({
|
|
token: {
|
|
accessToken: token.accessToken,
|
|
tokenType: 'Bearer',
|
|
savedAt: Date.now(),
|
|
...(token.refreshToken ? { refreshToken: token.refreshToken } : {}),
|
|
...(token.expiresAt !== undefined
|
|
? { expiresAt: token.expiresAt }
|
|
: {}),
|
|
},
|
|
}),
|
|
'utf8',
|
|
);
|
|
}
|
|
|
|
async function writeStoredMediaConfig(data: unknown) {
|
|
const file = path.join(projectRoot, '.od', 'media-config.json');
|
|
await mkdir(path.dirname(file), { recursive: true });
|
|
await writeFile(file, JSON.stringify(data), 'utf8');
|
|
}
|
|
|
|
function grokProvider(masked: { providers: unknown }) {
|
|
return (masked.providers as Record<string, unknown>).grok;
|
|
}
|
|
|
|
it('uses OD-native xai-tokens.json when one is stored', async () => {
|
|
await writeOdXaiTokens({
|
|
accessToken: 'od-bearer-1',
|
|
expiresAt: Date.now() + 3_600_000,
|
|
});
|
|
|
|
const resolved = await resolveProviderConfig(projectRoot, 'grok');
|
|
const masked = await readMaskedConfig(projectRoot);
|
|
|
|
expect(resolved.apiKey).toBe('od-bearer-1');
|
|
expect(grokProvider(masked)).toMatchObject({
|
|
configured: true,
|
|
source: 'oauth-xai-stored',
|
|
apiKeyTail: '',
|
|
});
|
|
});
|
|
|
|
it('borrows the Hermes-side xai-oauth token when OD has no native creds', async () => {
|
|
await writeHomeJson('.hermes/auth.json', {
|
|
providers: {
|
|
'xai-oauth': {
|
|
tokens: { access_token: 'hermes-xai-bearer' },
|
|
},
|
|
},
|
|
});
|
|
|
|
const resolved = await resolveProviderConfig(projectRoot, 'grok');
|
|
const masked = await readMaskedConfig(projectRoot);
|
|
|
|
expect(resolved.apiKey).toBe('hermes-xai-bearer');
|
|
expect(grokProvider(masked)).toMatchObject({
|
|
configured: true,
|
|
source: 'oauth-hermes-xai',
|
|
});
|
|
});
|
|
|
|
it('prefers OD-native xai-tokens over Hermes borrowing', async () => {
|
|
await writeOdXaiTokens({
|
|
accessToken: 'od-bearer-2',
|
|
expiresAt: Date.now() + 3_600_000,
|
|
});
|
|
await writeHomeJson('.hermes/auth.json', {
|
|
providers: {
|
|
'xai-oauth': {
|
|
tokens: { access_token: 'hermes-xai-bearer' },
|
|
},
|
|
},
|
|
});
|
|
|
|
const resolved = await resolveProviderConfig(projectRoot, 'grok');
|
|
expect(resolved.apiKey).toBe('od-bearer-2');
|
|
});
|
|
|
|
it('keeps env keys ahead of OAuth fallbacks', async () => {
|
|
process.env.XAI_API_KEY = 'env-xai-key';
|
|
await writeOdXaiTokens({
|
|
accessToken: 'od-bearer-3',
|
|
expiresAt: Date.now() + 3_600_000,
|
|
});
|
|
|
|
const resolved = await resolveProviderConfig(projectRoot, 'grok');
|
|
const masked = await readMaskedConfig(projectRoot);
|
|
|
|
expect(resolved.apiKey).toBe('env-xai-key');
|
|
expect(grokProvider(masked)).toMatchObject({
|
|
configured: true,
|
|
source: 'env',
|
|
});
|
|
});
|
|
|
|
it('keeps stored provider key ahead of OAuth fallbacks', async () => {
|
|
await writeStoredMediaConfig({
|
|
providers: {
|
|
grok: { apiKey: 'stored-grok-key', baseUrl: 'https://api.x.ai/v1' },
|
|
},
|
|
});
|
|
await writeOdXaiTokens({
|
|
accessToken: 'od-bearer-4',
|
|
expiresAt: Date.now() + 3_600_000,
|
|
});
|
|
|
|
const resolved = await resolveProviderConfig(projectRoot, 'grok');
|
|
expect(resolved.apiKey).toBe('stored-grok-key');
|
|
});
|
|
|
|
it('returns empty when no env, no stored key, and no OAuth source exists', async () => {
|
|
const resolved = await resolveProviderConfig(projectRoot, 'grok');
|
|
const masked = await readMaskedConfig(projectRoot);
|
|
|
|
expect(resolved.apiKey).toBe('');
|
|
expect(grokProvider(masked)).toMatchObject({
|
|
configured: false,
|
|
source: 'unset',
|
|
});
|
|
});
|
|
|
|
it('skips an OD-native token within the expiry skew when no refresh_token is stored', async () => {
|
|
// expiresAt within the 120s skew window → treated as expired by
|
|
// resolveXAIBearer. Without a refresh_token it can't recover, so
|
|
// the resolver falls through to other sources (none here).
|
|
await writeOdXaiTokens({
|
|
accessToken: 'od-bearer-expired',
|
|
expiresAt: Date.now() + 30_000,
|
|
});
|
|
|
|
const resolved = await resolveProviderConfig(projectRoot, 'grok');
|
|
expect(resolved.apiKey).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('media-config model alias resolution (issue #1277)', () => {
|
|
let projectRoot: string;
|
|
const originalEnvAliases = process.env.OD_MEDIA_MODEL_ALIASES;
|
|
const originalMediaConfigDir = process.env.OD_MEDIA_CONFIG_DIR;
|
|
const originalDataDir = process.env.OD_DATA_DIR;
|
|
|
|
beforeEach(async () => {
|
|
projectRoot = await mkdtemp(path.join(tmpdir(), 'od-media-alias-'));
|
|
delete process.env.OD_MEDIA_MODEL_ALIASES;
|
|
delete process.env.OD_MEDIA_CONFIG_DIR;
|
|
delete process.env.OD_DATA_DIR;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (originalEnvAliases == null) {
|
|
delete process.env.OD_MEDIA_MODEL_ALIASES;
|
|
} else {
|
|
process.env.OD_MEDIA_MODEL_ALIASES = originalEnvAliases;
|
|
}
|
|
if (originalMediaConfigDir == null) {
|
|
delete process.env.OD_MEDIA_CONFIG_DIR;
|
|
} else {
|
|
process.env.OD_MEDIA_CONFIG_DIR = originalMediaConfigDir;
|
|
}
|
|
if (originalDataDir == null) {
|
|
delete process.env.OD_DATA_DIR;
|
|
} else {
|
|
process.env.OD_DATA_DIR = originalDataDir;
|
|
}
|
|
await rm(projectRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
async function writeStoredMediaConfig(data: unknown) {
|
|
const file = path.join(projectRoot, '.od', 'media-config.json');
|
|
await mkdir(path.dirname(file), { recursive: true });
|
|
await writeFile(file, JSON.stringify(data), 'utf8');
|
|
}
|
|
|
|
it('passes through unmapped model ids unchanged', async () => {
|
|
expect(await resolveModelAlias(projectRoot, 'doubao-seedream-3-0-t2i-250415')).toBe(
|
|
'doubao-seedream-3-0-t2i-250415',
|
|
);
|
|
});
|
|
|
|
it('redirects via the stored aliases map in media-config.json', async () => {
|
|
// The flagship use case from the issue: registered catalog id
|
|
// -> the new model name the user actually has access to.
|
|
await writeStoredMediaConfig({
|
|
providers: {},
|
|
aliases: { 'doubao-seedream-3-0-t2i-250415': 'doubao-seedream-5-0' },
|
|
});
|
|
expect(
|
|
await resolveModelAlias(projectRoot, 'doubao-seedream-3-0-t2i-250415'),
|
|
).toBe('doubao-seedream-5-0');
|
|
});
|
|
|
|
it('redirects via the OD_MEDIA_MODEL_ALIASES env var', async () => {
|
|
process.env.OD_MEDIA_MODEL_ALIASES = JSON.stringify({
|
|
'doubao-seedream-3-0-t2i-250415': 'doubao-seedream-5-0',
|
|
});
|
|
expect(
|
|
await resolveModelAlias(projectRoot, 'doubao-seedream-3-0-t2i-250415'),
|
|
).toBe('doubao-seedream-5-0');
|
|
});
|
|
|
|
it('lets the env var override an on-disk alias (env wins for power users)', async () => {
|
|
await writeStoredMediaConfig({
|
|
providers: {},
|
|
aliases: { 'doubao-seedream-3-0-t2i-250415': 'on-disk-alias' },
|
|
});
|
|
process.env.OD_MEDIA_MODEL_ALIASES = JSON.stringify({
|
|
'doubao-seedream-3-0-t2i-250415': 'env-alias',
|
|
});
|
|
expect(
|
|
await resolveModelAlias(projectRoot, 'doubao-seedream-3-0-t2i-250415'),
|
|
).toBe('env-alias');
|
|
});
|
|
|
|
it('tolerates malformed env JSON and falls through to the stored map', async () => {
|
|
// A user with a half-typed env var (`OD_MEDIA_MODEL_ALIASES='{'`)
|
|
// should still get their on-disk aliases, not a hard error mid-
|
|
// generation.
|
|
process.env.OD_MEDIA_MODEL_ALIASES = '{not valid json';
|
|
await writeStoredMediaConfig({
|
|
providers: {},
|
|
aliases: { 'doubao-seedream-3-0-t2i-250415': 'doubao-seedream-5-0' },
|
|
});
|
|
expect(
|
|
await resolveModelAlias(projectRoot, 'doubao-seedream-3-0-t2i-250415'),
|
|
).toBe('doubao-seedream-5-0');
|
|
});
|
|
|
|
it('drops non-string and empty alias entries during coercion', async () => {
|
|
// Defends against a future schema bump (number / null / nested
|
|
// object) and against accidental empty-string entries from a
|
|
// Settings UI form. The coercion must never feed garbage into a
|
|
// dispatcher's request body.
|
|
process.env.OD_MEDIA_MODEL_ALIASES = JSON.stringify({
|
|
'good-key': 'good-value',
|
|
'empty-key': '',
|
|
'null-key': null,
|
|
'object-key': { nested: 'no' },
|
|
'': 'blank-key-rejected',
|
|
});
|
|
expect(await resolveModelAlias(projectRoot, 'good-key')).toBe('good-value');
|
|
expect(await resolveModelAlias(projectRoot, 'empty-key')).toBe('empty-key');
|
|
expect(await resolveModelAlias(projectRoot, 'null-key')).toBe('null-key');
|
|
expect(await resolveModelAlias(projectRoot, 'object-key')).toBe('object-key');
|
|
});
|
|
|
|
it('exposes the merged map via readAliasMap so Settings can show source attribution', async () => {
|
|
await writeStoredMediaConfig({
|
|
providers: {},
|
|
aliases: { 'stored-only': 'a', 'overridden': 'stored-value' },
|
|
});
|
|
process.env.OD_MEDIA_MODEL_ALIASES = JSON.stringify({
|
|
'env-only': 'b',
|
|
'overridden': 'env-value',
|
|
});
|
|
const map = await readAliasMap(projectRoot);
|
|
expect(map.stored).toEqual({ 'stored-only': 'a', 'overridden': 'stored-value' });
|
|
expect(map.env).toEqual({ 'env-only': 'b', 'overridden': 'env-value' });
|
|
expect(map.effective).toEqual({
|
|
'stored-only': 'a',
|
|
'env-only': 'b',
|
|
'overridden': 'env-value',
|
|
});
|
|
});
|
|
|
|
it('readMaskedConfig surfaces the alias map for the Settings UI', async () => {
|
|
// Lefarcen P3 (#1309 review): the prior PR description claimed
|
|
// `readAliasMap` was the daemon-public API for the Settings UI,
|
|
// but the HTTP route returned only `readMaskedConfig` (which
|
|
// had no aliases field). The fix wires aliases into the GET
|
|
// response so a future Settings UI PR can consume them without
|
|
// touching the daemon.
|
|
await writeStoredMediaConfig({
|
|
providers: {},
|
|
aliases: { 'dall-e-3': 'azure-dalle3' },
|
|
});
|
|
process.env.OD_MEDIA_MODEL_ALIASES = JSON.stringify({
|
|
'gpt-4o-mini-tts': 'custom-tts',
|
|
});
|
|
|
|
const masked = await readMaskedConfig(projectRoot);
|
|
|
|
expect(masked.aliases.stored).toEqual({ 'dall-e-3': 'azure-dalle3' });
|
|
expect(masked.aliases.env).toEqual({ 'gpt-4o-mini-tts': 'custom-tts' });
|
|
expect(masked.aliases.effective).toEqual({
|
|
'dall-e-3': 'azure-dalle3',
|
|
'gpt-4o-mini-tts': 'custom-tts',
|
|
});
|
|
});
|
|
|
|
it('readMaskedConfig returns empty alias maps when no aliases are configured', async () => {
|
|
// Settings UI needs a stable shape so it can render "no aliases
|
|
// configured" without crashing on `aliases.effective` being
|
|
// undefined.
|
|
const masked = await readMaskedConfig(projectRoot);
|
|
expect(masked.aliases.effective).toEqual({});
|
|
expect(masked.aliases.env).toEqual({});
|
|
expect(masked.aliases.stored).toEqual({});
|
|
});
|
|
|
|
it('writeConfig preserves aliases when a Settings-style provider PUT lands', async () => {
|
|
// The Settings UI in its current shape writes providers only.
|
|
// Without alias preservation, every provider edit would wipe the
|
|
// user's aliases. This pins the regression so a future refactor
|
|
// that touches writeStored has to keep both fields.
|
|
await writeStoredMediaConfig({
|
|
providers: {},
|
|
aliases: { 'doubao-seedream-3-0-t2i-250415': 'doubao-seedream-5-0' },
|
|
});
|
|
await writeConfig(projectRoot, {
|
|
providers: {
|
|
openai: { apiKey: 'sk-key', baseUrl: '' },
|
|
},
|
|
});
|
|
const onDisk = JSON.parse(
|
|
await readFile(
|
|
path.join(projectRoot, '.od', 'media-config.json'),
|
|
'utf8',
|
|
),
|
|
);
|
|
expect(onDisk.providers.openai).toMatchObject({ apiKey: 'sk-key' });
|
|
expect(onDisk.aliases).toEqual({
|
|
'doubao-seedream-3-0-t2i-250415': 'doubao-seedream-5-0',
|
|
});
|
|
expect(
|
|
await resolveModelAlias(projectRoot, 'doubao-seedream-3-0-t2i-250415'),
|
|
).toBe('doubao-seedream-5-0');
|
|
});
|
|
});
|
|
|
|
describe('seedProviderIfMissing', () => {
|
|
let projectRoot: string;
|
|
const SENSEAUDIO_ENV_KEYS = ['OD_SENSEAUDIO_API_KEY', 'SENSEAUDIO_API_KEY'];
|
|
const originalEnv = Object.fromEntries(
|
|
SENSEAUDIO_ENV_KEYS.map((key) => [key, process.env[key]]),
|
|
);
|
|
const originalMediaConfigDir = process.env.OD_MEDIA_CONFIG_DIR;
|
|
const originalDataDir = process.env.OD_DATA_DIR;
|
|
|
|
beforeEach(async () => {
|
|
projectRoot = await mkdtemp(path.join(tmpdir(), 'od-media-seed-'));
|
|
for (const key of SENSEAUDIO_ENV_KEYS) {
|
|
delete process.env[key];
|
|
}
|
|
delete process.env.OD_MEDIA_CONFIG_DIR;
|
|
delete process.env.OD_DATA_DIR;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
for (const key of SENSEAUDIO_ENV_KEYS) {
|
|
if (originalEnv[key] == null) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = originalEnv[key];
|
|
}
|
|
}
|
|
if (originalMediaConfigDir == null) {
|
|
delete process.env.OD_MEDIA_CONFIG_DIR;
|
|
} else {
|
|
process.env.OD_MEDIA_CONFIG_DIR = originalMediaConfigDir;
|
|
}
|
|
if (originalDataDir == null) {
|
|
delete process.env.OD_DATA_DIR;
|
|
} else {
|
|
process.env.OD_DATA_DIR = originalDataDir;
|
|
}
|
|
await rm(projectRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
async function writeStored(data: unknown) {
|
|
const file = path.join(projectRoot, '.od', 'media-config.json');
|
|
await mkdir(path.dirname(file), { recursive: true });
|
|
await writeFile(file, JSON.stringify(data), 'utf8');
|
|
}
|
|
|
|
async function readStoredJson(): Promise<unknown> {
|
|
const file = path.join(projectRoot, '.od', 'media-config.json');
|
|
const raw = await readFile(file, 'utf8');
|
|
return JSON.parse(raw);
|
|
}
|
|
|
|
it('writes a fresh entry when the slot is empty', async () => {
|
|
const wrote = await seedProviderIfMissing(projectRoot, 'senseaudio', {
|
|
apiKey: 'sa-test-key',
|
|
baseUrl: 'https://api.senseaudio.cn',
|
|
});
|
|
expect(wrote).toBe(true);
|
|
const stored = await readStoredJson();
|
|
expect(stored).toEqual({
|
|
providers: {
|
|
senseaudio: {
|
|
apiKey: 'sa-test-key',
|
|
baseUrl: 'https://api.senseaudio.cn',
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it('no-ops and preserves the stored key when one is already configured', async () => {
|
|
await writeStored({
|
|
providers: {
|
|
senseaudio: { apiKey: 'pre-existing-key', baseUrl: 'https://existing.example' },
|
|
},
|
|
});
|
|
const wrote = await seedProviderIfMissing(projectRoot, 'senseaudio', {
|
|
apiKey: 'newer-byok-key',
|
|
baseUrl: 'https://api.senseaudio.cn',
|
|
});
|
|
expect(wrote).toBe(false);
|
|
const stored = (await readStoredJson()) as { providers: Record<string, unknown> };
|
|
expect(stored.providers.senseaudio).toEqual({
|
|
apiKey: 'pre-existing-key',
|
|
baseUrl: 'https://existing.example',
|
|
});
|
|
});
|
|
|
|
it('preserves every other provider and aliases when seeding', async () => {
|
|
await writeStored({
|
|
providers: {
|
|
openai: { apiKey: 'sk-openai', baseUrl: 'https://api.openai.com/v1' },
|
|
volcengine: { apiKey: 'ark-key', baseUrl: 'https://ark.cn-beijing.volces.com/api/v3' },
|
|
},
|
|
aliases: { 'doubao-seedream-3-0-t2i-250415': 'doubao-seedream-5-0' },
|
|
});
|
|
const wrote = await seedProviderIfMissing(projectRoot, 'senseaudio', {
|
|
apiKey: 'sa-new',
|
|
});
|
|
expect(wrote).toBe(true);
|
|
const stored = (await readStoredJson()) as {
|
|
providers: Record<string, unknown>;
|
|
aliases: Record<string, string>;
|
|
};
|
|
expect(stored.providers.openai).toEqual({
|
|
apiKey: 'sk-openai',
|
|
baseUrl: 'https://api.openai.com/v1',
|
|
});
|
|
expect(stored.providers.volcengine).toEqual({
|
|
apiKey: 'ark-key',
|
|
baseUrl: 'https://ark.cn-beijing.volces.com/api/v3',
|
|
});
|
|
expect(stored.providers.senseaudio).toEqual({ apiKey: 'sa-new' });
|
|
expect(stored.aliases).toEqual({
|
|
'doubao-seedream-3-0-t2i-250415': 'doubao-seedream-5-0',
|
|
});
|
|
});
|
|
|
|
it('no-ops when an env var resolves a key for the provider', async () => {
|
|
process.env.OD_SENSEAUDIO_API_KEY = 'env-key';
|
|
const wrote = await seedProviderIfMissing(projectRoot, 'senseaudio', {
|
|
apiKey: 'sa-byok-key',
|
|
baseUrl: 'https://api.senseaudio.cn',
|
|
});
|
|
expect(wrote).toBe(false);
|
|
await expect(readStoredJson()).rejects.toThrow();
|
|
});
|
|
|
|
it('no-ops on empty apiKey', async () => {
|
|
const wrote = await seedProviderIfMissing(projectRoot, 'senseaudio', {
|
|
apiKey: '',
|
|
baseUrl: 'https://api.senseaudio.cn',
|
|
});
|
|
expect(wrote).toBe(false);
|
|
await expect(readStoredJson()).rejects.toThrow();
|
|
});
|
|
|
|
it('no-ops for unknown provider ids', async () => {
|
|
const wrote = await seedProviderIfMissing(projectRoot, 'not-a-provider', {
|
|
apiKey: 'whatever',
|
|
});
|
|
expect(wrote).toBe(false);
|
|
await expect(readStoredJson()).rejects.toThrow();
|
|
});
|
|
|
|
it('resolves the seeded key through resolveProviderConfig', async () => {
|
|
await seedProviderIfMissing(projectRoot, 'senseaudio', {
|
|
apiKey: 'sa-final',
|
|
baseUrl: 'https://api.senseaudio.cn',
|
|
});
|
|
const resolved = await resolveProviderConfig(projectRoot, 'senseaudio');
|
|
expect(resolved).toEqual({
|
|
apiKey: 'sa-final',
|
|
baseUrl: 'https://api.senseaudio.cn',
|
|
});
|
|
});
|
|
});
|