mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-27 01:23:52 +00:00
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
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:
parent
fc874dfe0b
commit
647fdff036
12 changed files with 837 additions and 35 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
245
packages/core/src/providers/__tests__/model-discovery.test.ts
Normal file
245
packages/core/src/providers/__tests__/model-discovery.test.ts
Normal 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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ export {
|
|||
shouldShowStep,
|
||||
} from './provider-config.js';
|
||||
|
||||
export { discoverProviderModels } from './model-discovery.js';
|
||||
|
||||
// Provider registry
|
||||
export {
|
||||
ALL_PROVIDERS,
|
||||
|
|
|
|||
110
packages/core/src/providers/model-discovery.ts
Normal file
110
packages/core/src/providers/model-discovery.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue