From 2154c04c805832369596bcb052ece6aa86aea973 Mon Sep 17 00:00:00 2001 From: hetaoBackend Date: Mon, 21 Sep 2026 10:37:52 +0800 Subject: [PATCH] fix: clarify Z.AI plan presets and allow endpoint retries --- docs/examples.md | 6 +- .../provider-presets.service.test.ts | 42 +++++++ .../provider-presets.service.ts | 14 ++- .../model-system/management/service.test.ts | 58 +++++++++ .../src/tui/features/provider/onboarding.ts | 47 ++++++-- .../test/unit/tui-provider-onboarding.test.ts | 111 ++++++++++++++++++ test/byok.test.mjs | 19 +++ 7 files changed, 286 insertions(+), 11 deletions(-) diff --git a/docs/examples.md b/docs/examples.md index da46a28c..bf2cba88 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -24,7 +24,11 @@ node /absolute/path/to/minimax-code/dist/cli.js --continue ## 2. Choose your own model -Use `/provider` in the interactive TUI to select a configured model. Before adding a custom provider, set a key in your current shell rather than putting it in command arguments or source: +Use `/model` in the interactive TUI to select a model or choose **+ Add 3rd-party provider…**; `/provider` manages saved connections. The known-provider picker labels Z.AI and Zhipu plans separately as **Coding Plan** and **API**. The regional default order puts Coding Plan first; remotely configured pinning can override that order. Choose the plan matching your key. On the model screen, review the Base URL or press **Ctrl+E** to edit it before testing. If the test fails, changes are not saved; the model and key draft remain available for editing and retry. Changing the URL requires another explicit test/save action and never triggers an automatic endpoint fallback. + +Preset IDs come from models.dev and do not select entries in the bundled inference registry. Onboarding saves the chosen URL under `custom_provider`; subsequent requests use that saved URL. + +Before adding a custom provider, set a key in your current shell rather than putting it in command arguments or source: ```bash # POSIX shell: read the key interactively without echoing it. diff --git a/packages/local-runtime-v2/src/service/model-system/catalog/provider-presets/provider-presets.service.test.ts b/packages/local-runtime-v2/src/service/model-system/catalog/provider-presets/provider-presets.service.test.ts index c72d1cdf..9617b225 100644 --- a/packages/local-runtime-v2/src/service/model-system/catalog/provider-presets/provider-presets.service.test.ts +++ b/packages/local-runtime-v2/src/service/model-system/catalog/provider-presets/provider-presets.service.test.ts @@ -178,6 +178,46 @@ async function parsePresetsForTest(catalog: Record, iconBaseUrl } describe('models.dev Provider Presets', () => { + it('distinguishes all Z.AI and Zhipu plans without changing catalog IDs or endpoints', async () => { + const plans = [ + ['zai', 'Z.AI API', 'https://api.z.ai/api/paas/v4'], + ['zai-coding-plan', 'Z.AI Coding Plan', 'https://api.z.ai/api/coding/paas/v4'], + ['zhipuai', 'Zhipu AI API', 'https://open.bigmodel.cn/api/paas/v4'], + [ + 'zhipuai-coding-plan', + 'Zhipu AI Coding Plan', + 'https://open.bigmodel.cn/api/coding/paas/v4', + ], + ]; + const presets = await parsePresetsForTest( + Object.fromEntries( + plans.map(([id, , api]) => [ + id, + { + name: 'Ambiguous upstream label', + npm: '@ai-sdk/openai-compatible', + api, + models: { 'glm-5.3': { name: 'GLM-5.3', tool_call: true } }, + }, + ]), + ), + ); + expect(presets).toHaveLength(4); + for (const [providerId, name, baseUrl] of plans) { + const preset = presets.find((item) => item.providerId === providerId); + expect(preset).toMatchObject({ + providerId, + name, + baseUrl, + apiFormat: 'openai-completions', + models: [{ modelId: 'glm-5.3' }], + }); + expect(providerCompletionUrl('openai-completions', preset!.baseUrl)).toBe( + `${baseUrl}/chat/completions`, + ); + } + }); + it('builds each Provider icon URL from the catalog snapshot prefix', async () => { const [preset] = await parsePresetsForTest( providerCatalog(['vendor.with.dots']), @@ -686,6 +726,7 @@ describe('Provider Preset ordering', () => { it('uses the region-local CN and Global pin order when Apollo is unavailable', async () => { const cnIds = [ 'minimax-cn', + 'zhipuai-coding-plan', 'zhipuai', 'deepseek', 'moonshotai-cn', @@ -705,6 +746,7 @@ describe('Provider Preset ordering', () => { const globalIds = [ 'minimax', + 'zai-coding-plan', 'zai', 'deepseek', 'moonshotai', diff --git a/packages/local-runtime-v2/src/service/model-system/catalog/provider-presets/provider-presets.service.ts b/packages/local-runtime-v2/src/service/model-system/catalog/provider-presets/provider-presets.service.ts index 6601a8c8..bda1f976 100644 --- a/packages/local-runtime-v2/src/service/model-system/catalog/provider-presets/provider-presets.service.ts +++ b/packages/local-runtime-v2/src/service/model-system/catalog/provider-presets/provider-presets.service.ts @@ -24,9 +24,17 @@ const DISABLED_PROVIDER_IDS = new Set([ 'minimax-cn-coding-plan', ]); const REGION_PINNED_PROVIDER_IDS = { - cn: ['zhipuai', 'deepseek', 'moonshotai-cn', 'openai', 'anthropic'], - en: ['zai', 'deepseek', 'moonshotai', 'openai', 'anthropic'], + cn: ['zhipuai-coding-plan', 'zhipuai', 'deepseek', 'moonshotai-cn', 'openai', 'anthropic'], + en: ['zai-coding-plan', 'zai', 'deepseek', 'moonshotai', 'openai', 'anthropic'], } as const; +// These IDs belong to models.dev, not the bundled inference registry. Keep their +// URLs and IDs intact, and make the billing plan explicit at selection time. +const PROVIDER_PLAN_NAMES = new Map([ + ['zai', 'Z.AI API'], + ['zai-coding-plan', 'Z.AI Coding Plan'], + ['zhipuai', 'Zhipu AI API'], + ['zhipuai-coding-plan', 'Zhipu AI Coding Plan'], +]); const refreshInFlight = new Map>(); export interface ProviderPresetCatalogOptions extends ProviderPresetRepositoryOptions { @@ -127,7 +135,7 @@ function parseProvider( if (models.length === 0) return undefined; return { providerId, - name: stringValue(value.name) ?? providerId, + name: PROVIDER_PLAN_NAMES.get(providerId) ?? stringValue(value.name) ?? providerId, ...transport, models, ...(iconBaseUrl ? { iconUrl: resolveProviderIconUrl(iconBaseUrl, providerId) } : {}), diff --git a/packages/local-runtime-v2/src/service/model-system/management/service.test.ts b/packages/local-runtime-v2/src/service/model-system/management/service.test.ts index 9026ef6e..91228c2a 100644 --- a/packages/local-runtime-v2/src/service/model-system/management/service.test.ts +++ b/packages/local-runtime-v2/src/service/model-system/management/service.test.ts @@ -11,6 +11,7 @@ import type { ModelDiscoveryResult, ModelDiscoveryTarget, } from '../contracts.js'; +import { planCustomProviderResolution } from '../resolution/model-resolver-byok.js'; import { LocalModelCache } from '../catalog/model-cache.js'; import { MINIMAX_API_DEFAULT_BASE_URL, minimaxApiModels } from '../catalog/minimax-api.js'; import { listLocalRuntimeModels } from '../catalog/catalog.js'; @@ -1020,6 +1021,63 @@ describe('custom provider candidate persistence implicit thinking default', () = }); describe('custom provider candidate persistence', () => { + it.each(['https://api.z.ai', 'https://open.bigmodel.cn'])( + 'persists and resolves only the explicitly retried Coding Plan endpoint on %s', + async (origin) => { + const h = makeHarness(); + const generalUrl = `${origin}/api/paas/v4`; + const codingUrl = `${origin}/api/coding/paas/v4`; + const candidate = { + name: 'GLM plan', + apiKey: CUSTOM_KEY, + baseUrl: generalUrl, + apiFormat: 'openai-completions', + models: [{ modelId: 'glm-5.3', toolCall: true }], + }; + h.setTestResult({ + ok: false, + errorCode: 'http_429', + errorMessage: 'Insufficient balance', + }); + const failed = await h.service.saveUserModelProviderCandidate({ + candidate, + modelId: 'glm-5.3', + saveAndUse: true, + }); + expect(failed.ok).toBe(false); + expect(h.config.custom_provider).toBeUndefined(); + expect(h.config.defaultModel).toBe('minimax/MiniMax-M3'); + expect(h.testCalls.map(({ target }) => target.baseUrl)).toEqual([generalUrl]); + + h.setTestResult({ ok: true }); + const saved = await h.service.saveUserModelProviderCandidate({ + candidate: { ...candidate, baseUrl: codingUrl }, + modelId: 'glm-5.3', + saveAndUse: true, + }); + expect(saved.ok).toBe(true); + const provider = saved.provider!.providerId; + const providerKey = provider.replace('custom_provider:', ''); + expect(h.config.custom_provider?.[providerKey]?.options?.baseURL).toBe(codingUrl); + expect(h.config.defaultModel).toBe(`${provider}/glm-5.3`); + expect(h.testCalls.map(({ target }) => target.baseUrl)).toEqual([generalUrl, codingUrl]); + // Custom provider resolution must use the persisted URL, not a similarly + // named provider in the bundled inference registry. + expect( + planCustomProviderResolution({ + byok: h.config, + provider, + providerKey, + modelId: 'glm-5.3', + }), + ).toMatchObject({ + baseUrl: codingUrl, + api: 'openai-completions', + apiKey: CUSTOM_KEY, + }); + }, + ); + it('saves every preset model without testing or switching the active model', async () => { const h = makeHarness(); diff --git a/packages/tui/src/tui/features/provider/onboarding.ts b/packages/tui/src/tui/features/provider/onboarding.ts index f914d86d..99257539 100644 --- a/packages/tui/src/tui/features/provider/onboarding.ts +++ b/packages/tui/src/tui/features/provider/onboarding.ts @@ -7,7 +7,7 @@ import type { } from '../../../provider/contract.js'; import { additiveProviderModels, matchesProviderTemplate } from './connections.js'; import { formatTuiActionFailure } from '../../../user-facing-failure.js'; -import { getKeybindings, Input } from '../../engine/public.js'; +import { getKeybindings, Input, matchesKey } from '../../engine/public.js'; import type { Component, Focusable } from '../../rendering/component.js'; import { truncateToWidth, visibleWidth } from '../../rendering/text.js'; import { sanitizeTerminalText } from '../../rendering/terminal-text.js'; @@ -35,6 +35,7 @@ type OnboardingMode = | 'alias' | 'model' | 'custom-name' + | 'preset-url' | 'custom-url' | 'custom-format' | 'custom-model' @@ -74,6 +75,7 @@ export class TuiProviderOnboarding implements Component, Focusable { private modelFocus: ModelFocus = 'models'; private editingModelApiKey = false; private modelApiKeyDraft = ''; + private presetBaseUrl = ''; private customName = ''; private customBaseUrl = ''; private customApiFormat: McodeProviderApiFormat = 'openai-completions'; @@ -189,6 +191,13 @@ export class TuiProviderOnboarding implements Component, Focusable { this.options.requestRender(); return; } + if (matchesKey(data, 'ctrl+e')) { + this.enterTextMode( + 'preset-url', + this.presetBaseUrl || this.connection?.baseUrl || this.template?.baseUrl || '', + ); + return; + } if ( this.modelFocus === 'models' && keybindings.matches(data, 'tui.select.up') && @@ -242,6 +251,10 @@ export class TuiProviderOnboarding implements Component, Focusable { const prompt = chalk.hex(colors.muted)('Search: '); const input = this.searchInput.render(Math.max(1, width - visibleWidth(prompt)))[0] ?? ''; return [ + chalk.hex(colors.text)( + `Base URL: ${sanitizeTerminalText(this.presetBaseUrl || this.connection?.baseUrl || this.template?.baseUrl || '')}`, + ), + chalk.hex(colors.dim)('ctrl+e edit URL · match the endpoint to your API plan'), ...this.renderModelApiKey(width), '', `${prompt}${input}`, @@ -487,12 +500,21 @@ export class TuiProviderOnboarding implements Component, Focusable { this.enterTextMode('custom-url', this.customBaseUrl); return; } - if (this.mode === 'custom-url') { + if (this.mode === 'custom-url' || this.mode === 'preset-url') { if (!isHttpUrl(trimmed)) { this.status = 'Base URL must use http or https.'; this.options.requestRender(); return; } + if (this.mode === 'preset-url') { + this.presetBaseUrl = trimmed; + // Return without rebuilding the model list or discarding the key/model draft. + this.mode = 'model'; + this.status = ''; + this.syncFocus(); + this.options.requestRender(); + return; + } this.customBaseUrl = trimmed; this.enterMode('custom-format'); return; @@ -522,7 +544,7 @@ export class TuiProviderOnboarding implements Component, Focusable { try { const result = await this.options.onSave(input); if (!result.success) { - this.status = result.status?.lastErrorMessage ?? 'Connection test failed.'; + this.status = `Changes were not saved. ${result.status?.lastErrorMessage ?? 'Connection test failed.'}`; return; } await this.options.onComplete({ @@ -555,7 +577,7 @@ export class TuiProviderOnboarding implements Component, Focusable { } : {}), name: this.connection?.name ?? (this.alias || this.template.name), - baseUrl: this.connection?.baseUrl ?? this.template.baseUrl, + baseUrl: this.presetBaseUrl || this.connection?.baseUrl || this.template.baseUrl, ...(apiKey ? { apiKey } : {}), apiFormat: this.template.apiFormat, models: this.connection @@ -605,7 +627,7 @@ export class TuiProviderOnboarding implements Component, Focusable { } private enterTextMode( - mode: 'custom-name' | 'custom-url' | 'custom-model' | 'alias', + mode: 'custom-name' | 'custom-url' | 'custom-model' | 'alias' | 'preset-url', value: string, ): void { this.mode = mode; @@ -637,6 +659,7 @@ export class TuiProviderOnboarding implements Component, Focusable { } private resetKnownProviderDraft(): void { + this.presetBaseUrl = ''; this.connection = undefined; this.alias = ''; this.selectedModelId = ''; @@ -647,6 +670,13 @@ export class TuiProviderOnboarding implements Component, Focusable { } private back(): void { + if (this.mode === 'preset-url') { + this.mode = 'model'; + this.status = ''; + this.syncFocus(); + this.options.requestRender(); + return; + } if (this.mode === 'provider') { this.resetKnownProviderDraft(); return this.options.onCancel(); @@ -668,6 +698,7 @@ export class TuiProviderOnboarding implements Component, Focusable { private subtitle(): string { if (this.mode === 'connection') return 'This provider is already configured. Use the saved connection or add another account.'; + if (this.mode === 'preset-url') return 'Confirm the endpoint before testing with your API key'; if (this.mode === 'alias') return 'Give the additional account a recognizable name'; if (this.mode === 'provider') return 'Choose a known provider or enter a custom endpoint'; if (this.mode === 'model') @@ -679,7 +710,7 @@ export class TuiProviderOnboarding implements Component, Focusable { private inputLabel(): string { if (this.mode === 'alias') return 'Account alias'; if (this.mode === 'custom-name') return 'Provider name'; - if (this.mode === 'custom-url') return 'Base URL'; + if (this.mode === 'custom-url' || this.mode === 'preset-url') return 'Base URL'; if (this.mode === 'custom-model') return 'Model ID'; return 'API Key'; } @@ -695,6 +726,7 @@ export class TuiProviderOnboarding implements Component, Focusable { } if (this.mode === 'api-key') return 'enter test, save, and use · esc back'; if ( + this.mode === 'preset-url' || this.mode === 'custom-name' || this.mode === 'custom-url' || this.mode === 'custom-model' || @@ -712,7 +744,8 @@ export class TuiProviderOnboarding implements Component, Focusable { (this.mode === 'model' && this.modelFocus === 'models' && !this.editingModelApiKey)); this.textInput.focused = this._focused && - (this.mode === 'custom-name' || + (this.mode === 'preset-url' || + this.mode === 'custom-name' || this.mode === 'custom-url' || this.mode === 'custom-model' || this.mode === 'alias'); diff --git a/packages/tui/test/unit/tui-provider-onboarding.test.ts b/packages/tui/test/unit/tui-provider-onboarding.test.ts index 26aeaec0..0f59de72 100644 --- a/packages/tui/test/unit/tui-provider-onboarding.test.ts +++ b/packages/tui/test/unit/tui-provider-onboarding.test.ts @@ -479,3 +479,114 @@ describe("existing connection onboarding", () => { } }); }); + +describe("preset endpoint editing", () => { + const template: McodeProviderTemplate = { + providerId: "zai", + name: "Z.AI API", + baseUrl: "https://api.z.ai/api/paas/v4", + apiFormat: "openai-completions", + models: [{ modelId: "glm-5.3", toolCall: true }], + }; + + it("keeps the key and model after failure and tests only an explicitly confirmed endpoint", async () => { + const onSave = vi + .fn() + .mockResolvedValueOnce({ + success: false, + status: { lastErrorMessage: "429 insufficient balance synthetic-key" }, + }) + .mockResolvedValueOnce({ success: true }); + const onComplete = vi.fn(); + const onboarding = new TuiProviderOnboarding({ + templates: [template], + onSave, + onComplete, + onCancel: vi.fn(), + requestRender: vi.fn(), + }); + onboarding.handleInput("\r"); + expect(stripAnsi(onboarding.render(140).join("\n"))).toContain( + template.baseUrl, + ); + onboarding.handleInput("\r"); + onboarding.handleInput("\r"); + onboarding.handleInput("synthetic-key"); + onboarding.handleInput("\r"); + onboarding.handleInput("\r"); + await vi.waitFor(() => + expect(stripAnsi(onboarding.render(180).join("\n"))).toContain( + "Changes were not saved", + ), + ); + expect(onComplete).not.toHaveBeenCalled(); + expect(onSave).toHaveBeenCalledTimes(1); + expect(stripAnsi(onboarding.render(180).join("\n"))).not.toContain( + "synthetic-key", + ); + + onboarding.handleInput("\u0005"); + onboarding.handleInput("\u0001"); + onboarding.handleInput("\u000b"); + onboarding.handleInput("ftp://invalid"); + onboarding.handleInput("\r"); + expect(stripAnsi(onboarding.render(140).join("\n"))).toContain( + "Base URL must use http or https", + ); + onboarding.handleInput("\u0001"); + onboarding.handleInput("\u000b"); + const codingUrl = "https://api.z.ai/api/coding/paas/v4"; + onboarding.handleInput(codingUrl); + onboarding.handleInput("\r"); + expect(onSave).toHaveBeenCalledTimes(1); + const view = stripAnsi(onboarding.render(140).join("\n")); + expect(view).toContain(codingUrl); + expect(view).toContain("Model ID: glm-5.3"); + expect(view).toContain("API Key: Ready for test"); + onboarding.handleInput("\r"); + await vi.waitFor(() => expect(onComplete).toHaveBeenCalledOnce()); + expect(onSave.mock.calls.map(([input]) => input.baseUrl)).toEqual([ + template.baseUrl, + codingUrl, + ]); + expect(onSave.mock.calls[1]?.[0]).toMatchObject({ + apiKey: "synthetic-key", + modelId: "glm-5.3", + models: template.models, + saveAndUse: true, + }); + }); + + it("cancels endpoint editing and clears the override when another preset is selected", async () => { + const onSave = vi.fn(async () => ({ success: true })); + const onboarding = new TuiProviderOnboarding({ + templates: [template, knownTemplate], + onSave, + onComplete: vi.fn(), + onCancel: vi.fn(), + requestRender: vi.fn(), + }); + onboarding.handleInput("\r"); + onboarding.handleInput("\u0005"); + onboarding.handleInput("discarded"); + onboarding.handleInput("\u001b"); + expect(stripAnsi(onboarding.render(140).join("\n"))).not.toContain( + "discarded", + ); + onboarding.handleInput("\u0005"); + onboarding.handleInput("\u0001"); + onboarding.handleInput("\u000b"); + onboarding.handleInput("https://explicit.example/v1"); + onboarding.handleInput("\r"); + onboarding.handleInput("\u001b"); + onboarding.handleInput("\u001b[B"); + onboarding.handleInput("\r"); + expect(stripAnsi(onboarding.render(140).join("\n"))).toContain( + knownTemplate.baseUrl, + ); + expect(stripAnsi(onboarding.render(140).join("\n"))).not.toContain( + "explicit.example", + ); + expect(onSave).not.toHaveBeenCalled(); + }); +}); diff --git a/test/byok.test.mjs b/test/byok.test.mjs index 9c68d556..fcbc4e29 100644 --- a/test/byok.test.mjs +++ b/test/byok.test.mjs @@ -297,6 +297,25 @@ test( assert.equal(selected.models[0].contextLimit, undefined); assert.equal(selected.models[0].maxOutputTokens, undefined); + // The selected plan path must survive YAML persistence and actual inference. + const codingUrl = `${new URL(baseUrl).origin}/api/coding/paas/v4`; + const beforeCoding = requests.length; + await run([ + "provider", "add", "--name", "Coding", "--base-url", codingUrl, + "--api-format", "openai-completions", "--model", "glm-5.3", "--use", + ]); + assert.equal(savedConfig().custom_provider.coding.options.baseURL, codingUrl); + assert.equal(savedConfig().defaultModel, "custom_provider:coding/glm-5.3"); + assert.match(await run([ + "exec", "CODING_ENDPOINT_TEST", "--timeout", "20s", "--max-steps", "1", + ]), /LOCAL_BYOK_OK/); + const codingRequests = requests.slice(beforeCoding); + assert.ok(codingRequests.some(({ body }) => body.stream === true && body.model === "glm-5.3")); + // Loopback hosts may also receive a separate Responses token-count probe. + const chatRequests = codingRequests.filter(({ body }) => Array.isArray(body.messages)); + assert.ok(chatRequests.length >= 2, "Both the connection test and inference must use Chat"); + assert.ok(chatRequests.every(({ url }) => url === "/api/coding/paas/v4/chat/completions")); + const addArgs = [ "provider", "add", "--name", "Limited", "--base-url", baseUrl, "--api-format", "openai-completions", "--model", "fixture-model",