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
6 changes: 5 additions & 1 deletion docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,46 @@ async function parsePresetsForTest(catalog: Record<string, unknown>, 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']),
Expand Down Expand Up @@ -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',
Expand All @@ -705,6 +746,7 @@ describe('Provider Preset ordering', () => {

const globalIds = [
'minimax',
'zai-coding-plan',
'zai',
'deepseek',
'moonshotai',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Promise<void>>();

export interface ProviderPresetCatalogOptions extends ProviderPresetRepositoryOptions {
Expand Down Expand Up @@ -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) } : {}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();

Expand Down
47 changes: 40 additions & 7 deletions packages/tui/src/tui/features/provider/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -35,6 +35,7 @@ type OnboardingMode =
| 'alias'
| 'model'
| 'custom-name'
| 'preset-url'
| 'custom-url'
| 'custom-format'
| 'custom-model'
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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') &&
Expand Down Expand 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}`,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -637,6 +659,7 @@ export class TuiProviderOnboarding implements Component, Focusable {
}

private resetKnownProviderDraft(): void {
this.presetBaseUrl = '';
this.connection = undefined;
this.alias = '';
this.selectedModelId = '';
Expand All @@ -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();
Expand All @@ -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')
Expand All @@ -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';
}
Expand All @@ -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' ||
Expand All @@ -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');
Expand Down
Loading
Loading