diff --git a/packages/dmoss-agent/src/cli-main.ts b/packages/dmoss-agent/src/cli-main.ts index 9f8c42e..7587880 100644 --- a/packages/dmoss-agent/src/cli-main.ts +++ b/packages/dmoss-agent/src/cli-main.ts @@ -4,13 +4,13 @@ import { execSync } from 'node:child_process'; import os from 'node:os'; import { resolveCliAgentRuntimeOptions } from './cli/agent-runtime.js'; import { createCliToolApprovalHook, resolveCliSafetyMode } from './cli/approval.js'; -import { CliConfigWriteError, loadCliConfigFile, loadEnvFromAncestors, resolveCliConfig, resolveConfigDir, safeProcessCwd } from './cli/config.js'; +import { CliConfigFileError, CliConfigWriteError, loadCliConfigFile, loadEnvFromAncestors, resolveCliConfig, resolveConfigDir, safeProcessCwd } from './cli/config.js'; import { parseCliArgs } from './cli/args.js'; import { renderCliDoctor, cliDoctorHasFailure } from './cli/doctor.js'; import { displayHelp, displayVersion } from './cli/help.js'; import { createConfiguredGuardrailHooks } from './cli/guardrails.js'; import { createConfiguredHookCallbacks } from './cli/hooks.js'; -import { DMOSS_CLI_IDENTITY } from './cli/identity.js'; +import { buildDmossCliIdentity } from './cli/identity.js'; import type { AgentHooks } from './core/agent/agent-hooks.js'; import { createCliProvider } from './cli/providers.js'; import type { CliProviderRuntimeConfig } from './cli/providers.js'; @@ -473,7 +473,9 @@ async function main() { const agent = new DmossAgent({ llmProvider: createCliProvider(providerConfig), sessionStore, model, workspaceDir: workspace, - baseSystemPrompt: DMOSS_CLI_IDENTITY, + // Keep the Moss persona, but name the actual model so the agent can answer + // "which model are you?" honestly instead of substituting "Moss". + baseSystemPrompt: buildDmossCliIdentity({ model, usingBundledDefault: resolvedConfig.usingBundledDefault }), enableToolOutputTruncation: true, extraPromptLayers, skillPipeline, memoryContextProvider: () => memoryManager.buildDigest(), ...resolveCliAgentRuntimeOptions(resolvedConfig), @@ -582,9 +584,11 @@ async function main() { } main().catch((err) => { - // Config-write failures already carry a clean, actionable one-liner — show it - // alone instead of a raw Node stack from writeFileSync. - if (err instanceof CliConfigWriteError) { + // Config file errors already carry a clean, actionable one-liner — show it + // alone instead of a raw Node stack. A malformed/hand-edited config.json + // (parse error → CliConfigFileError) is just as likely as a write failure, + // so both get the same friendly treatment on every entry point. + if (err instanceof CliConfigWriteError || err instanceof CliConfigFileError) { console.error(`moss: ${err.message}`); process.exit(1); } diff --git a/packages/dmoss-agent/src/cli/help.ts b/packages/dmoss-agent/src/cli/help.ts index 9d7f6b3..3071338 100644 --- a/packages/dmoss-agent/src/cli/help.ts +++ b/packages/dmoss-agent/src/cli/help.ts @@ -124,7 +124,7 @@ export function displayHelp(c: Colors, options: { all?: boolean } = {}): void { ` ${c.yellow('--base-url')} ${c.dim('')} override provider base URL`, ` ${c.yellow('--session')} ${c.dim('')} continue or create a named session key`, ` ${c.yellow('--last')} with resume/fork, use latest session`, - ` ${c.yellow('--ask-for-approval')} ${c.dim('

')} never | on-request | untrusted`, + ` ${c.yellow('--ask-for-approval')} ${c.dim('

')} never | prompt | on-request | read-only | workspace-write | full-access`, ` ${c.yellow('--read-only')} block mutating tools`, ` ${c.yellow('--workspace-write')} allow workspace writes/exec with approval (default)`, ` ${c.yellow('--full-access')} allow device/external tools with approval`, diff --git a/packages/dmoss-agent/src/cli/identity.ts b/packages/dmoss-agent/src/cli/identity.ts index b995195..67441b7 100644 --- a/packages/dmoss-agent/src/cli/identity.ts +++ b/packages/dmoss-agent/src/cli/identity.ts @@ -4,17 +4,49 @@ * The CLI is itself a Moss host, so it owns the agent's identity (Moss core * stays vendor/persona-neutral). Without this, the model has no instruction * about who it is and free-associates a name (it was introducing itself as - * another assistant). Passed as `baseSystemPrompt`, so it sits first in the system prompt, - * ahead of the robotics domain prompt. Kept short and bilingual for - * cross-model consistency. + * another assistant). Passed as `baseSystemPrompt`, so it sits first in the + * system prompt, ahead of the robotics domain prompt. Kept short and bilingual + * for cross-model consistency. + * + * Persona vs. model honesty: "Moss" is the product/persona name and is kept — + * the agent must not role-play as a different assistant product. But the + * underlying language model is disclosed honestly: when the user asks which + * model powers Moss, the agent names the actual model instead of substituting + * "Moss" for the model name. + */ +export function buildDmossCliIdentity( + options: { model?: string; usingBundledDefault?: boolean } = {}, +): string { + const modelLineEn = options.usingBundledDefault + ? " You currently run on D-Robotics' built-in model gateway." + : options.model + ? ` You currently run on the \`${options.model}\` model.` + : ''; + const modelLineZh = options.usingBundledDefault + ? ' 你当前运行在地瓜机器人的内置模型网关上。' + : options.model + ? ` 你当前运行在 \`${options.model}\` 模型上。` + : ''; + return [ + 'You are Moss, an AI agent developed by D-Robotics (地瓜机器人). You help users get work done across ' + + 'their computer and RDK boards — code, device operations, and ROS/robotics tasks. ' + + 'Moss is your name and product identity; keep it and do not role-play as a different assistant product. ' + + 'But be honest about the model underneath: if the user asks which language model powers you, name the actual ' + + 'model truthfully — do not substitute "Moss" for the model name.' + + modelLineEn, + '', + '你是 Moss,地瓜机器人(D-Robotics)研发的 Agent。你在用户的电脑与 RDK 开发板上,' + + '协助完成代码、设备操作与 ROS/机器人任务。Moss 是你的名字与产品身份,请保持,不要扮演成其他助手产品。' + + '但对底层模型要诚实:用户若问你用的是什么模型,请如实说出实际模型,不要用"Moss"代替模型名。' + + modelLineZh, + '', + 'Think through each step before you act. 每一步都要先想清楚再做。', + ].join('\n'); +} + +/** + * Back-compatible identity string with no specific model named. Prefer + * {@link buildDmossCliIdentity} so the active model is disclosed honestly. + * @public */ -export const DMOSS_CLI_IDENTITY = [ - 'You are Moss, an AI agent developed by D-Robotics (地瓜机器人). You help users get work done across ' + - 'their computer and RDK boards — code, device operations, and ' + - 'ROS/robotics tasks. Identify yourself as Moss; never claim to be any other assistant.', - '', - '你是 Moss,地瓜机器人(D-Robotics)研发的 Agent。你在用户的电脑与 RDK 开发板上,' + - '协助完成代码、设备操作与 ROS/机器人任务。请以 Moss 自称,不要自称其他助手。', - '', - 'Think through each step before you act. 每一步都要先想清楚再做。', -].join('\n'); +export const DMOSS_CLI_IDENTITY = buildDmossCliIdentity(); diff --git a/packages/dmoss-agent/src/cli/model-catalog.ts b/packages/dmoss-agent/src/cli/model-catalog.ts index ce3c4d6..57e4161 100644 --- a/packages/dmoss-agent/src/cli/model-catalog.ts +++ b/packages/dmoss-agent/src/cli/model-catalog.ts @@ -162,13 +162,24 @@ export function parseCustomModelConfigInput(input: string): CustomModelConfigPar } export function formatCustomModelConfigInstructions(configPath?: string): string { + // Preset-prefilled, copy-paste-ready lines for the common first-party + // providers so the user only pastes their key — no looking up base_url/model. + // `moss setup` remains the guided alternative with a hidden key field. + const presetLine = (p: CliProviderPreset): string => { + const preset = PROVIDER_PRESETS[p]; + return ` ${preset.displayName.padEnd(10)} /model config provider=${p} base_url=${preset.defaultBaseUrl} model_name=${preset.defaultModel} key=`; + }; return [ - 'Configure a custom model', + 'Add your own model & key', ` config file ${configPath || '(default user config)'}`, - ' command /model config base_url= key= model_name= [image_input=true]', '', - 'Example:', - ' /model config base_url=https://your-gateway.example/v1 key=sk-... model_name=your-model-name', + 'Pick your provider, paste your key after key=, and press Enter:', + presetLine('deepseek'), + presetLine('qwen'), + presetLine('openai'), + ' Custom /model config base_url= model_name= key= [image_input=true]', + '', + 'Or run `moss setup` for a guided prompt with a hidden key field.', ].join('\n'); } diff --git a/packages/dmoss-agent/src/cli/providers.ts b/packages/dmoss-agent/src/cli/providers.ts index d2085c5..ee1670c 100644 --- a/packages/dmoss-agent/src/cli/providers.ts +++ b/packages/dmoss-agent/src/cli/providers.ts @@ -310,7 +310,16 @@ async function callOpenAI( if (!res.ok) { const text = await res.text(); - throw providerError('OpenAI-compatible', res.status, text); + const error = providerError('OpenAI-compatible', res.status, text); + // A fresh install talks to the shared built-in gateway. When that gateway + // is over quota / rate-limited / payment-required, the upstream body is a + // raw (often non-English) limit message — a newcomer cannot tell the free + // pool is just depleted. Give them the one actionable way forward. + if (config.usingBundledDefault && (res.status === 429 || res.status === 402 || res.status === 503)) { + error.message += + '\nThe free built-in Moss model is over its shared quota right now — run `moss setup` to use your own model key (DeepSeek/Qwen/OpenAI/Anthropic/any OpenAI-compatible), or try again later.'; + } + throw error; } const data: OpenAIResponse = (await res.json()) as OpenAIResponse; diff --git a/packages/dmoss-agent/src/cli/setup.ts b/packages/dmoss-agent/src/cli/setup.ts index e4c88f4..4fd3704 100644 --- a/packages/dmoss-agent/src/cli/setup.ts +++ b/packages/dmoss-agent/src/cli/setup.ts @@ -22,12 +22,44 @@ import { saveConfigFileAtPath, type CliProviderPreset, type ConfigFile, + type ResolvedCliConfig, } from './config.js'; import { clearDmossCommunityAuthSession, formatCommunityAuthStatus, getDmossCommunityAuthStatus, } from './community-auth.js'; +import { loadModelChoicesForRuntime } from './model-catalog.js'; + +/** + * After saving a model config, check whether the gateway is actually reachable + * with the configured key, so `moss setup` reports a verified outcome instead + * of an unconditional "Saved configuration". Reuses the live /v1/models probe + * (timeout-bounded, never throws). Anthropic has no /v1/models, so it is + * reported as saved-but-unchecked rather than failed. + * @internal + */ +export async function probeSetupReachability( + config: Partial, + options: { fetchImpl?: typeof fetch; timeoutMs?: number } = {}, +): Promise { + let result; + try { + result = await loadModelChoicesForRuntime(config, config.model ?? '', { + timeoutMs: options.timeoutMs ?? 2500, + fetchImpl: options.fetchImpl, + }); + } catch { + return 'Saved, but could not reach the gateway with this key — check baseUrl/key, then re-run `moss setup`.'; + } + if (result.source === 'live') { + return `Configured and reachable — ${result.choices.length} model(s) available from the gateway.`; + } + if (result.warning) { + return 'Saved, but could not reach the gateway with this key — check baseUrl/key, then re-run `moss setup`.'; + } + return `Key saved (${result.providerLabel} — skipping live reachability check).`; +} function print(line = ''): void { output.write(`${line}\n`); @@ -319,7 +351,8 @@ export function renderConfigUsage(): string { ' moss config show', ' moss config show --json', ' moss config validate [--strict] [--json]', - ' moss config set ', + ' moss config set # model', + ' moss config set # operational', ' moss config set --project ', ' moss config unset ', ' moss config unset --project ', @@ -336,6 +369,7 @@ export function renderConfigUsage(): string { ' moss config set provider openai-compatible', ' moss config set model ', ' moss config set baseUrl https://your-gateway.example/v1', + ' moss config set apiKey ', ' moss config set imageInput true', ' moss config set --project safetyMode workspace-write', ' moss config set approvalPolicy prompt', @@ -418,10 +452,25 @@ export async function runSetupWizard(): Promise { const defaultModel = current.model || preset.defaultModel; const defaultBaseUrl = current.baseUrl || preset.defaultBaseUrl; - const modelAnswer = rl ? await questionWith(rl, `Model [${defaultModel}]: `) : nextPipedAnswer(); - const model = modelAnswer || defaultModel; - const baseUrlAnswer = rl ? await questionWith(rl, `Base URL [${defaultBaseUrl}]: `) : nextPipedAnswer(); - const baseUrlInput = baseUrlAnswer || defaultBaseUrl; + // Key-only fast path: a first-party preset already carries a sensible model + // and base URL, so an interactive user goes straight to the API key (2 inputs + // instead of 4). openai-compatible has no default endpoint, so it keeps the + // full prompts. Non-TTY (piped) always asks every field in order so scripted + // answer lines stay aligned with the existing tests. + const fastPath = Boolean(rl) && provider !== 'openai-compatible'; + let model: string; + let baseUrlInput: string; + if (fastPath) { + model = defaultModel; + baseUrlInput = defaultBaseUrl; + print(`Using ${preset.displayName} defaults — model ${defaultModel}, base URL ${defaultBaseUrl}.`); + print('(Change later with `moss config set model ` or `moss config set baseUrl `.)'); + } else { + const modelAnswer = rl ? await questionWith(rl, `Model [${defaultModel}]: `) : nextPipedAnswer(); + model = modelAnswer || defaultModel; + const baseUrlAnswer = rl ? await questionWith(rl, `Base URL [${defaultBaseUrl}]: `) : nextPipedAnswer(); + baseUrlInput = baseUrlAnswer || defaultBaseUrl; + } if (!isHttpUrl(baseUrlInput)) { rl?.close(); print(`Setup cancelled: base URL must be a full http(s) URL, got: ${baseUrlInput}`); @@ -460,6 +509,14 @@ export async function runSetupWizard(): Promise { print(`Model: ${model}`); print(`Base URL: ${withoutSecret(baseUrl)}`); print(`Image input: ${imageInput ? 'enabled' : 'disabled'}${imageInput ? '' : ' (enable with `moss config set imageInput true` for a vision-capable gateway)'}`); + // No success claim without a verified outcome: probe the gateway interactively + // so the user knows the key actually works, not just that it was written. + // Skipped for non-TTY/scripted setup so CI does not make a network call. + if (input.isTTY) { + print(''); + print('Checking the gateway…'); + print(await probeSetupReachability({ provider, model, baseUrl, apiKey })); + } print(''); print('Try `dmoss "explain this project and how to run it"` or run `dmoss` for interactive mode.'); } @@ -584,7 +641,7 @@ function buildProjectConfigTemplate(): ConfigFile { } function supportedConfigKeys(): string { - return 'Supported keys: profile, provider, model, baseUrl, imageInput, workspace, safetyMode, approvalPolicy, trustedTools, deniedTools, promptCache, promptCacheDebug, guardrails.input.blockPatterns, guardrails.input.redactPatterns, guardrails.output.blockPatterns, guardrails.output.redactPatterns, mcp.enabled, mcp.configPath, agent.maxTurns, agent.contextTokens, agent.compaction.reserveTokens, agent.compaction.keepRecentTokens'; + return 'Supported keys — model: provider, model, baseUrl, apiKey, imageInput; operational: profile, workspace, safetyMode, approvalPolicy, trustedTools, deniedTools, promptCache, promptCacheDebug, guardrails.input.blockPatterns, guardrails.input.redactPatterns, guardrails.output.blockPatterns, guardrails.output.redactPatterns, mcp.enabled, mcp.configPath, agent.maxTurns, agent.contextTokens, agent.compaction.reserveTokens, agent.compaction.keepRecentTokens'; } function removeEmptyNestedConfig(config: ConfigFile): ConfigFile { @@ -664,6 +721,7 @@ export function runConfigSet(args: string[], startDir = process.cwd()): void { next.provider = provider; } else if (key === 'model') next.model = value; + else if (key === 'apiKey') next.apiKey = value; else if (key === 'baseUrl') { if (!isHttpUrl(value)) { print(`Invalid baseUrl: ${value.trim()}`); @@ -800,6 +858,10 @@ export function runConfigSet(args: string[], startDir = process.cwd()): void { saveConfigFileAtPath(next, target.configPath); const scope = target.scope === 'project' ? 'project ' : ''; print(`[config] ${scope}${key} updated in ${target.configPath}`); + if (key === 'apiKey') { + // Never echo the key value; just confirm and flag the shell-history caveat. + print('[config] The API key is stored in the config file; avoid leaving it in shell history.'); + } } export function runConfigUnset(args: string[], startDir = process.cwd()): void { @@ -893,8 +955,8 @@ export function printMissingConfigGuidance(interactive: boolean, options: { bund print('Script path (no TTY — model settings are read from config files, never env vars):'); print(' moss config set provider deepseek'); print(' moss config set model deepseek-chat'); - print(' write the API key with `moss setup` once, or provide a config file:'); - print(' moss --config-file /path/to/config.json # {"provider":"deepseek","apiKey":"..."}'); + print(' moss config set apiKey '); + print(' # or point Moss at a JSON file: moss --config-file /path/to/config.json # {"provider":"deepseek","apiKey":"..."}'); print(''); if (interactive) { print('You can run setup now, then start `dmoss` again.'); diff --git a/packages/dmoss-agent/test/cli-config-setup.spec.mjs b/packages/dmoss-agent/test/cli-config-setup.spec.mjs index 6b87f95..b471411 100644 --- a/packages/dmoss-agent/test/cli-config-setup.spec.mjs +++ b/packages/dmoss-agent/test/cli-config-setup.spec.mjs @@ -23,6 +23,7 @@ import { import { resolveCliAgentRuntimeOptions } from '../dist/cli/agent-runtime.js'; import { auditResolvedCliConfig as auditResolvedCliConfigFromRoot } from '../dist/index.js'; import { + probeSetupReachability, renderAuthStatus, renderConfigJson, renderConfigUsage, @@ -32,6 +33,30 @@ import { runConfigValidate, } from '../dist/cli/setup.js'; +// #2 — `moss setup` must verify the gateway is reachable, not just claim "Saved". +{ + const okFetch = async () => ({ ok: true, json: async () => ({ data: [{ id: 'm1' }, { id: 'm2' }] }) }); + const reachable = await probeSetupReachability( + { provider: 'deepseek', model: 'm1', baseUrl: 'https://gw.example', apiKey: 'k' }, + { fetchImpl: okFetch }, + ); + assert.match(reachable, /reachable/i, 'a working key/gateway is reported as reachable'); + assert.match(reachable, /2 model/, 'reachable message reports the live model count'); + + const badFetch = async () => ({ ok: false, json: async () => ({}) }); + const unreachable = await probeSetupReachability( + { provider: 'deepseek', model: 'm1', baseUrl: 'https://gw.example', apiKey: 'bad' }, + { fetchImpl: badFetch }, + ); + assert.match(unreachable, /could not reach/i, 'a bad key/gateway must NOT be reported as configured-ok'); + + const anthropic = await probeSetupReachability( + { provider: 'anthropic', model: 'claude', baseUrl: 'https://api.anthropic.com', apiKey: 'k' }, + { fetchImpl: okFetch }, + ); + assert.match(anthropic, /skipping live/i, 'anthropic (no /v1/models) is saved-but-unchecked, not failed'); +} + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dmoss-cli-config-')); const oldConfigDir = process.env.DMOSS_CONFIG_DIR; const oldConfigFile = process.env.DMOSS_CONFIG_FILE; @@ -990,6 +1015,25 @@ try { assert.equal(loadConfigFile().profile, 'autonomous'); runConfigSet(['model', 'qwen-plus']); assert.equal(loadConfigFile().model, 'qwen-plus'); + // apiKey is now a settable config key. The auth-failure guidance advertised + // `moss config set apiKey ...` but no handler existed (it fell through to the + // "Supported keys" error). The confirmation must NOT echo the secret. + { + const origWrite = process.stdout.write.bind(process.stdout); + let captured = ''; + process.stdout.write = (chunk, ...rest) => { + captured += typeof chunk === 'string' ? chunk : chunk.toString(); + return origWrite(chunk, ...rest); + }; + try { + runConfigSet(['apiKey', 'topsecret-do-not-print-abc']); + } finally { + process.stdout.write = origWrite; + } + assert.equal(loadConfigFile().apiKey, 'topsecret-do-not-print-abc', 'config set apiKey writes the key'); + assert.doesNotMatch(captured, /topsecret-do-not-print-abc/, 'config set apiKey must never echo the key value'); + assert.match(renderConfigUsage(), /apiKey/, 'usage must list apiKey as a settable key'); + } runConfigSet(['baseUrl', 'https://example.com/v1/']); assert.equal(loadConfigFile().baseUrl, 'https://example.com'); runConfigSet(['baseUrl', 'https://user:pass@example.com/compatible-mode/v1?api_key=secret#frag']); diff --git a/packages/dmoss-agent/test/cli-identity.spec.mjs b/packages/dmoss-agent/test/cli-identity.spec.mjs index 59e8b6d..2a98df5 100644 --- a/packages/dmoss-agent/test/cli-identity.spec.mjs +++ b/packages/dmoss-agent/test/cli-identity.spec.mjs @@ -23,9 +23,12 @@ import test from 'node:test'; import { fileURLToPath } from 'node:url'; import { DmossAgent, InMemorySessionStore } from '../dist/core/index.js'; import { PiAiLLMProvider } from '../dist/provider/index.js'; -import { DMOSS_CLI_IDENTITY } from '../dist/cli/identity.js'; +import { DMOSS_CLI_IDENTITY, buildDmossCliIdentity } from '../dist/cli/identity.js'; -const UNIQUE_CLAUSE = /never claim to be any other assistant/; +// Persona is kept (do not role-play as a different assistant product), but the +// underlying model is disclosed honestly (no substituting "Moss" for the model). +const UNIQUE_CLAUSE = /do not role-play as a different assistant product/; +const MODEL_HONESTY = /be honest about the model/i; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const cliPath = path.resolve(__dirname, '../dist/cli.js'); @@ -45,15 +48,26 @@ function newAgent(extra = {}) { }); } -test('DMOSS_CLI_IDENTITY names Moss and D-Robotics/地瓜机器人 and forbids other names', () => { +test('DMOSS_CLI_IDENTITY keeps the Moss persona but is honest about the model', () => { assert.match(DMOSS_CLI_IDENTITY, /\bMoss\b/); assert.match(DMOSS_CLI_IDENTITY, /D-Robotics/); assert.match(DMOSS_CLI_IDENTITY, /地瓜机器人/); assert.match(DMOSS_CLI_IDENTITY, UNIQUE_CLAUSE); + assert.match(DMOSS_CLI_IDENTITY, MODEL_HONESTY, 'identity must allow honest model disclosure'); // bilingual: includes the Chinese identity too assert.match(DMOSS_CLI_IDENTITY, /地瓜机器人(D-Robotics)研发的 Agent/); }); +test('buildDmossCliIdentity names the actual model (honest, not substituted with "Moss")', () => { + const withModel = buildDmossCliIdentity({ model: 'deepseek-v4-pro' }); + assert.match(withModel, /\bMoss\b/, 'persona name is still Moss'); + assert.match(withModel, /deepseek-v4-pro/, 'the real model name is disclosed'); + assert.match(withModel, MODEL_HONESTY); + // bundled gateway: honestly says built-in gateway rather than a fake model name + const bundled = buildDmossCliIdentity({ usingBundledDefault: true, model: 'Moss' }); + assert.match(bundled, /built-in model gateway/); +}); + test('without an identity baseSystemPrompt the system prompt has no identity (bug baseline)', () => { const frame = newAgent().buildSystemPrompt(); assert.doesNotMatch(frame, UNIQUE_CLAUSE); diff --git a/packages/dmoss-agent/test/cli-model-catalog.spec.mjs b/packages/dmoss-agent/test/cli-model-catalog.spec.mjs index 84dc030..ee74999 100644 --- a/packages/dmoss-agent/test/cli-model-catalog.spec.mjs +++ b/packages/dmoss-agent/test/cli-model-catalog.spec.mjs @@ -8,12 +8,32 @@ */ import assert from 'node:assert/strict'; import { + formatCustomModelConfigInstructions, formatModelChoices, loadModelChoicesForRuntime, parseCustomModelConfigInput, resolveModelSelection, } from '../dist/cli/model-catalog.js'; +// In-session BYOK instructions must be preset-prefilled (no looking up base_url/ +// model): a user picks their provider's line and only pastes the key. The +// prefilled lines must themselves parse via parseCustomModelConfigInput. +{ + const instr = formatCustomModelConfigInstructions('/tmp/cfg.json'); + assert.match(instr, /deepseek/i, 'instructions list DeepSeek as a ready line'); + assert.match(instr, /api\.deepseek\.com/, 'DeepSeek line prefills the base URL'); + assert.match(instr, /paste-your-key/, 'the only thing the user fills is the key'); + assert.match(instr, /moss setup/, 'instructions point at the guided setup alternative'); + // The DeepSeek prefilled line (with a real key substituted) must actually parse. + const dsLine = instr.split('\n').find((l) => /provider=deepseek/.test(l)) ?? ''; + const raw = dsLine.slice(dsLine.indexOf('provider=')).replace('key=', 'key=fake-key-1234'); + const parsed = parseCustomModelConfigInput(raw); + assert.equal(parsed.ok, true, 'the prefilled DeepSeek line parses once a key is pasted'); + assert.equal(parsed.config.provider, 'deepseek'); + assert.equal(parsed.config.baseUrl, 'https://api.deepseek.com'); + assert.equal(parsed.config.apiKey, 'fake-key-1234'); +} + const builtIn = await loadModelChoicesForRuntime({ provider: 'openai-compatible', model: 'Moss',