diff --git a/packages/cli/src/i18n/locales/ca.js b/packages/cli/src/i18n/locales/ca.js index c8599fd044e..7b572c4ca7b 100644 --- a/packages/cli/src/i18n/locales/ca.js +++ b/packages/cli/src/i18n/locales/ca.js @@ -2140,4 +2140,16 @@ export default { "Set up Qwen Code's status line UI": "Configurar la interfície de la barra d'estat de Qwen Code", + 'List available models from the configured API endpoint': + 'Llistar models disponibles des del punt de connexió API configurat', + 'No baseUrl configured. Please configure modelProviders or set the API endpoint.': + "No s'ha configurat cap baseUrl. Si us plau, configureu modelProviders o establiu el punt de connexió API.", + 'Failed to fetch models:': 'Error en obtenir els models:', + 'Model listing is not supported for auth type: {{authType}}. Only OpenAI-compatible endpoints are supported.': + "La llista de models no és compatible amb el tipus d'autenticació: {{authType}}. Només s'admeten punts de compatibles amb OpenAI.", + 'Request timed out. The endpoint may be slow or unreachable.': + 'Sol·licitud expirada. El punt de connexió pot ser lent o inaccessible.', + 'Request cancelled.': 'Sol·licitud cancel·lada.', + 'No models found from the configured endpoint.': + "No s'ha trobat cap model des del punt de connexió configurat.", }; diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 4cc0d50e5d2..4484b0cce7a 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -2063,4 +2063,16 @@ export default { 'Not in plan mode. Use "/plan" to enter plan mode first.', "Set up Qwen Code's status line UI": "Set up Qwen Code's status line UI", + 'List available models from the configured API endpoint': + 'Verfügbare Modelle vom konfigurierten API-Endpunkt auflisten', + 'No baseUrl configured. Please configure modelProviders or set the API endpoint.': + 'Kein baseUrl konfiguriert. Bitte konfigurieren Sie modelProviders oder setzen Sie den API-Endpunkt.', + 'Failed to fetch models:': 'Fehler beim Abrufen der Modelle:', + 'Model listing is not supported for auth type: {{authType}}. Only OpenAI-compatible endpoints are supported.': + 'Modellliste wird für Authentifizierungstyp {{authType}} nicht unterstützt. Nur OpenAI-kompatible Endpunkte werden unterstützt.', + 'Request timed out. The endpoint may be slow or unreachable.': + 'Zeitüberschreitung der Anfrage. Der Endpunkt ist möglicherweise langsam oder nicht erreichbar.', + 'Request cancelled.': 'Anfrage abgebrochen.', + 'No models found from the configured endpoint.': + 'Keine Modelle vom konfigurierten Endpunkt gefunden.', }; diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 268aa504874..58597e46400 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1210,6 +1210,18 @@ export default { 'No models available for the current authentication type ({{authType}}).': 'No models available for the current authentication type ({{authType}}).', + 'List available models from the configured API endpoint': + 'List available models from the configured API endpoint', + 'No baseUrl configured. Please configure modelProviders or set the API endpoint.': + 'No baseUrl configured. Please configure modelProviders or set the API endpoint.', + 'Failed to fetch models:': 'Failed to fetch models:', + 'Model listing is not supported for auth type: {{authType}}. Only OpenAI-compatible endpoints are supported.': + 'Model listing is not supported for auth type: {{authType}}. Only OpenAI-compatible endpoints are supported.', + 'Request timed out. The endpoint may be slow or unreachable.': + 'Request timed out. The endpoint may be slow or unreachable.', + 'Request cancelled.': 'Request cancelled.', + 'No models found from the configured endpoint.': + 'No models found from the configured endpoint.', // ============================================================================ // Commands - Clear // ============================================================================ diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index 433b61f4a07..1412c20d7b9 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -2096,4 +2096,16 @@ export default { "Set up Qwen Code's status line UI": "Configurer l'interface de la barre de statut de Qwen Code", + 'List available models from the configured API endpoint': + 'Lister les modèles disponibles depuis le point de terminaison API configuré', + 'No baseUrl configured. Please configure modelProviders or set the API endpoint.': + 'Aucun baseUrl configuré. Veuillez configurer modelProviders ou définir le point de terminaison API.', + 'Failed to fetch models:': 'Échec de la récupération des modèles :', + 'Model listing is not supported for auth type: {{authType}}. Only OpenAI-compatible endpoints are supported.': + "La liste de modèles n'est pas prise en charge pour le type d'authentification : {{authType}}. Seuls les points de terminaison compatibles OpenAI sont pris en charge.", + 'Request timed out. The endpoint may be slow or unreachable.': + "Délai d'attente dépassé. Le point de terminaison est peut-être lent ou inaccessible.", + 'Request cancelled.': 'Requête annulée.', + 'No models found from the configured endpoint.': + 'Aucun modèle trouvé depuis le point de terminaison configuré.', }; diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 94a4e5ffc6d..17b9db75ecf 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -1553,4 +1553,16 @@ export default { 'Not in plan mode. Use "/plan" to enter plan mode first.', "Set up Qwen Code's status line UI": "Set up Qwen Code's status line UI", + 'List available models from the configured API endpoint': + '設定されたAPIエンドポイントから利用可能なモデルを一覧表示', + 'No baseUrl configured. Please configure modelProviders or set the API endpoint.': + 'baseUrlが設定されていません。modelProvidersを設定するか、APIエンドポイントを設定してください。', + 'Failed to fetch models:': 'モデルの取得に失敗しました:', + 'Model listing is not supported for auth type: {{authType}}. Only OpenAI-compatible endpoints are supported.': + '認証タイプ {{authType}} ではモデル一覧はサポートされていません。OpenAI互換のエンドポイントのみサポートされています。', + 'Request timed out. The endpoint may be slow or unreachable.': + 'リクエストがタイムアウトしました。エンドポイントが遅いか到達できない可能性があります。', + 'Request cancelled.': 'リクエストがキャンセルされました。', + 'No models found from the configured endpoint.': + '設定されたエンドポイントからモデルが見つかりませんでした。', }; diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 74e9630dc28..edc285b419d 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -2054,4 +2054,16 @@ export default { 'Not in plan mode. Use "/plan" to enter plan mode first.', "Set up Qwen Code's status line UI": "Set up Qwen Code's status line UI", + 'List available models from the configured API endpoint': + 'Listar modelos disponíveis do endpoint de API configurado', + 'No baseUrl configured. Please configure modelProviders or set the API endpoint.': + 'Nenhum baseUrl configurado. Configure modelProviders ou defina o endpoint de API.', + 'Failed to fetch models:': 'Falha ao buscar modelos:', + 'Model listing is not supported for auth type: {{authType}}. Only OpenAI-compatible endpoints are supported.': + 'A listagem de modelos não é suportada para o tipo de autenticação: {{authType}}. Apenas endpoints compatíveis com OpenAI são suportados.', + 'Request timed out. The endpoint may be slow or unreachable.': + 'Tempo de solicitação esgotado. O endpoint pode estar lento ou inacessível.', + 'Request cancelled.': 'Solicitação cancelada.', + 'No models found from the configured endpoint.': + 'Nenhum modelo encontrado no endpoint configurado.', }; diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index f2071ffeda9..546dfc9c180 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -2059,4 +2059,16 @@ export default { 'Not in plan mode. Use "/plan" to enter plan mode first.', "Set up Qwen Code's status line UI": "Set up Qwen Code's status line UI", + 'List available models from the configured API endpoint': + 'Список доступных моделей из настроенной конечной точки API', + 'No baseUrl configured. Please configure modelProviders or set the API endpoint.': + 'baseUrl не настроен. Настройте modelProviders или задайте конечную точку API.', + 'Failed to fetch models:': 'Не удалось получить модели:', + 'Model listing is not supported for auth type: {{authType}}. Only OpenAI-compatible endpoints are supported.': + 'Список моделей не поддерживается для типа аутентификации: {{authType}}. Поддерживаются только конечные точки, совместимые с OpenAI.', + 'Request timed out. The endpoint may be slow or unreachable.': + 'Время ожидания запроса истекло. Конечная точка может быть медленной или недоступной.', + 'Request cancelled.': 'Запрос отменён.', + 'No models found from the configured endpoint.': + 'Модели не найдены в настроенной конечной точке.', }; diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 5ab279b510e..36259e770f6 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -1675,4 +1675,15 @@ export default { 'Not in plan mode. Use "/plan" to enter plan mode first.': '未處於計劃模式。請先使用 "/plan" 進入計劃模式。', "Set up Qwen Code's status line UI": '配置 Qwen Code 的狀態欄', + 'List available models from the configured API endpoint': + '從已設定的 API 端點列出可用模型', + 'No baseUrl configured. Please configure modelProviders or set the API endpoint.': + '未設定 baseUrl。請設定 modelProviders 或設定 API 端點。', + 'Failed to fetch models:': '取得模型失敗:', + 'Model listing is not supported for auth type: {{authType}}. Only OpenAI-compatible endpoints are supported.': + '不支援認證類型 {{authType}} 的模型列表。僅支援相容 OpenAI 的端點。', + 'Request timed out. The endpoint may be slow or unreachable.': + '請求超時。端點可能回應緩慢或無法存取。', + 'Request cancelled.': '請求已取消。', + 'No models found from the configured endpoint.': '未從已設定的端點找到模型。', }; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 701732e8709..f873e466982 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1914,4 +1914,15 @@ export default { '未处于计划模式。请先使用 "/plan" 进入计划模式。', "Set up Qwen Code's status line UI": '配置 Qwen Code 的状态栏', + 'List available models from the configured API endpoint': + '从已配置的 API 端点列出可用模型', + 'No baseUrl configured. Please configure modelProviders or set the API endpoint.': + '未配置 baseUrl。请配置 modelProviders 或设置 API 端点。', + 'Failed to fetch models:': '获取模型失败:', + 'Model listing is not supported for auth type: {{authType}}. Only OpenAI-compatible endpoints are supported.': + '不支持认证类型 {{authType}} 的模型列表。仅支持兼容 OpenAI 的端点。', + 'Request timed out. The endpoint may be slow or unreachable.': + '请求超时。端点可能响应缓慢或无法访问。', + 'Request cancelled.': '请求已取消。', + 'No models found from the configured endpoint.': '未从已配置的端点找到模型。', }; diff --git a/packages/cli/src/ui/commands/modelCommand.test.ts b/packages/cli/src/ui/commands/modelCommand.test.ts index 7da549519e2..41023cbf2a0 100644 --- a/packages/cli/src/ui/commands/modelCommand.test.ts +++ b/packages/cli/src/ui/commands/modelCommand.test.ts @@ -5,15 +5,26 @@ */ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { modelCommand } from './modelCommand.js'; +import { modelCommand, fetchModels } from './modelCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { AuthType, + getOrCreateSharedDispatcher, type ContentGeneratorConfig, type Config, } from '@qwen-code/qwen-code-core'; +// Mock the proxy dispatcher module so tests control its return value. +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getOrCreateSharedDispatcher: vi.fn().mockReturnValue(undefined), + }; +}); + // Helper function to create a mock config function createMockConfig( contentGeneratorConfig: ContentGeneratorConfig | null, @@ -192,4 +203,864 @@ describe('modelCommand', () => { }); }); }); + + describe('fetchModels', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('should parse standard OpenAI response with data array', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: 'qwen-plus' }, { id: 'qwen-max' }], + }), + }; + vi.spyOn(global, 'fetch').mockResolvedValue( + mockResponse as unknown as Response, + ); + + const result = await fetchModels('https://api.openai.com/v1', 'key'); + expect(result).toEqual(['qwen-plus', 'qwen-max']); + }); + + it('should parse response with object field (DeepSeek style)', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + object: 'list', + data: [ + { id: 'deepseek-chat', owned_by: 'deepseek' }, + { id: 'deepseek-coder', owned_by: 'deepseek' }, + ], + }), + }; + vi.spyOn(global, 'fetch').mockResolvedValue( + mockResponse as unknown as Response, + ); + + const result = await fetchModels('https://api.deepseek.com', 'key'); + expect(result).toEqual(['deepseek-chat', 'deepseek-coder']); + }); + + it('should parse bare array response (some providers)', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue([{ id: 'model-1' }, { id: 'model-2' }]), + }; + vi.spyOn(global, 'fetch').mockResolvedValue( + mockResponse as unknown as Response, + ); + + const result = await fetchModels('https://api.example.com/v1', 'key'); + expect(result).toEqual(['model-1', 'model-2']); + }); + + it('should ignore extra fields (owned_by, created, permission)', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + data: [ + { + id: 'model-1', + owned_by: 'org', + created: 1234567890, + permission: [], + }, + { id: 'model-2', owned_by: 'org', created: 1234567891 }, + ], + }), + }; + vi.spyOn(global, 'fetch').mockResolvedValue( + mockResponse as unknown as Response, + ); + + const result = await fetchModels('https://api.example.com/v1', 'key'); + expect(result).toEqual(['model-1', 'model-2']); + }); + + it('should skip entries with missing id field', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: 'model-1' }, { owned_by: 'org' }, { id: 'model-2' }], + }), + }; + vi.spyOn(global, 'fetch').mockResolvedValue( + mockResponse as unknown as Response, + ); + + const result = await fetchModels('https://api.example.com/v1', 'key'); + expect(result).toEqual(['model-1', 'model-2']); + }); + + it('should skip entries with empty id', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: 'model-1' }, { id: '' }, { id: 'model-2' }], + }), + }; + vi.spyOn(global, 'fetch').mockResolvedValue( + mockResponse as unknown as Response, + ); + + const result = await fetchModels('https://api.example.com/v1', 'key'); + expect(result).toEqual(['model-1', 'model-2']); + }); + + it('should skip entries with whitespace-only id', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: 'model-1' }, { id: ' ' }, { id: 'model-2' }], + }), + }; + vi.spyOn(global, 'fetch').mockResolvedValue( + mockResponse as unknown as Response, + ); + + const result = await fetchModels('https://api.example.com/v1', 'key'); + expect(result).toEqual(['model-1', 'model-2']); + }); + + it('should throw on non-ok HTTP response', async () => { + const mockResponse = { + ok: false, + status: 401, + text: vi.fn().mockResolvedValue('Unauthorized'), + }; + vi.spyOn(global, 'fetch').mockResolvedValue( + mockResponse as unknown as Response, + ); + + await expect( + fetchModels('https://api.example.com/v1', 'key'), + ).rejects.toThrow('Request failed (401): Unauthorized'); + }); + + it('should throw on missing data array in object response', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + object: 'list', + }), + }; + vi.spyOn(global, 'fetch').mockResolvedValue( + mockResponse as unknown as Response, + ); + + await expect( + fetchModels('https://api.example.com/v1', 'key'), + ).rejects.toThrow('Unexpected response format: missing data array'); + }); + + it('should throw on non-object, non-array response', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue('just a string'), + }; + vi.spyOn(global, 'fetch').mockResolvedValue( + mockResponse as unknown as Response, + ); + + await expect( + fetchModels('https://api.example.com/v1', 'key'), + ).rejects.toThrow( + 'Unexpected response format: response is not an object or array', + ); + }); + + it('should normalize baseUrl with trailing slash', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: 'model-1' }], + }), + }; + const fetchSpy = vi + .spyOn(global, 'fetch') + .mockResolvedValue(mockResponse as unknown as Response); + + await fetchModels('https://api.example.com/v1/', 'key'); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://api.example.com/v1/models', + expect.any(Object), + ); + }); + + it('should strip trailing /models case-insensitively to avoid double path', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: 'model-1' }], + }), + }; + const fetchSpy = vi + .spyOn(global, 'fetch') + .mockResolvedValue(mockResponse as unknown as Response); + + await fetchModels('https://api.example.com/v1/Models', 'key'); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://api.example.com/v1/models', + expect.any(Object), + ); + }); + + it('should merge customHeaders into fetch request headers', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: 'model-1' }], + }), + }; + const fetchSpy = vi + .spyOn(global, 'fetch') + .mockResolvedValue(mockResponse as unknown as Response); + + await fetchModels( + 'https://api.example.com/v1', + 'key', + undefined, + undefined, + { + 'X-Custom': 'value', + }, + ); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://api.example.com/v1/models', + expect.objectContaining({ + headers: expect.objectContaining({ + Accept: 'application/json', + 'X-Custom': 'value', + Authorization: 'Bearer key', + }), + }), + ); + }); + + it('should pass dispatcher from getOrCreateSharedDispatcher when proxy is set', async () => { + const mockDispatcher = { fake: true }; + vi.mocked(getOrCreateSharedDispatcher).mockReturnValue( + mockDispatcher as never, + ); + + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: 'model-1' }], + }), + }; + const fetchSpy = vi + .spyOn(global, 'fetch') + .mockResolvedValue(mockResponse as unknown as Response); + + await fetchModels( + 'https://api.example.com/v1', + 'key', + undefined, + 'http://proxy:8080', + ); + expect(getOrCreateSharedDispatcher).toHaveBeenCalledWith( + 'http://proxy:8080', + ); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://api.example.com/v1/models', + expect.objectContaining({ dispatcher: mockDispatcher }), + ); + + vi.mocked(getOrCreateSharedDispatcher).mockReturnValue( + undefined as never, + ); + }); + + it('should deduplicate authorization header when customHeaders has lowercase key', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: 'model-1' }], + }), + }; + const fetchSpy = vi + .spyOn(global, 'fetch') + .mockResolvedValue(mockResponse as unknown as Response); + + await fetchModels( + 'https://api.example.com/v1', + 'my-key', + undefined, + undefined, + { + authorization: 'should-be-replaced', + }, + ); + // Should only have one Authorization header with the apiKey value + const callArgs = fetchSpy.mock.calls[0][1] as { + headers: Record; + }; + const authHeaders = Object.keys(callArgs.headers).filter( + (k) => k.toLowerCase() === 'authorization', + ); + expect(authHeaders).toHaveLength(1); + expect(callArgs.headers['Authorization']).toBe('Bearer my-key'); + }); + + it('should return empty array when data array is empty', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + data: [], + }), + }; + vi.spyOn(global, 'fetch').mockResolvedValue( + mockResponse as unknown as Response, + ); + + const result = await fetchModels('https://api.example.com/v1', 'key'); + expect(result).toEqual([]); + }); + + it('should include Authorization header when apiKey is provided', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: 'model-1' }], + }), + }; + const fetchSpy = vi + .spyOn(global, 'fetch') + .mockResolvedValue(mockResponse as unknown as Response); + + await fetchModels('https://api.example.com/v1', 'my-key'); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://api.example.com/v1/models', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer my-key', + }), + }), + ); + }); + + it('should not include Authorization header when apiKey is not provided', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: 'model-1' }], + }), + }; + const fetchSpy = vi + .spyOn(global, 'fetch') + .mockResolvedValue(mockResponse as unknown as Response); + + await fetchModels('https://api.example.com/v1'); + expect(fetchSpy).toHaveBeenCalledWith( + 'https://api.example.com/v1/models', + expect.objectContaining({ + headers: expect.not.objectContaining({ + Authorization: expect.anything(), + }), + }), + ); + }); + + it('should abort via internal timeout and call clearTimeout on completion', async () => { + vi.useFakeTimers(); + const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout'); + + // Suppress the unhandled rejection that vitest reports from fake timers + const unhandledHandler = () => {}; + process.on('unhandledRejection', unhandledHandler); + + // Mock fetch to hang on a pending promise; pass the abort signal + // through so the internal AbortController timeout propagates correctly. + vi.spyOn(global, 'fetch').mockImplementation((_url, opts) => { + const signal = opts?.signal; + let rejectFn: (reason: unknown) => void; + const promise = new Promise((_resolve, reject) => { + rejectFn = reject; + }); + signal?.addEventListener('abort', () => rejectFn!(signal.reason), { + once: true, + }); + return promise; + }); + + const fetchPromise = fetchModels('https://api.example.com/v1', 'key'); + + await vi.advanceTimersByTimeAsync(15_000); + + await expect(fetchPromise).rejects.toThrow('The operation was aborted'); + + // clearTimeout must have been called in the finally block + expect(clearTimeoutSpy).toHaveBeenCalled(); + + process.removeListener('unhandledRejection', unhandledHandler); + vi.useRealTimers(); + }, 10_000); + + it('should use custom timeout instead of the default 15s', async () => { + vi.useFakeTimers(); + const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout'); + + // Mock fetch to hang on a pending promise; pass the abort signal + // through so the internal AbortController timeout propagates correctly. + vi.spyOn(global, 'fetch').mockImplementation((_url, opts) => { + const signal = opts?.signal; + let rejectFn: (reason: unknown) => void; + const promise = new Promise((_resolve, reject) => { + rejectFn = reject; + }); + signal?.addEventListener('abort', () => rejectFn!(signal.reason), { + once: true, + }); + return promise; + }); + + // Use a 5-second custom timeout + const fetchPromise = fetchModels( + 'https://api.example.com/v1', + 'key', + undefined, + undefined, + undefined, + 5_000, + ); + + // At 4 seconds the request should NOT have timed out yet + await vi.advanceTimersByTimeAsync(4_000); + // The fetch promise should still be pending — verify it hasn't resolved/rejected + let settled = false; + fetchPromise.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await vi.advanceTimersByTimeAsync(0); + expect(settled).toBe(false); + + // At 6 seconds total the custom timeout should fire + await vi.advanceTimersByTimeAsync(2_000); + await expect(fetchPromise).rejects.toThrow('The operation was aborted'); + + // clearTimeout must have been called in the finally block + expect(clearTimeoutSpy).toHaveBeenCalled(); + + vi.useRealTimers(); + }); + + it('should call clearTimeout on successful fetch completion', async () => { + const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout'); + + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: 'model-1' }], + }), + }; + vi.spyOn(global, 'fetch').mockResolvedValue( + mockResponse as unknown as Response, + ); + + await fetchModels('https://api.example.com/v1', 'key'); + + // clearTimeout is called in the finally block after a successful request + expect(clearTimeoutSpy).toHaveBeenCalled(); + }); + + it('should fall back to default timeout when timeout is 0', async () => { + vi.useFakeTimers(); + const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout'); + + // Mock fetch to hang until aborted + vi.spyOn(global, 'fetch').mockImplementation((_url, opts) => { + const signal = opts?.signal; + let rejectFn: (reason: unknown) => void; + const promise = new Promise((_resolve, reject) => { + rejectFn = reject; + }); + signal?.addEventListener('abort', () => rejectFn!(signal.reason), { + once: true, + }); + return promise; + }); + + // timeout: 0 should fall back to the default 15s, not abort immediately + const fetchPromise = fetchModels( + 'https://api.example.com/v1', + 'key', + undefined, + undefined, + undefined, + 0, + ); + + // At 1 second it should NOT have timed out (would have with timeout: 0) + await vi.advanceTimersByTimeAsync(1_000); + let settled = false; + fetchPromise.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + await vi.advanceTimersByTimeAsync(0); + expect(settled).toBe(false); + + // At 15 seconds the default timeout fires + await vi.advanceTimersByTimeAsync(14_000); + await expect(fetchPromise).rejects.toThrow('The operation was aborted'); + expect(clearTimeoutSpy).toHaveBeenCalled(); + + vi.useRealTimers(); + }, 10_000); + + it('should preserve custom authorization header when apiKey is not set', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: 'model-1' }], + }), + }; + const fetchSpy = vi + .spyOn(global, 'fetch') + .mockResolvedValue(mockResponse as unknown as Response); + + // No apiKey, but customHeaders has an authorization header + await fetchModels( + 'https://api.example.com/v1', + undefined, + undefined, + undefined, + { Authorization: 'Bearer custom-token' }, + ); + + // The custom Authorization header should be preserved + expect(fetchSpy).toHaveBeenCalledWith( + 'https://api.example.com/v1/models', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer custom-token', + }), + }), + ); + }); + }); + + describe('list subcommand', () => { + let mockContext: CommandContext; + + function getListAction() { + const cmd = modelCommand.subCommands?.find((c) => c.name === 'list'); + if (!cmd) throw new Error('list subcommand not found'); + return cmd.action!; + } + + beforeEach(() => { + mockContext = createMockCommandContext(); + vi.restoreAllMocks(); + }); + + it('should return error when config is missing', async () => { + mockContext.services.config = null; + + const result = await getListAction()(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Configuration not available.', + }); + }); + + it('should return error when contentGeneratorConfig is missing', async () => { + const mockConfig = createMockConfig(null); + mockContext.services.config = mockConfig as Config; + + const result = await getListAction()(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Content generator configuration not available.', + }); + }); + + it('should return error when baseUrl is not configured', async () => { + const mockConfig = createMockConfig({ + model: 'test-model', + authType: AuthType.USE_OPENAI, + baseUrl: undefined, + }); + mockContext.services.config = mockConfig as Config; + + const result = await getListAction()(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: + 'No baseUrl configured. Please configure modelProviders or set the API endpoint.', + }); + }); + + it('should return model list on success', async () => { + const mockConfig = createMockConfig({ + model: 'test-model', + authType: AuthType.USE_OPENAI, + baseUrl: 'https://api.example.com/v1', + apiKey: 'test-key', + }); + mockContext.services.config = mockConfig as Config; + + vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: 'model-1' }, { id: 'model-2' }], + }), + } as unknown as Response); + + const result = await getListAction()(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'model-1\nmodel-2', + }); + }); + + it('should pass proxy, customHeaders, and timeout from config to fetchModels', async () => { + const mockDispatcher = { fake: true }; + vi.mocked(getOrCreateSharedDispatcher).mockReturnValue( + mockDispatcher as never, + ); + + const mockConfig = createMockConfig({ + model: 'test-model', + authType: AuthType.USE_OPENAI, + baseUrl: 'https://api.example.com/v1', + apiKey: 'test-key', + proxy: 'http://proxy:8080', + customHeaders: { 'X-Custom': 'value' }, + timeout: 5_000, + }); + mockContext.services.config = mockConfig as Config; + + const fetchSpy = vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ + data: [{ id: 'model-1' }], + }), + } as unknown as Response); + + const result = await getListAction()(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'model-1', + }); + + // Verify proxy dispatcher was looked up + expect(getOrCreateSharedDispatcher).toHaveBeenCalledWith( + 'http://proxy:8080', + ); + + // Verify fetch was called with dispatcher, custom headers, and timeout signal + expect(fetchSpy).toHaveBeenCalledWith( + 'https://api.example.com/v1/models', + expect.objectContaining({ + dispatcher: mockDispatcher, + headers: expect.objectContaining({ + 'X-Custom': 'value', + Authorization: 'Bearer test-key', + }), + signal: expect.any(AbortSignal), + }), + ); + + vi.mocked(getOrCreateSharedDispatcher).mockReturnValue( + undefined as never, + ); + }); + + it('should handle Error instance throw from fetchModels', async () => { + const mockConfig = createMockConfig({ + model: 'test-model', + authType: AuthType.USE_OPENAI, + baseUrl: 'https://api.example.com/v1', + apiKey: 'test-key', + }); + mockContext.services.config = mockConfig as Config; + + vi.spyOn(global, 'fetch').mockRejectedValue(new Error('Network error')); + + const result = await getListAction()(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: expect.stringContaining('Failed to fetch models:'), + }); + }); + + it('should handle non-Error throw from fetchModels', async () => { + const mockConfig = createMockConfig({ + model: 'test-model', + authType: AuthType.USE_OPENAI, + baseUrl: 'https://api.example.com/v1', + apiKey: 'test-key', + }); + mockContext.services.config = mockConfig as Config; + + vi.spyOn(global, 'fetch').mockRejectedValue('string error'); + + const result = await getListAction()(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: expect.stringContaining('Failed to fetch models:'), + }); + }); + + it('should return error for QWEN_OAUTH auth type', async () => { + const mockConfig = createMockConfig({ + model: 'test-model', + authType: AuthType.QWEN_OAUTH, + baseUrl: 'https://api.example.com/v1', + apiKey: 'test-key', + }); + mockContext.services.config = mockConfig as Config; + + const result = await getListAction()(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: expect.stringContaining('not supported for auth type'), + }); + }); + + it('should return "no models found" when endpoint returns empty array', async () => { + const mockConfig = createMockConfig({ + model: 'test-model', + authType: AuthType.USE_OPENAI, + baseUrl: 'https://api.example.com/v1', + apiKey: 'test-key', + }); + mockContext.services.config = mockConfig as Config; + + vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue({ data: [] }), + } as unknown as Response); + + const result = await getListAction()(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('No models found'), + }); + }); + + it('should return "timed out" on AbortError when abortSignal is not aborted (timeout)', async () => { + const mockConfig = createMockConfig({ + model: 'test-model', + authType: AuthType.USE_OPENAI, + baseUrl: 'https://api.example.com/v1', + apiKey: 'test-key', + }); + mockContext.services.config = mockConfig as Config; + + const abortError = new DOMException( + 'The operation was aborted', + 'AbortError', + ); + vi.spyOn(global, 'fetch').mockRejectedValue(abortError); + + // abortSignal exists but is NOT aborted → timeout + mockContext.abortSignal = new AbortController().signal; + + const result = await getListAction()(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: expect.stringContaining('timed out'), + }); + }); + + it('should return "cancelled" on AbortError when abortSignal is aborted (user cancel)', async () => { + const mockConfig = createMockConfig({ + model: 'test-model', + authType: AuthType.USE_OPENAI, + baseUrl: 'https://api.example.com/v1', + apiKey: 'test-key', + }); + mockContext.services.config = mockConfig as Config; + + const abortError = new DOMException( + 'The operation was aborted', + 'AbortError', + ); + vi.spyOn(global, 'fetch').mockRejectedValue(abortError); + + // abortSignal exists AND is aborted → user cancel + const controller = new AbortController(); + controller.abort(); + mockContext.abortSignal = controller.signal; + + const result = await getListAction()(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: expect.stringContaining('cancelled'), + }); + }); + }); + + describe('fetchModels error handling', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('should handle response.text() throwing in error path', async () => { + const mockResponse = { + ok: false, + status: 500, + text: vi.fn().mockRejectedValue(new Error('body stream locked')), + }; + vi.spyOn(global, 'fetch').mockResolvedValue( + mockResponse as unknown as Response, + ); + + await expect( + fetchModels('https://api.example.com/v1', 'key'), + ).rejects.toThrow( + 'Request failed (500): (unable to read error response)', + ); + }); + + it('should throw on non-JSON 200 response', async () => { + const mockResponse = { + ok: true, + json: vi.fn().mockRejectedValue(new SyntaxError('Unexpected token')), + }; + vi.spyOn(global, 'fetch').mockResolvedValue( + mockResponse as unknown as Response, + ); + + await expect( + fetchModels('https://api.example.com/v1', 'key'), + ).rejects.toThrow('Invalid JSON response from /models endpoint'); + }); + }); }); diff --git a/packages/cli/src/ui/commands/modelCommand.ts b/packages/cli/src/ui/commands/modelCommand.ts index 1ee5e60836b..6f6bdf46490 100644 --- a/packages/cli/src/ui/commands/modelCommand.ts +++ b/packages/cli/src/ui/commands/modelCommand.ts @@ -13,6 +13,10 @@ import type { import { CommandKind } from './types.js'; import { t } from '../../i18n/index.js'; import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; +import { + AuthType, + getOrCreateSharedDispatcher, +} from '@qwen-code/qwen-code-core'; export const modelCommand: SlashCommand = { name: 'model', @@ -148,4 +152,231 @@ export const modelCommand: SlashCommand = { dialog: 'model', }; }, + subCommands: [ + { + name: 'list', + get description() { + return t('List available models from the configured API endpoint'); + }, + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + action: async ( + context: CommandContext, + _args: string, + ): Promise => { + const { services } = context; + const { config } = services; + + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Configuration not available.'), + }; + } + + const contentGeneratorConfig = config.getContentGeneratorConfig(); + if (!contentGeneratorConfig) { + return { + type: 'message', + messageType: 'error', + content: t('Content generator configuration not available.'), + }; + } + + const { baseUrl, apiKey, authType, proxy, customHeaders, timeout } = + contentGeneratorConfig; + + if (!authType || authType !== AuthType.USE_OPENAI) { + return { + type: 'message', + messageType: 'error', + content: t( + 'Model listing is not supported for auth type: {{authType}}. Only OpenAI-compatible endpoints are supported.', + { authType: authType ?? 'none' }, + ), + }; + } + + if (!baseUrl) { + return { + type: 'message', + messageType: 'error', + content: t( + 'No baseUrl configured. Please configure modelProviders or set the API endpoint.', + ), + }; + } + + try { + const models = await fetchModels( + baseUrl, + apiKey, + context.abortSignal, + proxy, + customHeaders, + timeout, + ); + + if (models.length === 0) { + return { + type: 'message', + messageType: 'info', + content: t('No models found from the configured endpoint.'), + }; + } + + return { + type: 'message', + messageType: 'info', + content: models.join('\n'), + }; + } catch (error) { + let errorMessage: string; + if (error instanceof DOMException && error.name === 'AbortError') { + const isTimeout = !context.abortSignal?.aborted; + errorMessage = isTimeout + ? t('Request timed out. The endpoint may be slow or unreachable.') + : t('Request cancelled.'); + } else { + errorMessage = + error instanceof Error ? error.message : String(error); + } + return { + type: 'message', + messageType: 'error', + content: `${t('Failed to fetch models:')} ${errorMessage}`, + }; + } + }, + }, + ], }; + +/** + * Fetch available models from the OpenAI-compatible /models endpoint. + * Handles multiple response shapes: + * - Standard: { data: [{ id: "qwen-plus" }] } + * - With object field: { object: "list", data: [{ id: "deepseek-chat", ... }] } + * - Bare array: [{ id: "model" }] (some providers) + * Extra fields (owned_by, created, etc.) are ignored. + * Export for testing. + */ +const DEFAULT_FETCH_TIMEOUT_MS = 15_000; + +export async function fetchModels( + baseUrl: string, + apiKey?: string, + abortSignal?: AbortSignal, + proxy?: string, + customHeaders?: Record, + timeout?: number, +): Promise { + // Normalize baseUrl: strip trailing slashes and trailing /models (case-insensitive) to avoid + // double path (e.g., "https://api.example.com/v1/models" → "/models/models") + const normalizedUrl = baseUrl.replace(/\/+$/, '').replace(/\/models$/i, ''); + const url = `${normalizedUrl}/models`; + + // Build headers with customHeaders support (mirrors provider pattern) + const defaultHeaders: Record = { + Accept: 'application/json', + }; + const headers = customHeaders + ? { ...defaultHeaders, ...customHeaders } + : { ...defaultHeaders }; + + if (apiKey) { + // Deduplicate any existing authorization header (case-insensitive) before + // setting the Bearer token to avoid sending two Authorization headers when + // customHeaders contains a lowercase 'authorization' key. + const existingAuthKey = Object.keys(headers).find( + (k) => k.toLowerCase() === 'authorization', + ); + if (existingAuthKey) { + delete headers[existingAuthKey]; + } + headers['Authorization'] = `Bearer ${apiKey}`; + } + + // Set up timeout and optional user abort signal + const fetchTimeout = + timeout && timeout > 0 ? timeout : DEFAULT_FETCH_TIMEOUT_MS; + const timeoutController = new AbortController(); + const timeoutId = setTimeout(() => timeoutController.abort(), fetchTimeout); + + const signal = abortSignal + ? AbortSignal.any([timeoutController.signal, abortSignal]) + : timeoutController.signal; + + // Build proxy-aware fetch options (mirrors provider pattern) + const runtimeOptions = getOrCreateSharedDispatcher(proxy); + + try { + const response = await fetch(url, { + method: 'GET', + headers, + signal, + ...(runtimeOptions ? { dispatcher: runtimeOptions } : {}), + }); + if (!response.ok) { + let errorText: string; + try { + errorText = (await response.text()).slice(0, 500); + } catch { + errorText = '(unable to read error response)'; + } + throw new Error(`Request failed (${response.status}): ${errorText}`); + } + + let body: unknown; + try { + body = await response.json(); + } catch { + throw new Error( + 'Invalid JSON response from /models endpoint. Check that the baseUrl points to a valid OpenAI-compatible API.', + ); + } + + // Extract the models array from various response shapes. + // 1. Standard OpenAI: { data: [{ id: "..." }] } + // 2. With object field: { object: "list", data: [{ id: "...", owned_by: "...", ... }] } + // 3. Bare array: [{ id: "..." }] (some providers skip the wrapper) + let modelArray: unknown[]; + + if (Array.isArray(body)) { + // Shape 3: bare array + modelArray = body; + } else if (body && typeof body === 'object') { + // Shapes 1 & 2: look for data property using bracket notation + const obj = body as Record; + if (Array.isArray(obj['data'])) { + modelArray = obj['data'] as unknown[]; + } else { + throw new Error('Unexpected response format: missing data array'); + } + } else { + throw new Error( + 'Unexpected response format: response is not an object or array', + ); + } + + // Extract model IDs. + // The only required field is `id` (string, non-empty). + // All other fields (owned_by, created, object, permission, etc.) are ignored. + const modelIds: string[] = []; + + for (const item of modelArray) { + if (item && typeof item === 'object') { + const model = item as Record; + const id = model['id']; + if (typeof id === 'string' && id.trim().length > 0) { + modelIds.push(id.trim()); + } + } + } + + return modelIds; + } finally { + clearTimeout(timeoutId); + } +}