feat(providers): load model recommendations before editing (#9980)
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 3/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 3/3 (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 1/2 (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 2/2 (push) Waiting to run
E2E Tests / channel-plugin E2E (nightly) (push) Waiting to run
E2E Tests / cron-interactive E2E (nightly) (push) Waiting to run
E2E Tests / web-shell Browser Regression (push) Waiting to run
SDK Java / ubuntu-latest / Java 11 (push) Waiting to run
SDK Java / ubuntu-latest / Java 17 (push) Waiting to run
SDK Java / macos-latest / Java 21 (push) Waiting to run
SDK Java / ubuntu-latest / Java 21 (push) Waiting to run
SDK Java / windows-latest / Java 21 (push) Waiting to run
SDK Java / Real daemon E2E / Java 11 (push) Waiting to run
Security Checks / Dependency CVE audit (push) Waiting to run
Security Checks / Secret scan (TruffleHog) (push) Waiting to run

* feat(providers): load model recommendations before editing

* fix(providers): harden model discovery after review round 1 (#9980)

* fix(providers): stop curating discovered model ids (#9980)

* fix(providers): reject full unsafe unicode classes in discovered model ids (#9980)

* fix(providers): close the unassigned-code-point gap in model id validation (#9980)

* test(providers): pin the lone-surrogate model id rejection (#9980)

* test(cli): step past Grok in the MiniMax endpoint auth walk (#9980)

The stdin-driven walk pressed down once from DeepSeek expecting MiniMax,
but the Grok (xAI) preset landed between them on main. The suite skips
under CI=true, so only local runs saw the stale adjacency.

* fix(cli): clear stale model-ids error and lead submits with served models (#9980)

Two Critical review findings on the discovery wizard. On the discovery
path edits never cleared the empty-submit error banner because they only
call the no-op sync; add a clearModelIdsError flow action for that
branch. And mergeModelIds put free-form ids first, so a partial-catalog
no-edit submit led with an unserved demoted default, which
buildInstallPlan writes as model.name on first-time setup; checked
recommendations lead now.

---------

Co-authored-by: qqqys <266654365+qqqys@users.noreply.github.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
This commit is contained in:
qqqys 2026-08-26 09:02:28 +00:00 committed by GitHub
parent fc874dfe0b
commit 647fdff036
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 837 additions and 35 deletions

View file

@ -16,6 +16,15 @@ import { UIActionsContext } from '../contexts/UIActionsContext.js';
import type { UIState } from '../contexts/UIStateContext.js';
import type { UIActions } from '../contexts/UIActionsContext.js';
const discoverProviderModelsMock = vi.hoisted(() =>
vi.fn().mockResolvedValue(null),
);
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({
...(await importOriginal<typeof import('@qwen-code/qwen-code-core')>()),
discoverProviderModels: discoverProviderModelsMock,
}));
type UIStateOverrides = Partial<UIState> & Partial<UIState['auth']>;
type UIActionsOverrides = Partial<UIActions> & Partial<UIActions['auth']>;
@ -144,6 +153,18 @@ const waitForSelectedOption = async (
);
};
const waitForText = async (
lastFrame: () => string | undefined,
expectedText: string,
) => {
await vi.waitFor(
() => {
expect(lastFrame()).toContain(expectedText);
},
{ timeout: WAIT_FOR_TIMEOUT },
);
};
const pressEnterAndWaitFor = async (
stdin: { write: (s: string) => void },
lastFrame: () => string | undefined,
@ -1103,6 +1124,7 @@ describe('AuthDialog', { timeout: 15000 }, () => {
},
{ timeout: WAIT_FOR_TIMEOUT },
);
await moveDownAndWaitForSelection(stdin, lastFrame, 'Grok (xAI) API Key');
await moveDownAndWaitForSelection(stdin, lastFrame, 'MiniMax API Key');
await pressEnterAndWaitFor(
stdin,
@ -1249,6 +1271,7 @@ describe('AuthDialog', { timeout: 15000 }, () => {
lastFrame,
'Alibaba ModelStudio · Step 3/3 · Model IDs',
);
await waitForText(lastFrame, 'Enter model IDs directly');
stdin.write('\r');
await vi.waitFor(
() => {
@ -1329,6 +1352,7 @@ describe('AuthDialog', { timeout: 15000 }, () => {
lastFrame,
'Alibaba ModelStudio · Step 3/3 · Model IDs',
);
await waitForText(lastFrame, 'Enter model IDs directly');
// The Model IDs input is pre-filled with the saved custom model id
// (which only exists in settings, never among the built-in defaults).

View file

@ -8,13 +8,23 @@ import { renderWithProviders } from '../../test-utils/render.js';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import { AuthType } from '@qwen-code/qwen-code-core';
import type { ModelSpec } from '@qwen-code/qwen-code-core';
import type { KeypressHandler, Key } from '../contexts/KeypressContext.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { ProviderSetupSteps } from './ProviderSetupSteps.js';
import type { ProviderSetupFlow } from './useProviderSetupFlow.js';
import {
useProviderSetupFlow,
type ProviderSetupFlow,
} from './useProviderSetupFlow.js';
type UseKeypressMockOptions = { isActive: boolean };
const discoverProviderModelsMock = vi.hoisted(() => vi.fn());
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => ({
...(await importOriginal<typeof import('@qwen-code/qwen-code-core')>()),
discoverProviderModels: discoverProviderModelsMock,
}));
vi.mock('../hooks/useKeypress.js');
let activeKeypressHandlers: KeypressHandler[] = [];
@ -22,6 +32,7 @@ let activeKeypressHandlers: KeypressHandler[] = [];
describe('ProviderSetupSteps', () => {
beforeEach(() => {
activeKeypressHandlers = [];
discoverProviderModelsMock.mockReset();
vi.mocked(useKeypress).mockImplementation(
(handler: KeypressHandler, options?: UseKeypressMockOptions) => {
if (options?.isActive) {
@ -116,6 +127,7 @@ describe('ProviderSetupSteps', () => {
changeApiKey: noop,
submitApiKey: noop,
changeModelIds: noop,
clearModelIdsError: vi.fn(),
submitModelIds: noop,
moveAdvancedFocusUp: vi.fn(),
moveAdvancedFocusDown: vi.fn(),
@ -188,6 +200,7 @@ describe('ProviderSetupSteps', () => {
changeApiKey: noop,
submitApiKey: noop,
changeModelIds: noop,
clearModelIdsError: vi.fn(),
submitModelIds,
moveAdvancedFocusUp: noop,
moveAdvancedFocusDown: noop,
@ -249,6 +262,7 @@ describe('ProviderSetupSteps', () => {
changeApiKey: noop,
submitApiKey: noop,
changeModelIds: noop,
clearModelIdsError: vi.fn(),
submitModelIds,
moveAdvancedFocusUp: noop,
moveAdvancedFocusDown: noop,
@ -259,6 +273,18 @@ describe('ProviderSetupSteps', () => {
} as unknown as ProviderSetupFlow;
};
const enableDiscovery = (flow: ProviderSetupFlow) => {
if (!flow.state.provider) {
throw new Error('Expected a provider');
}
flow.state.provider = {
...flow.state.provider,
supportsModelDiscovery: true,
};
flow.state.baseUrl = 'https://example.com/v1';
flow.state.apiKey = 'secret-key';
};
it('maps Ctrl+P/N to advanced-config focus navigation', () => {
const flow = createAdvancedConfigFlow();
@ -282,6 +308,7 @@ describe('ProviderSetupSteps', () => {
const frame = lastFrame() ?? '';
expect(frame).toContain('Enter model IDs directly');
expect(frame).toContain('Recommended models');
expect(frame).not.toContain('Other models from the provider');
expect(frame).toContain(
'Checked recommended models are applied on submit but not copied into the input.',
);
@ -362,7 +389,7 @@ describe('ProviderSetupSteps', () => {
pressKey('return', '\r');
expect(submitModelIds).toHaveBeenCalledWith({
modelIds: ['custom-model', 'MiniMax-M3', 'MiniMax-M2.7'],
modelIds: ['MiniMax-M3', 'MiniMax-M2.7', 'custom-model'],
});
unmount();
});
@ -387,4 +414,269 @@ describe('ProviderSetupSteps', () => {
expect(submitModelIds).toHaveBeenCalledTimes(1);
unmount();
});
it('does not mount the model editor before discovery settles', () => {
discoverProviderModelsMock.mockReturnValue(new Promise(() => {}));
const flow = createModelIdsFlow();
enableDiscovery(flow);
const { lastFrame, unmount } = renderWithProviders(
<ProviderSetupSteps flow={flow} />,
);
const frame = lastFrame() ?? '';
expect(frame).toContain('Loading models from provider…');
expect(frame).toContain('Esc to go back');
expect(frame).not.toContain('Enter model IDs directly');
expect(discoverProviderModelsMock).toHaveBeenCalledWith({
baseUrl: 'https://example.com/v1',
apiKey: 'secret-key',
staticModels: flow.state.provider?.models,
signal: expect.any(AbortSignal),
});
unmount();
});
it('mounts one provider snapshot without promoting new models or dropping unserved selections', async () => {
let resolveDiscovery!: (models: ModelSpec[]) => void;
discoverProviderModelsMock.mockReturnValue(
new Promise<ModelSpec[]>((resolve) => {
resolveDiscovery = resolve;
}),
);
const submitModelIds = vi.fn();
const flow = createModelIdsFlow({
modelIds: 'custom-model, MiniMax-M3, MiniMax-M2.7',
submitModelIds,
});
enableDiscovery(flow);
const { lastFrame, unmount } = renderWithProviders(
<ProviderSetupSteps flow={flow} />,
);
await act(async () => {
resolveDiscovery([
{
id: 'MiniMax-M3',
contextWindowSize: 1000000,
modalities: { image: true, video: true },
},
{ id: 'MiniMax-M4' },
{ id: 'custom-model' },
]);
});
const frame = lastFrame() ?? '';
expect(frame).toContain('Recommended models · from the provider');
expect(frame).toContain('custom-model');
expect(frame).toContain('MiniMax-M3');
expect(frame).toContain('MiniMax-M4');
// The snapshot has no row for the previously selected MiniMax-M2.7, so it
// stays visible and selectable through the free-form input instead.
expect(frame).not.toMatch(/[◉○]\uFE0E\s+MiniMax-M2\.7/);
const inputLine = frame
.split('\n')
.find((line) => line.includes('custom-model'));
expect(inputLine).toContain('custom-model, MiniMax-M2.7');
expect(frame).toMatch(/◉\uFE0E\s+MiniMax-M3/);
expect(frame).toMatch(/○\uFE0E\s+MiniMax-M4/);
expect(frame).toMatch(/○\uFE0E\s+custom-model/);
const lines = frame.split('\n');
const otherHeadingLine = lines.findIndex((line) =>
line.includes('Other models from the provider'),
);
expect(otherHeadingLine).toBeGreaterThan(
lines.findIndex((line) => /◉\uFE0E\s+MiniMax-M3/.test(line)),
);
expect(otherHeadingLine).toBeLessThan(
lines.findIndex((line) => /○\uFE0E\s+MiniMax-M4/.test(line)),
);
await act(async () => {
pressLatestKey('x', 'x');
});
expect(lastFrame()).toContain('xcustom-model, MiniMax-M2.7');
expect(flow.changeModelIds).not.toHaveBeenCalled();
pressKey('return', '\r');
expect(submitModelIds).toHaveBeenCalledWith({
modelIds: ['MiniMax-M3', 'xcustom-model', 'MiniMax-M2.7'],
});
unmount();
});
it('lists provider-only models without the recommended endorsement', async () => {
discoverProviderModelsMock.mockResolvedValue([
{ id: 'served-unknown-a' },
{ id: 'served-unknown-b' },
]);
const flow = createModelIdsFlow();
enableDiscovery(flow);
const { lastFrame, unmount } = renderWithProviders(
<ProviderSetupSteps flow={flow} />,
);
await act(async () => {});
const frame = lastFrame() ?? '';
expect(frame).not.toContain('Recommended models · from the provider');
expect(frame).toContain('Other models from the provider');
expect(frame).toMatch(/○\uFE0E\s+served-unknown-a/);
expect(frame).toMatch(/○\uFE0E\s+served-unknown-b/);
unmount();
});
it('toggles and submits an unendorsed provider model like a recommended one', async () => {
let resolveDiscovery!: (models: ModelSpec[]) => void;
discoverProviderModelsMock.mockReturnValue(
new Promise<ModelSpec[]>((resolve) => {
resolveDiscovery = resolve;
}),
);
const submitModelIds = vi.fn();
const flow = createModelIdsFlow({
modelIds: 'custom-model, MiniMax-M3',
submitModelIds,
});
enableDiscovery(flow);
const { lastFrame, unmount } = renderWithProviders(
<ProviderSetupSteps flow={flow} />,
);
await act(async () => {
resolveDiscovery([
{ id: 'MiniMax-M3', contextWindowSize: 1000000 },
{ id: 'MiniMax-M4' },
]);
});
await act(async () => {
pressLatestKey('down');
});
await act(async () => {
pressLatestKey('down');
});
await act(async () => {
pressLatestKey('down');
});
await act(async () => {
pressLatestKey('space', ' ');
});
expect(lastFrame()).toMatch(/◉\uFE0E\s+MiniMax-M4/);
pressKey('return', '\r');
expect(submitModelIds).toHaveBeenCalledWith({
modelIds: ['MiniMax-M3', 'MiniMax-M4', 'custom-model'],
});
unmount();
});
it('falls back to built-ins after an unavailable catalog', async () => {
discoverProviderModelsMock.mockResolvedValue(null);
const flow = createModelIdsFlow();
enableDiscovery(flow);
const { lastFrame, unmount } = renderWithProviders(
<ProviderSetupSteps flow={flow} />,
);
await act(async () => {});
const frame = lastFrame() ?? '';
expect(frame).toContain(
'Recommended models · provider list unavailable, showing built-ins',
);
expect(frame).not.toContain('Other models from the provider');
expect(frame).toContain('MiniMax-M2.7');
unmount();
});
it('cancels a pending discovery when the step unmounts', () => {
discoverProviderModelsMock.mockReturnValue(new Promise(() => {}));
const flow = createModelIdsFlow();
enableDiscovery(flow);
const { unmount } = renderWithProviders(<ProviderSetupSteps flow={flow} />);
const signal = discoverProviderModelsMock.mock.calls[0]?.[0]
.signal as AbortSignal;
unmount();
expect(signal.aborted).toBe(true);
});
it('submits served recommendations ahead of unserved defaults on a partial catalog', async () => {
let resolveDiscovery!: (models: ModelSpec[]) => void;
discoverProviderModelsMock.mockReturnValue(
new Promise<ModelSpec[]>((resolve) => {
resolveDiscovery = resolve;
}),
);
const submitModelIds = vi.fn();
// Defaults are pre-selected; the catalog serves MiniMax-M3 but not the
// built-in MiniMax-M2.7, which the step demotes into the free-form input.
const flow = createModelIdsFlow({ submitModelIds });
enableDiscovery(flow);
const { lastFrame, unmount } = renderWithProviders(
<ProviderSetupSteps flow={flow} />,
);
await act(async () => {
resolveDiscovery([{ id: 'MiniMax-M3', contextWindowSize: 1000000 }]);
});
expect(lastFrame()).toContain('Enter model IDs directly');
pressKey('return', '\r');
// The no-edit submit must lead with a catalog-served model: models[0]
// becomes modelSelection and is written as model.name on first-time setup.
expect(submitModelIds).toHaveBeenCalledWith({
modelIds: ['MiniMax-M3', 'MiniMax-M2.7'],
});
unmount();
});
it('clears the model-ids error on edit after an empty discovery submit', async () => {
discoverProviderModelsMock.mockResolvedValue([{ id: 'served-model' }]);
let flow: ProviderSetupFlow | undefined;
const RealFlowHarness = () => {
const realFlow = useProviderSetupFlow(async () => {});
flow = realFlow;
return <ProviderSetupSteps flow={realFlow} />;
};
const { lastFrame, unmount } = renderWithProviders(<RealFlowHarness />);
await act(async () => {
flow?.start({
id: 'discovery-provider',
label: 'Discovery Provider',
description: 'Provider with model discovery',
protocol: AuthType.USE_OPENAI,
baseUrl: 'https://example.com/v1',
envKey: 'DISCOVERY_API_KEY',
modelsEditable: true,
supportsModelDiscovery: true,
modelNamePrefix: 'Discovery',
});
});
await act(async () => {
flow?.submitApiKey('sk-discovery');
});
await act(async () => {});
expect(lastFrame()).toContain('Enter model IDs directly');
await act(async () => {
pressLatestKey('return', '\r');
});
expect(lastFrame()).toContain('Model IDs cannot be empty.');
await act(async () => {
pressLatestKey('x', 'x');
});
expect(lastFrame()).not.toContain('Model IDs cannot be empty.');
unmount();
});
});

View file

@ -5,7 +5,7 @@
*/
import type React from 'react';
import { useCallback, useMemo, useState } from 'react';
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import Link from 'ink-link';
import { DescriptiveRadioButtonSelect } from '../components/shared/DescriptiveRadioButtonSelect.js';
@ -14,7 +14,7 @@ import { theme } from '../semantic-colors.js';
import { ICON } from '../constants.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { t } from '../../i18n/index.js';
import { AuthType } from '@qwen-code/qwen-code-core';
import { AuthType, discoverProviderModels } from '@qwen-code/qwen-code-core';
import type {
ProviderConfig,
BaseUrlOption,
@ -194,6 +194,8 @@ interface ModelOption {
label: string;
}
type ModelRecommendationSource = 'provider' | 'fallback';
function formatModelOptionLabel(model: ModelSpec): string {
const details: string[] = [];
if (model.contextWindowSize) {
@ -231,65 +233,79 @@ function mergeModelIds(
customModelIdsText: string,
selectedRecommendationKeys: string[],
): string[] {
// Checked recommendations lead: models[0] becomes the active model on
// first-time setup, and checked ids are catalog-served while free-form ids
// can include defaults the account's catalog does not serve.
return uniqueModelIds([
...normalizeModelIds(customModelIdsText),
...selectedRecommendationKeys,
...normalizeModelIds(customModelIdsText),
]);
}
function getRecommendedSelections(
selectedModelIds: string[],
modelOptions: ModelOption[],
builtInModelIds: Set<string>,
): string[] {
const selectedSet = new Set(selectedModelIds);
return modelOptions
.filter((item) => selectedSet.has(item.key))
.filter(
(item) => selectedSet.has(item.key) && builtInModelIds.has(item.key),
)
.map((item) => item.key);
}
function getCustomModelIdsText(
selectedModelIds: string[],
recommendedModelIds: Set<string>,
selectedRecommendationKeys: string[],
): string {
const recommendedSelections = new Set(selectedRecommendationKeys);
return selectedModelIds
.filter((id) => !recommendedModelIds.has(id))
.filter((id) => !recommendedSelections.has(id))
.join(', ');
}
function ModelIdsStep({
config,
flow,
models = config.models ?? [],
recommendationSource,
syncChangesToFlow = true,
}: {
config: ProviderConfig;
flow: ProviderSetupFlow;
models?: ModelSpec[];
recommendationSource?: ModelRecommendationSource;
syncChangesToFlow?: boolean;
}): React.JSX.Element {
const defaultIds = config.models?.map((m) => m.id).join(', ') ?? '';
const hasSelectableModels = (config.models?.length ?? 0) > 0;
const hasSelectableModels = models.length > 0;
const selectedModelIds = useMemo(
() => normalizeModelIds(flow.state.modelIds),
[flow.state.modelIds],
);
const modelOptions = useMemo<ModelOption[]>(
() =>
config.models?.map((model) => ({
models.map((model) => ({
key: model.id,
value: model.id,
label: formatModelOptionLabel(model),
})) ?? [],
[config.models],
})),
[models],
);
const recommendedModelIds = useMemo(
() => new Set(modelOptions.map((item) => item.key)),
[modelOptions],
const builtInModelIds = useMemo(
() => new Set(config.models?.map((model) => model.id) ?? []),
[config.models],
);
const [focusedModelIndex, setFocusedModelIndex] = useState(
MODEL_CUSTOM_INPUT_FOCUS_INDEX,
);
const [customModelIdsText, setCustomModelIdsText] = useState(() =>
getCustomModelIdsText(selectedModelIds, recommendedModelIds),
);
const [selectedRecommendationKeys, setSelectedRecommendationKeys] = useState(
() => getRecommendedSelections(selectedModelIds, modelOptions),
() =>
getRecommendedSelections(selectedModelIds, modelOptions, builtInModelIds),
);
const [customModelIdsText, setCustomModelIdsText] = useState(() =>
getCustomModelIdsText(selectedModelIds, selectedRecommendationKeys),
);
const [modelSearchQuery, setModelSearchQuery] = useState('');
const filteredModelOptions = useMemo(() => {
@ -301,6 +317,13 @@ function ModelIdsStep({
modelOptionSearchText(item).includes(normalizedQuery),
);
}, [modelOptions, modelSearchQuery]);
// Only the built-in specs are endorsed as recommendations; other served ids
// are listed under a separate heading below.
const firstOtherModelIndex = useMemo(
() =>
filteredModelOptions.findIndex((item) => !builtInModelIds.has(item.key)),
[filteredModelOptions, builtInModelIds],
);
const recommendedScrollOffset =
focusedModelIndex < 0
? 0
@ -318,11 +341,17 @@ function ModelIdsStep({
const syncModelIds = useCallback(
(customText: string, recommendationKeys: string[]) => {
flow.changeModelIds(
mergeModelIds(customText, recommendationKeys).join(', '),
);
if (syncChangesToFlow) {
flow.changeModelIds(
mergeModelIds(customText, recommendationKeys).join(', '),
);
} else {
// Edits commit only on Enter here, but a stale submit error must
// still clear on edit, as changeModelIds does on the synced path.
flow.clearModelIdsError();
}
},
[flow],
[flow, syncChangesToFlow],
);
const handleSubmitModelIds = useCallback(() => {
@ -439,9 +468,16 @@ function ModelIdsStep({
)}
</Text>
</Box>
<Box marginTop={1}>
<Text color={theme.text.secondary}>{t('Recommended models')}</Text>
</Box>
{firstOtherModelIndex !== 0 && (
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('Recommended models')}
{recommendationSource === 'provider' && t(' · from the provider')}
{recommendationSource === 'fallback' &&
t(' · provider list unavailable, showing built-ins')}
</Text>
</Box>
)}
<Box marginTop={0} flexDirection="column">
<Text color={theme.text.secondary}>{t('Search')}</Text>
<TextInput
@ -475,17 +511,30 @@ function ModelIdsStep({
: isSelected
? theme.text.accent
: theme.text.primary;
const showOtherModelsHeading =
firstOtherModelIndex !== -1 &&
modelIndex >= firstOtherModelIndex &&
(visibleIndex === 0 || modelIndex === firstOtherModelIndex);
return (
<Box key={item.key} alignItems="flex-start">
<Box minWidth={4} flexShrink={0}>
<Text color={textColor}>
{isSelected ? ICON.RADIO_FILLED : ICON.CIRCLE_EMPTY}
</Text>
<Fragment key={item.key}>
{showOtherModelsHeading && (
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('Other models from the provider')}
</Text>
</Box>
)}
<Box alignItems="flex-start">
<Box minWidth={4} flexShrink={0}>
<Text color={textColor}>
{isSelected ? ICON.RADIO_FILLED : ICON.CIRCLE_EMPTY}
</Text>
</Box>
<Box flexGrow={1}>
<Text color={textColor}>{item.label}</Text>
</Box>
</Box>
<Box flexGrow={1}>
<Text color={textColor}>{item.label}</Text>
</Box>
</Box>
</Fragment>
);
})
) : (
@ -540,6 +589,69 @@ function ModelIdsStep({
);
}
function DiscoveringModelIdsStep({
config,
flow,
}: {
config: ProviderConfig;
flow: ProviderSetupFlow;
}): React.JSX.Element {
const [snapshot, setSnapshot] = useState<{
models: ModelSpec[];
source: ModelRecommendationSource;
} | null>(null);
const baseUrl = flow.state.baseUrl;
const apiKey = flow.state.apiKey;
useEffect(() => {
const controller = new AbortController();
let active = true;
const builtInModels = config.models ?? [];
void discoverProviderModels({
baseUrl,
apiKey,
staticModels: builtInModels,
signal: controller.signal,
}).then((models) => {
if (active) {
setSnapshot({
models: models ?? builtInModels,
source: models ? 'provider' : 'fallback',
});
}
});
return () => {
active = false;
controller.abort();
};
}, [apiKey, baseUrl, config.models]);
if (!snapshot) {
return (
<Box marginTop={1} flexDirection="column">
<Text color={theme.text.secondary}>
{t('Loading models from provider…')}
</Text>
<Box marginTop={1}>
<Text color={theme.text.secondary}>{t('Esc to go back')}</Text>
</Box>
</Box>
);
}
return (
<ModelIdsStep
config={config}
flow={flow}
models={snapshot.models}
recommendationSource={snapshot.source}
syncChangesToFlow={false}
/>
);
}
// ---------------------------------------------------------------------------
// Step: Advanced config
// ---------------------------------------------------------------------------
@ -794,6 +906,9 @@ export function ProviderSetupSteps({
return <ApiKeyStep config={provider} flow={flow} />;
case 'models':
if (provider.supportsModelDiscovery) {
return <DiscoveringModelIdsStep config={provider} flow={flow} />;
}
return <ModelIdsStep config={provider} flow={flow} />;
case 'advancedConfig':

View file

@ -314,6 +314,10 @@ export function useProviderSetupFlow(
setModelIdsError(null);
}, []);
const clearModelIdsError = useCallback(() => {
setModelIdsError(null);
}, []);
const submitModelIds = useCallback(
(overrides?: Partial<ProviderSetupInputs>): boolean => {
const normalized = overrides?.modelIds ?? normalizeModelIds(modelIds);
@ -512,6 +516,7 @@ export function useProviderSetupFlow(
changeApiKey,
submitApiKey,
changeModelIds,
clearModelIdsError,
submitModelIds,
moveAdvancedFocusUp,
moveAdvancedFocusDown,

View file

@ -0,0 +1,245 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { discoverProviderModels } from '../model-discovery.js';
const { fetchWithPolicyMock } = vi.hoisted(() => ({
fetchWithPolicyMock: vi.fn(),
}));
vi.mock('../../utils/fetch.js', () => ({
fetchWithPolicy: fetchWithPolicyMock,
}));
function response(body: unknown, status = 200) {
return {
kind: 'response' as const,
status,
statusText: '',
contentType: 'application/json',
contentDisposition: '',
body: Buffer.from(JSON.stringify(body)),
finalUrl: 'https://example.com/v1/models',
};
}
const options = {
baseUrl: ' https://example.com/v1/ ',
apiKey: ' secret-key ',
staticModels: [
{ id: 'known-a', contextWindowSize: 1000 },
{ id: 'known-b', enableThinking: true },
{ id: 'retired' },
],
};
describe('discoverProviderModels', () => {
beforeEach(() => {
fetchWithPolicyMock.mockReset();
});
it('returns every served id uncurated, merging known specs first in stable order', async () => {
fetchWithPolicyMock.mockResolvedValue(
response({
data: [
{ id: 'known-b' },
{ id: 'new-model' },
{ id: 'known-a' },
{ id: 'new-model' },
{ id: ' padded-model ' },
{ id: 'qwen2-audio-instruct' },
{ id: 'qwen-vl-ocr-latest' },
{ id: 'wan2.7-t2v-plus' },
],
}),
);
await expect(discoverProviderModels(options)).resolves.toEqual([
{ id: 'known-a', contextWindowSize: 1000 },
{ id: 'known-b', enableThinking: true },
{ id: 'new-model' },
{ id: 'padded-model' },
{ id: 'qwen2-audio-instruct' },
{ id: 'qwen-vl-ocr-latest' },
{ id: 'wan2.7-t2v-plus' },
]);
expect(fetchWithPolicyMock).toHaveBeenCalledWith(
'https://example.com/v1/models',
expect.objectContaining({
timeoutMs: 5000,
maxBytes: 1024 * 1024,
maxRedirects: 2,
headers: {
Accept: 'application/json',
Authorization: 'Bearer secret-key',
},
}),
);
});
it.each([
[{ id: 'model-a' }],
{ data: ['model-a'] },
{ data: [{ id: '' }] },
{ data: [] },
{ data: [{ id: 'model-a' }, null] },
{ models: [{ id: 'model-a' }] },
])('rejects a non-standard or empty listing: %j', async (body) => {
fetchWithPolicyMock.mockResolvedValue(response(body));
await expect(discoverProviderModels(options)).resolves.toBeNull();
});
it('keeps valid ids and skips ones with structural or control bytes', async () => {
fetchWithPolicyMock.mockResolvedValue(
response({
data: [
{ id: 'a, b' },
{ id: 'bad\u001b[31mid' },
{ id: 'del\u007fete' },
{ id: 'good-model' },
],
}),
);
await expect(discoverProviderModels(options)).resolves.toEqual([
{ id: 'good-model' },
]);
});
it('skips ids with invisible or formatting characters', async () => {
fetchWithPolicyMock.mockResolvedValue(
response({
data: [
{ id: 'qwen3.7-plus' },
{ id: 'qwen3.7\u200b-plus' },
{ id: '\u200bqwen-lookalike' },
{ id: 'qwen\u202e3.7' },
{ id: 'soft\u00adhyphen' },
{ id: 'a\ufeffb' },
{ id: 'qwen\u20663' },
{ id: 'line\u2028sep' },
{ id: 'para\u2029sep' },
{ id: 'arabic\u061cmark' },
{ id: 'mongolian\u180evs' },
],
}),
);
await expect(discoverProviderModels(options)).resolves.toEqual([
{ id: 'qwen3.7-plus' },
]);
});
it('skips ids with unassigned, private-use, or surrogate code points', async () => {
fetchWithPolicyMock.mockResolvedValue(
response({
data: [
{ id: 'unassigned\u2065point' },
{ id: 'private\ue000use' },
{ id: 'surrogate\ud800point' },
{ id: 'good-model' },
],
}),
);
await expect(discoverProviderModels(options)).resolves.toEqual([
{ id: 'good-model' },
]);
});
it('skips ids with C1 control bytes', async () => {
fetchWithPolicyMock.mockResolvedValue(
response({
data: [
{ id: 'csi\u009b31m' },
{ id: 'nel\u0085line' },
{ id: 'dcs\u0090string' },
{ id: 'st\u009cterm' },
{ id: 'good-model' },
],
}),
);
await expect(discoverProviderModels(options)).resolves.toEqual([
{ id: 'good-model' },
]);
});
it('skips ids longer than a plausible model name', async () => {
fetchWithPolicyMock.mockResolvedValue(
response({
data: [
{ id: 'a'.repeat(257) },
{ id: 'b'.repeat(256) },
{ id: 'good-model' },
],
}),
);
await expect(discoverProviderModels(options)).resolves.toEqual([
{ id: 'b'.repeat(256) },
{ id: 'good-model' },
]);
});
it('falls back when every served id is unsafe', async () => {
fetchWithPolicyMock.mockResolvedValue(
response({ data: [{ id: 'a, b' }, { id: '\u0007bell' }] }),
);
await expect(discoverProviderModels(options)).resolves.toBeNull();
});
it.each([
response({ data: [{ id: 'model-a' }] }, 401),
{
kind: 'cross-host-redirect' as const,
originalUrl: 'https://example.com/v1/models',
redirectUrl: 'https://other.example/models',
status: 302,
},
])('falls back for an unsuccessful response', async (result) => {
fetchWithPolicyMock.mockResolvedValue(result);
await expect(discoverProviderModels(options)).resolves.toBeNull();
});
it('falls back when the request or JSON parsing fails', async () => {
fetchWithPolicyMock.mockRejectedValueOnce(new Error('offline'));
await expect(discoverProviderModels(options)).resolves.toBeNull();
fetchWithPolicyMock.mockResolvedValueOnce({
...response({}),
body: Buffer.from('{'),
});
await expect(discoverProviderModels(options)).resolves.toBeNull();
});
it('does not request a catalog without both endpoint and key', async () => {
await expect(
discoverProviderModels({ ...options, apiKey: '' }),
).resolves.toBeNull();
await expect(
discoverProviderModels({ ...options, baseUrl: '' }),
).resolves.toBeNull();
expect(fetchWithPolicyMock).not.toHaveBeenCalled();
});
it('passes caller cancellation to the bounded request', async () => {
fetchWithPolicyMock.mockResolvedValue(response({ data: [{ id: 'new' }] }));
const controller = new AbortController();
await discoverProviderModels({ ...options, signal: controller.signal });
expect(fetchWithPolicyMock).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ signal: controller.signal }),
);
});
});

View file

@ -35,6 +35,8 @@ describe('coding plan provider', () => {
modelIds: getDefaultModelIds(codingPlanProvider),
});
expect(codingPlanProvider.supportsModelDiscovery).toBe(true);
expect(plan.providerId).toBe('coding-plan');
expect(plan.authType).toBe(AuthType.USE_OPENAI);
expect(plan.env).toEqual({ [CODING_PLAN_ENV_KEY]: 'sk-coding' });

View file

@ -34,6 +34,8 @@ describe('token plan provider', () => {
modelIds: getDefaultModelIds(tokenPlanProvider),
});
expect(tokenPlanProvider.supportsModelDiscovery).toBe(true);
expect(template.map((model) => model.id)).toEqual([
'qwen3.7-plus',
'qwen3.6-plus',

View file

@ -34,6 +34,8 @@ export {
shouldShowStep,
} from './provider-config.js';
export { discoverProviderModels } from './model-discovery.js';
// Provider registry
export {
ALL_PROVIDERS,

View file

@ -0,0 +1,110 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { fetchWithPolicy } from '../utils/fetch.js';
import type { ModelSpec } from './types.js';
const DISCOVERY_TIMEOUT_MS = 5000;
const DISCOVERY_MAX_BYTES = 1024 * 1024;
const MAX_MODEL_ID_LENGTH = 256;
// The wizard joins ids with commas and renders them raw, so a served id with
// a comma or a code point in the Unicode C (other) category would split into
// bogus models or poison the TUI.
const UNSAFE_MODEL_ID_CHARS = /[,\p{C}\p{Zl}\p{Zp}]/u;
interface DiscoverProviderModelsOptions {
baseUrl: string;
apiKey: string;
staticModels: readonly ModelSpec[];
signal?: AbortSignal;
}
function readModelIds(value: unknown): string[] | null {
if (!value || typeof value !== 'object' || !('data' in value)) {
return null;
}
const data = (value as { data?: unknown }).data;
if (!Array.isArray(data)) {
return null;
}
const ids: string[] = [];
const seen = new Set<string>();
for (const item of data) {
if (!item || typeof item !== 'object' || !('id' in item)) {
return null;
}
const id = (item as { id?: unknown }).id;
if (typeof id !== 'string') {
return null;
}
const trimmedId = id.trim();
if (
trimmedId &&
trimmedId.length <= MAX_MODEL_ID_LENGTH &&
!UNSAFE_MODEL_ID_CHARS.test(trimmedId) &&
!seen.has(trimmedId)
) {
seen.add(trimmedId);
ids.push(trimmedId);
}
}
return ids.length > 0 ? ids : null;
}
function mergeModelSpecs(
ids: string[],
staticModels: readonly ModelSpec[],
): ModelSpec[] {
const discoveredIds = new Set(ids);
const knownModels = staticModels.filter((model) =>
discoveredIds.has(model.id),
);
const knownIds = new Set(knownModels.map((model) => model.id));
return [
...knownModels,
...ids.filter((id) => !knownIds.has(id)).map((id) => ({ id })),
];
}
export async function discoverProviderModels({
baseUrl,
apiKey,
staticModels,
signal,
}: DiscoverProviderModelsOptions): Promise<ModelSpec[] | null> {
const normalizedBaseUrl = baseUrl.trim();
const normalizedApiKey = apiKey.trim();
if (!normalizedBaseUrl || !normalizedApiKey) {
return null;
}
try {
const modelsUrl = `${normalizedBaseUrl.replace(/\/+$/, '')}/models`;
const result = await fetchWithPolicy(modelsUrl, {
timeoutMs: DISCOVERY_TIMEOUT_MS,
maxBytes: DISCOVERY_MAX_BYTES,
maxRedirects: 2,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${normalizedApiKey}`,
},
signal,
});
if (
result.kind !== 'response' ||
result.status < 200 ||
result.status >= 300
) {
return null;
}
const ids = readModelIds(JSON.parse(result.body.toString('utf8')));
return ids ? mergeModelSpecs(ids, staticModels) : null;
} catch {
return null;
}
}

View file

@ -77,6 +77,7 @@ export const codingPlanProvider: ProviderConfig = {
envKey: CODING_PLAN_ENV_KEY,
models: MODELSTUDIO_MODELS,
modelsEditable: true,
supportsModelDiscovery: true,
modelNamePrefix: (baseUrl) =>
baseUrl === CODING_PLAN_GLOBAL_BASE_URL
? 'ModelStudio Coding Plan for Global/Intl'

View file

@ -106,6 +106,7 @@ export const tokenPlanProvider: ProviderConfig = {
envKey: TOKEN_PLAN_ENV_KEY,
models: TOKEN_PLAN_MODELS,
modelsEditable: true,
supportsModelDiscovery: true,
modelNamePrefix: (baseUrl) =>
baseUrl === TOKEN_PLAN_GLOBAL_BASE_URL
? 'ModelStudio Token Plan for Global/Intl'

View file

@ -67,6 +67,9 @@ export interface ProviderConfig {
*/
modelsEditable?: boolean;
/** Load the account's current model recommendations from `/models`. */
supportsModelDiscovery?: boolean;
/** Display name prefix for model entries, or a function of baseUrl. */
modelNamePrefix: string | ((baseUrl: string) => string);