Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

</details>
Expand Down
2 changes: 2 additions & 0 deletions README_ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)。

</details>
Expand Down
14 changes: 13 additions & 1 deletion docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,19 @@ pnpm mcode provider test <provider-id> --model <model-id>
pnpm mcode exec "Explain this project's test entry points" --model <provider-id>/<model-id>
```

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.

Expand Down
16 changes: 15 additions & 1 deletion packages/tui/src/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,13 +192,17 @@ export function createTuiProgram(options: CreateTuiProgramOptions): Command {
)
.option('--model <id>', 'model ID (repeatable)', collectOptionValue, [])
.option('--api-key-env <name>', 'environment variable containing the API key')
.option('--use', 'select the first model as the default')
.option('--context-limit <tokens>', 'context limit for every listed model', parsePositiveSafeInteger)
.option('--output-limit <tokens>', '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;
}) => {
Expand All @@ -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,
});
Expand Down Expand Up @@ -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;
}
42 changes: 38 additions & 4 deletions packages/tui/src/cli/provider-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -58,14 +60,46 @@ export async function runMcodeProviderCommand(
`Provider API key is missing. Set ${envName} or pass --api-key-env <name>.`,
);
}
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 <id> 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') {
Expand Down
2 changes: 2 additions & 0 deletions packages/tui/src/provider/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
Expand Down
2 changes: 2 additions & 0 deletions packages/tui/src/provider/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
6 changes: 5 additions & 1 deletion packages/tui/test/unit/provider-application.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => ({
Expand Down Expand Up @@ -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();
});

Expand Down
60 changes: 59 additions & 1 deletion test/byok.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 = "";
Expand All @@ -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({
Expand Down Expand Up @@ -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"),
Expand All @@ -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",
Expand Down
19 changes: 19 additions & 0 deletions test/smoke.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading