diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx index 901a178ac5..b874a89f18 100644 --- a/packages/cli/src/ui/auth/AuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx @@ -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()), + discoverProviderModels: discoverProviderModelsMock, +})); + type UIStateOverrides = Partial & Partial; type UIActionsOverrides = Partial & Partial; @@ -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). diff --git a/packages/cli/src/ui/auth/ProviderSetupSteps.test.tsx b/packages/cli/src/ui/auth/ProviderSetupSteps.test.tsx index f1306e6085..ca91128ea0 100644 --- a/packages/cli/src/ui/auth/ProviderSetupSteps.test.tsx +++ b/packages/cli/src/ui/auth/ProviderSetupSteps.test.tsx @@ -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()), + 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( + , + ); + + 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((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( + , + ); + + 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( + , + ); + 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((resolve) => { + resolveDiscovery = resolve; + }), + ); + const submitModelIds = vi.fn(); + const flow = createModelIdsFlow({ + modelIds: 'custom-model, MiniMax-M3', + submitModelIds, + }); + enableDiscovery(flow); + const { lastFrame, unmount } = renderWithProviders( + , + ); + + 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( + , + ); + 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(); + 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((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( + , + ); + + 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 ; + }; + + const { lastFrame, unmount } = renderWithProviders(); + + 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(); + }); }); diff --git a/packages/cli/src/ui/auth/ProviderSetupSteps.tsx b/packages/cli/src/ui/auth/ProviderSetupSteps.tsx index fa9d13ecfb..069faac6b4 100644 --- a/packages/cli/src/ui/auth/ProviderSetupSteps.tsx +++ b/packages/cli/src/ui/auth/ProviderSetupSteps.tsx @@ -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[] { 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, + 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( () => - 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({ )} - - {t('Recommended models')} - + {firstOtherModelIndex !== 0 && ( + + + {t('Recommended models')} + {recommendationSource === 'provider' && t(' · from the provider')} + {recommendationSource === 'fallback' && + t(' · provider list unavailable, showing built-ins')} + + + )} {t('Search')} = firstOtherModelIndex && + (visibleIndex === 0 || modelIndex === firstOtherModelIndex); return ( - - - - {isSelected ? ICON.RADIO_FILLED : ICON.CIRCLE_EMPTY} - + + {showOtherModelsHeading && ( + + + {t('Other models from the provider')} + + + )} + + + + {isSelected ? ICON.RADIO_FILLED : ICON.CIRCLE_EMPTY} + + + + {item.label} + - - {item.label} - - + ); }) ) : ( @@ -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 ( + + + {t('Loading models from provider…')} + + + {t('Esc to go back')} + + + ); + } + + return ( + + ); +} + // --------------------------------------------------------------------------- // Step: Advanced config // --------------------------------------------------------------------------- @@ -794,6 +906,9 @@ export function ProviderSetupSteps({ return ; case 'models': + if (provider.supportsModelDiscovery) { + return ; + } return ; case 'advancedConfig': diff --git a/packages/cli/src/ui/auth/useProviderSetupFlow.ts b/packages/cli/src/ui/auth/useProviderSetupFlow.ts index 28c38ea556..a63fd0e6bf 100644 --- a/packages/cli/src/ui/auth/useProviderSetupFlow.ts +++ b/packages/cli/src/ui/auth/useProviderSetupFlow.ts @@ -314,6 +314,10 @@ export function useProviderSetupFlow( setModelIdsError(null); }, []); + const clearModelIdsError = useCallback(() => { + setModelIdsError(null); + }, []); + const submitModelIds = useCallback( (overrides?: Partial): boolean => { const normalized = overrides?.modelIds ?? normalizeModelIds(modelIds); @@ -512,6 +516,7 @@ export function useProviderSetupFlow( changeApiKey, submitApiKey, changeModelIds, + clearModelIdsError, submitModelIds, moveAdvancedFocusUp, moveAdvancedFocusDown, diff --git a/packages/core/src/providers/__tests__/model-discovery.test.ts b/packages/core/src/providers/__tests__/model-discovery.test.ts new file mode 100644 index 0000000000..eb8db1139a --- /dev/null +++ b/packages/core/src/providers/__tests__/model-discovery.test.ts @@ -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 }), + ); + }); +}); diff --git a/packages/core/src/providers/__tests__/presets/alibaba-coding-plan.test.ts b/packages/core/src/providers/__tests__/presets/alibaba-coding-plan.test.ts index 876cb1b522..950774a653 100644 --- a/packages/core/src/providers/__tests__/presets/alibaba-coding-plan.test.ts +++ b/packages/core/src/providers/__tests__/presets/alibaba-coding-plan.test.ts @@ -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' }); diff --git a/packages/core/src/providers/__tests__/presets/alibaba-token-plan.test.ts b/packages/core/src/providers/__tests__/presets/alibaba-token-plan.test.ts index 969256655e..358295f610 100644 --- a/packages/core/src/providers/__tests__/presets/alibaba-token-plan.test.ts +++ b/packages/core/src/providers/__tests__/presets/alibaba-token-plan.test.ts @@ -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', diff --git a/packages/core/src/providers/index.ts b/packages/core/src/providers/index.ts index 1ec27bddca..17e4366435 100644 --- a/packages/core/src/providers/index.ts +++ b/packages/core/src/providers/index.ts @@ -34,6 +34,8 @@ export { shouldShowStep, } from './provider-config.js'; +export { discoverProviderModels } from './model-discovery.js'; + // Provider registry export { ALL_PROVIDERS, diff --git a/packages/core/src/providers/model-discovery.ts b/packages/core/src/providers/model-discovery.ts new file mode 100644 index 0000000000..f217604bf1 --- /dev/null +++ b/packages/core/src/providers/model-discovery.ts @@ -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(); + 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 { + 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; + } +} diff --git a/packages/core/src/providers/presets/alibaba-coding-plan.ts b/packages/core/src/providers/presets/alibaba-coding-plan.ts index 27190dbb9c..7a004fcba6 100644 --- a/packages/core/src/providers/presets/alibaba-coding-plan.ts +++ b/packages/core/src/providers/presets/alibaba-coding-plan.ts @@ -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' diff --git a/packages/core/src/providers/presets/alibaba-token-plan.ts b/packages/core/src/providers/presets/alibaba-token-plan.ts index ea678fef7f..9f70494064 100644 --- a/packages/core/src/providers/presets/alibaba-token-plan.ts +++ b/packages/core/src/providers/presets/alibaba-token-plan.ts @@ -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' diff --git a/packages/core/src/providers/types.ts b/packages/core/src/providers/types.ts index d54f72f7a4..7422a2ae78 100644 --- a/packages/core/src/providers/types.ts +++ b/packages/core/src/providers/types.ts @@ -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);