From e6384134d986280ae6b175392b3338761e5b34e6 Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Sat, 19 Sep 2026 07:20:12 +0800 Subject: [PATCH] fix: test provider candidates before use and expose token limits --- README.md | 2 + README_ZH.md | 2 + docs/examples.md | 14 ++++- packages/tui/src/cli/program.ts | 16 ++++- packages/tui/src/cli/provider-command.ts | 42 +++++++++++-- packages/tui/src/provider/application.ts | 2 + packages/tui/src/provider/contract.ts | 2 + .../test/unit/provider-application.test.ts | 6 +- test/byok.test.mjs | 60 ++++++++++++++++++- test/smoke.test.mjs | 19 ++++++ 10 files changed, 157 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index d64bdb2..55ac301 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,8 @@ mcode provider add --name my-provider --base-url https://example.com/v1 \ mcode ``` +`--use` tests the first listed model before saving and selecting it. A failed connection test saves nothing. Omit `--use` to save without testing or changing the default model. For custom/local models, add `--context-limit 32768 --output-limit 4096` (use your server's actual limits). Each value must be a positive safe integer and applies to every repeated `--model`. Inspect configured limits with `mcode provider list --json`. Omitting these flags preserves the existing model-limit defaults. + Supported API formats: `openai-completions`, `openai-responses`, and `anthropic-messages`. See the [model examples](docs/examples.md#2-choose-your-own-model) for environment variable setup, connection checks, and model overrides for a single run. diff --git a/README_ZH.md b/README_ZH.md index 7596f2f..b436d2f 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -92,6 +92,8 @@ mcode provider add --name my-provider --base-url https://example.com/v1 \ mcode ``` +`--use` 会先测试第一个模型,成功后保存并设为默认模型;连接测试失败时不保存。省略 `--use` 则仅保存,不测试,也不改变默认模型。对于自定义或本地模型,可添加 `--context-limit 32768 --output-limit 4096`(请填写服务器的实际限制)。两个值都必须是正安全整数,并应用于所有重复指定的 `--model`。可通过 `mcode provider list --json` 查看已配置的限制。省略这两个参数时保持现有的模型限制默认值。 + 支持 `openai-completions`、`openai-responses` 和 `anthropic-messages`。连接测试、单次模型切换及环境变量设置见 [模型示例](docs/examples.md#2-choose-your-own-model)。 diff --git a/docs/examples.md b/docs/examples.md index 40e2e68..ff2fbef 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -50,7 +50,19 @@ pnpm mcode provider test --model pnpm mcode exec "Explain this project's test entry points" --model / ``` -Replace the example URL, model name, and IDs with your configuration and the IDs returned by the list command. `--use` sets the default model; `exec --model` overrides only the current run. Backslash line continuations are for POSIX shells; use a single line in PowerShell. +Replace the example URL, model name, and IDs with your configuration and the IDs returned by the list command. `--use` tests the first listed model, then saves the provider and selects that model as the default. A failed connection test exits nonzero without saving or changing the default; correct the URL, key, or first model ID and retry. Omit `--use` to save without a connection test or default-model change. `exec --model` overrides only the current run. Backslash line continuations are for POSIX shells; use a single line in PowerShell. + +For a local server, configure its actual token limits explicitly: + +```bash +pnpm mcode provider add --name local-models --base-url http://localhost:8080/v1 \ + --api-format openai-completions --model local-model --model another-model \ + --api-key-env MCODE_PROVIDER_API_KEY \ + --context-limit 32768 --output-limit 4096 --use +pnpm mcode provider list --json +``` + +`--context-limit` and `--output-limit` each accept a positive safe integer (at most `9007199254740991`). Either flag can be used independently. The same limits apply to every repeated `--model`; only the first model is tested and selected by `--use`. The JSON list shows the configured values as `contextLimit` and `maxOutputTokens`. Without these flags, the existing defaults remain unchanged (unknown custom models currently fall back to 200,000 context tokens and 16,384 output tokens). Model discovery does not infer your local server's context size. [Live acceptance](verification.md) separately verified MiniMax Token Plan and one configured BYOK provider. This is not a guarantee for every compatible service. diff --git a/packages/tui/src/cli/program.ts b/packages/tui/src/cli/program.ts index 6327ff2..f2e33a6 100644 --- a/packages/tui/src/cli/program.ts +++ b/packages/tui/src/cli/program.ts @@ -192,13 +192,17 @@ export function createTuiProgram(options: CreateTuiProgramOptions): Command { ) .option('--model ', 'model ID (repeatable)', collectOptionValue, []) .option('--api-key-env ', 'environment variable containing the API key') - .option('--use', 'select the first model as the default') + .option('--context-limit ', 'context limit for every listed model', parsePositiveSafeInteger) + .option('--output-limit ', 'output limit for every listed model', parsePositiveSafeInteger) + .option('--use', 'test the first model, then save and select it as the default') .action( (commandOptions: { name: string; baseUrl: string; apiFormat: McodeProviderApiFormat; model: string[]; + contextLimit?: number; + outputLimit?: number; apiKeyEnv?: string; use?: boolean; }) => { @@ -211,6 +215,8 @@ export function createTuiProgram(options: CreateTuiProgramOptions): Command { baseUrl: commandOptions.baseUrl, apiFormat: commandOptions.apiFormat, models: commandOptions.model, + contextLimit: commandOptions.contextLimit, + outputLimit: commandOptions.outputLimit, apiKeyEnv: commandOptions.apiKeyEnv, saveAndUse: commandOptions.use, }); @@ -415,3 +421,11 @@ function requireTelemetryRunner(options: CreateTuiProgramOptions) { if (!options.runTelemetry) throw new Error('Telemetry inspection is unavailable.'); return options.runTelemetry; } + +function parsePositiveSafeInteger(value: string): number { + const number = Number(value); + if (!Number.isSafeInteger(number) || number <= 0) { + throw new InvalidArgumentError('expected a positive safe integer'); + } + return number; +} diff --git a/packages/tui/src/cli/provider-command.ts b/packages/tui/src/cli/provider-command.ts index dea0982..a3ce462 100644 --- a/packages/tui/src/cli/provider-command.ts +++ b/packages/tui/src/cli/provider-command.ts @@ -12,6 +12,8 @@ export type McodeProviderCliRequest = readonly baseUrl: string; readonly apiFormat: McodeProviderApiFormat; readonly models: readonly string[]; + readonly contextLimit?: number; + readonly outputLimit?: number; readonly apiKeyEnv?: string; readonly saveAndUse?: boolean; } @@ -58,14 +60,46 @@ export async function runMcodeProviderCommand( `Provider API key is missing. Set ${envName} or pass --api-key-env .`, ); } - await context.application.create({ + const input = { name: request.name, baseUrl: request.baseUrl, apiKey, apiFormat: request.apiFormat, - models: request.models.map((modelId) => ({ modelId })), - saveAndUse: request.saveAndUse, - }); + models: request.models.map((modelId) => ({ + modelId, + ...(request.contextLimit !== undefined || request.outputLimit !== undefined + ? { + limit: { + ...(request.contextLimit !== undefined ? { context: request.contextLimit } : {}), + ...(request.outputLimit !== undefined ? { output: request.outputLimit } : {}), + }, + } + : {}), + })), + }; + if (request.saveAndUse) { + const modelId = request.models[0]; + if (!modelId) throw new Error('At least one --model is required.'); + const result = await context.application.saveCandidate({ + ...input, + modelId, + saveAndUse: true, + }); + if (!result.success) { + throw new Error( + formatTuiActionFailure( + result.status?.lastErrorMessage ?? result.status?.state ?? 'Connection unavailable', + { + summary: 'Provider connection test failed. Nothing was saved or selected.', + nextStep: + 'Check the URL, API key, and first model ID, then retry; omit --use to save without testing.', + }, + ), + ); + } + return `Provider added and selected: ${request.name}`; + } + await context.application.create(input); return `Provider added: ${request.name}`; } if (request.action === 'remove') { diff --git a/packages/tui/src/provider/application.ts b/packages/tui/src/provider/application.ts index 01751bf..5eda027 100644 --- a/packages/tui/src/provider/application.ts +++ b/packages/tui/src/provider/application.ts @@ -189,6 +189,8 @@ function normalizeCustomProvider(provider: McodeRuntimeProviderView): McodeProvi modelId: model.modelId, ...(model.displayName ? { displayName: model.displayName } : {}), ...(model.selected !== undefined ? { selected: model.selected } : {}), + ...(model.contextLimit !== undefined ? { contextLimit: model.contextLimit } : {}), + ...(model.maxOutputTokens !== undefined ? { maxOutputTokens: model.maxOutputTokens } : {}), ...(model.status ? { status: model.status } : {}), })), ...(provider.status ? { status: provider.status } : {}), diff --git a/packages/tui/src/provider/contract.ts b/packages/tui/src/provider/contract.ts index c90d287..df204e3 100644 --- a/packages/tui/src/provider/contract.ts +++ b/packages/tui/src/provider/contract.ts @@ -24,6 +24,8 @@ export interface McodeProviderModel { readonly modelId: string; readonly displayName?: string; readonly selected?: boolean; + readonly contextLimit?: number; + readonly maxOutputTokens?: number; readonly status?: McodeProviderStatus; } diff --git a/packages/tui/test/unit/provider-application.test.ts b/packages/tui/test/unit/provider-application.test.ts index a8d0b41..2c1d19d 100644 --- a/packages/tui/test/unit/provider-application.test.ts +++ b/packages/tui/test/unit/provider-application.test.ts @@ -28,7 +28,7 @@ function createPort() { configRevision: 'rev-1', maskedApiKey: 'sk-****1234', rawApiKey: 'must-never-cross-the-cli-boundary', - models: [{ modelId: 'gpt-4.1', displayName: 'GPT-4.1' }], + models: [{ modelId: 'gpt-4.1', displayName: 'GPT-4.1', contextLimit: 32768, maxOutputTokens: 4096 }], }, ]), getMiniMaxApiKeyStatus: vi.fn(async () => ({ @@ -118,6 +118,10 @@ describe('McodeProviderApplication', () => { expect(JSON.stringify(snapshot)).toContain('MiniMax OAuth'); expect(JSON.stringify(snapshot)).not.toContain('must-never-cross-the-cli-boundary'); expect(snapshot.providers[2]).not.toHaveProperty('rawApiKey'); + expect(snapshot.providers[2]?.models[0]).toMatchObject({ + contextLimit: 32768, + maxOutputTokens: 4096, + }); expect(port.getCodexOAuthStatus).not.toHaveBeenCalled(); }); diff --git a/test/byok.test.mjs b/test/byok.test.mjs index ab131ab..cb93bf2 100644 --- a/test/byok.test.mjs +++ b/test/byok.test.mjs @@ -14,6 +14,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import Database from "better-sqlite3"; +import { parse as parseYaml } from "yaml"; const cli = fileURLToPath(new URL("../dist/cli.js", import.meta.url)); // This fixture validates BYOK transport and real Runtime persistence, not model quality. @@ -38,6 +39,7 @@ test( const readMarker = `ACTUAL_FILE_CONTENT_${Date.now()}`; writeFileSync(path.join(dataDir, "read-fixture.txt"), readMarker); let toolRequested = false; + let rejectConnection = false; const networkAudit = path.join(dataDir, "network-audit.log"); const server = createServer(async (req, res) => { let raw = ""; @@ -48,6 +50,12 @@ test( res.writeHead(404).end(); return; } + if (rejectConnection) { + res.writeHead(401, { "content-type": "application/json" }).end( + JSON.stringify({ error: { message: "Synthetic invalid credential" } }), + ); + return; + } if (!body.stream) { res.writeHead(200, { "content-type": "application/json" }).end( JSON.stringify({ @@ -229,6 +237,7 @@ test( "--model", "fixture-model", ]); + assert.equal(requests.length, 0, "Adding without --use must not test or activate"); const snapshot = JSON.parse(await run(["provider", "list", "--json"])); assert.equal( snapshot.providers.some((p) => p.kind === "minimax-oauth"), @@ -245,11 +254,60 @@ test( "--model", "fixture-model", ]); + const configPath = path.join(dataDir, "config.yaml"); + const savedConfig = () => parseYaml(readFileSync(configPath, "utf8")); + assert.equal(savedConfig().defaultModel, "minimax/MiniMax-M3"); + assert.equal(savedConfig().custom_provider.fixture.models["fixture-model"].limit, undefined); + assert.equal(selected.active, false); + assert.equal(selected.models[0].contextLimit, undefined); + assert.equal(selected.models[0].maxOutputTokens, undefined); + + const addArgs = [ + "provider", "add", "--name", "Limited", "--base-url", baseUrl, + "--api-format", "openai-completions", "--model", "fixture-model", + "--model", "second-model", "--context-limit", "32768", "--output-limit", "4096", "--use", + ]; + const beforeFailure = readFileSync(configPath, "utf8"); + rejectConnection = true; + await assert.rejects(run(addArgs), /Provider connection test failed.*Nothing was saved/s); + assert.equal(readFileSync(configPath, "utf8"), beforeFailure); + rejectConnection = false; + const beforeAdd = requests.length; + assert.match(await run(addArgs), /Provider added and selected: Limited/); + assert.ok(requests.length > beforeAdd, "Activation must test the candidate before saving"); + assert.equal(requests[beforeAdd].body.model, "fixture-model"); + const config = savedConfig(); + assert.equal(config.defaultModel, "custom_provider:limited/fixture-model"); + for (const model of ["fixture-model", "second-model"]) { + assert.deepEqual(config.custom_provider.limited.models[model].limit, { context: 32768, output: 4096 }); + } + const configured = JSON.parse(await run(["provider", "list", "--json"])).providers.find( + (provider) => provider.name === "Limited", + ); + assert.equal(configured.active, true); + assert.equal(configured.models[0].selected, true); + for (const model of configured.models) { + assert.equal(model.contextLimit, 32768); + assert.equal(model.maxOutputTokens, 4096); + } + const beforeSaveOnly = requests.length; + for (const [name, flag, limit] of [ + ["context-only", "--context-limit", { context: 32768 }], + ["output-only", "--output-limit", { output: 32768 }], + ]) { + await run([ + "provider", "add", "--name", name, "--base-url", baseUrl, + "--api-format", "openai-completions", "--model", "fixture-model", flag, "32768", + ]); + assert.deepEqual(savedConfig().custom_provider[name].models["fixture-model"].limit, limit); + assert.equal(savedConfig().defaultModel, config.defaultModel); + } + assert.equal(requests.length, beforeSaveOnly, "Limits alone must not test or select a model"); const modelArgs = ["--model", `${selected.providerId}/fixture-model`]; + // The first run must work through the saved default, without --model or managed login. const first = await run([ "exec", "Remember this marker: SOURCE_REPOSITORY_TEST", - ...modelArgs, "--timeout", "20s", "--max-steps", diff --git a/test/smoke.test.mjs b/test/smoke.test.mjs index 50ecaa8..0ccb8a6 100644 --- a/test/smoke.test.mjs +++ b/test/smoke.test.mjs @@ -199,3 +199,22 @@ test("local plugin browsing remains available with managed services offline", (t assert.match(result.stdout, /Usage:/); } }); + +test("provider add validates token limits before opening the runtime", (t) => { + const options = fixture(t); + const args = [ + "provider", "add", "--name", "Invalid limits", "--base-url", "http://127.0.0.1:1/v1", + "--model", "synthetic-model", + ]; + for (const flag of ["--context-limit", "--output-limit"]) { + for (const value of ["0", "-1", "1.5", "NaN", "Infinity", "9007199254740992"]) { + const result = spawnSync(process.execPath, [cli, ...args, `${flag}=${value}`], { + ...options, encoding: "utf8", timeout: 15000, + }); + assert.equal(result.status, 1, result.stderr); + assert.match(result.stderr, /positive safe integer/); + assert.ok(result.stderr.includes(flag), result.stderr); + } + } + assert.equal(existsSync(path.join(options.cwd, "config.yaml")), false); +});