diff --git a/.gitignore b/.gitignore index 9644bc90bda..9ad5700b245 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,4 @@ storybook-static # Dev symlink: qc-helper bundled skill docs (created by scripts/dev.js) packages/core/src/skills/bundled/qc-helper/docs +tmp/ \ No newline at end of file diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index ad40a34cbaf..c635d1a273a 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -515,6 +515,7 @@ export const AppContainer = (props: AppContainerProps) => { handleCodingPlanSubmit, handleAlibabaStandardSubmit, handleOpenRouterSubmit, + handleCustomApiKeySubmit, openAuthDialog, cancelAuthentication, } = useAuthCommand(settings, config, historyManager.addItem, refreshStatic); @@ -2355,6 +2356,7 @@ export const AppContainer = (props: AppContainerProps) => { handleCodingPlanSubmit, handleAlibabaStandardSubmit, handleOpenRouterSubmit, + handleCustomApiKeySubmit, handleEditorSelect, exitEditorDialog, closeSettingsDialog, @@ -2424,6 +2426,7 @@ export const AppContainer = (props: AppContainerProps) => { handleCodingPlanSubmit, handleAlibabaStandardSubmit, handleOpenRouterSubmit, + handleCustomApiKeySubmit, handleEditorSelect, exitEditorDialog, closeSettingsDialog, diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx index bee260f6b2b..4310e2da5f7 100644 --- a/packages/cli/src/ui/auth/AuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx @@ -70,6 +70,23 @@ const renderAuthDialog = ( ); }; +/** + * Type text into the terminal one character at a time. + * Works around a Node 24.x + ink compatibility issue on Windows + * where bulk stdin.write() may not propagate to TextInput correctly. + */ +const typeText = async ( + stdin: { write: (s: string) => void }, + text: string, +) => { + const delay = (ms = 5) => new Promise((resolve) => setTimeout(resolve, ms)); + for (const char of text) { + stdin.write(char); + await delay(5); + } + await delay(30); +}; + describe('AuthDialog', () => { const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -666,3 +683,515 @@ describe('AuthDialog', () => { unmount(); }); }); + +describe('AuthDialog Custom API Key Wizard', () => { + const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms)); + + const createStandardSettings = (): LoadedSettings => + new LoadedSettings( + { + settings: { ui: { customThemes: {} }, mcpServers: {} }, + originalSettings: { ui: { customThemes: {} }, mcpServers: {} }, + path: '', + }, + { + settings: {}, + originalSettings: {}, + path: '', + }, + { + settings: { + security: { auth: { selectedType: undefined } }, + ui: { customThemes: {} }, + mcpServers: {}, + }, + originalSettings: { + security: { auth: { selectedType: undefined } }, + ui: { customThemes: {} }, + mcpServers: {}, + }, + path: '', + }, + { + settings: { ui: { customThemes: {} }, mcpServers: {} }, + originalSettings: { ui: { customThemes: {} }, mcpServers: {} }, + path: '', + }, + true, + new Set(), + ); + + it('navigates to protocol selection when Custom API Key is selected', async () => { + const settings = createStandardSettings(); + const handleCustomApiKeySubmit = vi.fn(); + + const mockUIState = { + authError: null, + pendingAuthType: undefined, + } as UIState; + + const mockUIActions = { + handleAuthSelect: vi.fn(), + handleCodingPlanSubmit: vi.fn(), + handleAlibabaStandardSubmit: vi.fn(), + handleOpenRouterSubmit: vi.fn(), + handleCustomApiKeySubmit, + onAuthError: vi.fn(), + handleRetryLastPrompt: vi.fn(), + } as unknown as UIActions; + + const mockConfig = { + getAuthType: vi.fn(() => undefined), + getContentGeneratorConfig: vi.fn(() => ({})), + } as unknown as Config; + + const { stdin, lastFrame, unmount } = renderWithProviders( + + + + + , + { settings, config: mockConfig }, + ); + await wait(); + + // Press down twice to select API Key (from default OAUTH, down once wraps to CODING_PLAN, down again to API_KEY) + stdin.write('\u001b[B'); // Down from OAUTH -> CODING_PLAN + await wait(); + stdin.write('\u001b[B'); // Down from CODING_PLAN -> API_KEY + await wait(); + stdin.write('\r'); // Enter + await wait(); + + // Now on api-key-type-select,Encoding we need to see both options + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Custom API Key'); + }); + + // Select Custom API Key (second option) + stdin.write('\u001b[B'); // Down arrow + await wait(); + stdin.write('\r'); // Enter + await wait(); + + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Step 1/6 · Protocol'); + expect(frame).toContain('OpenAI-compatible'); + expect(frame).toContain('Anthropic-compatible'); + expect(frame).toContain('Gemini-compatible'); + }); + + unmount(); + }); + + it('navigates to base URL input after selecting a protocol', async () => { + const settings = createStandardSettings(); + const handleCustomApiKeySubmit = vi.fn(); + + const mockUIState = { + authError: null, + pendingAuthType: undefined, + } as UIState; + + const mockUIActions = { + handleAuthSelect: vi.fn(), + handleCodingPlanSubmit: vi.fn(), + handleAlibabaStandardSubmit: vi.fn(), + handleOpenRouterSubmit: vi.fn(), + handleCustomApiKeySubmit, + onAuthError: vi.fn(), + handleRetryLastPrompt: vi.fn(), + } as unknown as UIActions; + + const mockConfig = { + getAuthType: vi.fn(() => undefined), + getContentGeneratorConfig: vi.fn(() => ({})), + } as unknown as Config; + + const { stdin, lastFrame, unmount } = renderWithProviders( + + + + + , + { settings, config: mockConfig }, + ); + await wait(); + + // Navigate: Main -> API Key Type -> Custom API Key -> Protocol select + stdin.write('\u001b[B'); // Down from OAUTH -> CODING_PLAN + await wait(); + stdin.write('\u001b[B'); // Down from CODING_PLAN -> API_KEY + await wait(); + stdin.write('\r'); // Enter + await wait(); + stdin.write('\u001b[B'); // Down to Custom API Key + await wait(); + stdin.write('\r'); // Enter -> protocol select + await wait(); + + // Now at protocol selection. First option is OpenAI. Press Enter + stdin.write('\r'); // Enter -> select OpenAI protocol + await wait(); + + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Step 2/6 · Base URL'); + expect(frame).toContain('Enter the API endpoint'); + }); + + unmount(); + }); + + it('shows review screen with JSON after entering model IDs', async () => { + const settings = createStandardSettings(); + const handleCustomApiKeySubmit = vi.fn(); + + const mockUIState = { + authError: null, + pendingAuthType: undefined, + } as UIState; + + const mockUIActions = { + handleAuthSelect: vi.fn(), + handleCodingPlanSubmit: vi.fn(), + handleAlibabaStandardSubmit: vi.fn(), + handleOpenRouterSubmit: vi.fn(), + handleCustomApiKeySubmit, + onAuthError: vi.fn(), + handleRetryLastPrompt: vi.fn(), + } as unknown as UIActions; + + const mockConfig = { + getAuthType: vi.fn(() => undefined), + getContentGeneratorConfig: vi.fn(() => ({})), + } as unknown as Config; + + const { stdin, lastFrame, unmount } = renderWithProviders( + + + + + , + { settings, config: mockConfig }, + ); + await wait(); + + // Navigate through the wizard: + // Main -> API Key -> Custom API Key -> Protocol -> Base URL -> API Key -> Model IDs -> Review + stdin.write('\u001b[B'); + await wait(); // OAUTH -> CODING_PLAN + stdin.write('\u001b[B'); + await wait(); // CODING_PLAN -> API_KEY + stdin.write('\r'); + await wait(); // -> api-key-type-select + stdin.write('\u001b[B'); + await wait(); // Custom API Key + stdin.write('\r'); + await wait(); // -> protocol select + + // Default protocol is OpenAI, press Enter + stdin.write('\r'); + await wait(); // -> base URL input + + // Base URL is pre-filled with default. Submit it. + stdin.write('\r'); + await wait(); // -> API key input + + // Enter test API key + stdin.write('sk-test-key-12345'); + await wait(); + stdin.write('\r'); + await wait(); // -> model IDs input + + // Enter model IDs + stdin.write('qwen/qwen3-coder,gpt-4.1'); + await wait(); + stdin.write('\r'); + await wait(); // -> advanced config + + // Press Enter to skip advanced config (use defaults) + stdin.write('\r'); + await wait(); // -> review + + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Step 6/6 · Review'); + expect(frame).toContain('The following JSON will be saved'); + expect(frame).toContain('QWEN_CUSTOM_API_KEY_OPENAI'); + expect(frame).toContain('qwen/qwen3-coder'); + expect(frame).toContain('gpt-4.1'); + expect(frame).toContain('Enter to save'); + }); + + unmount(); + }); + + it('calls handleCustomApiKeySubmit on Enter in review view', async () => { + const settings = createStandardSettings(); + const handleCustomApiKeySubmit = vi.fn().mockResolvedValue(undefined); + + const mockUIState = { + authError: null, + pendingAuthType: undefined, + } as UIState; + + const mockUIActions = { + handleAuthSelect: vi.fn(), + handleCodingPlanSubmit: vi.fn(), + handleAlibabaStandardSubmit: vi.fn(), + handleOpenRouterSubmit: vi.fn(), + handleCustomApiKeySubmit, + onAuthError: vi.fn(), + handleRetryLastPrompt: vi.fn(), + } as unknown as UIActions; + + const mockConfig = { + getAuthType: vi.fn(() => undefined), + getContentGeneratorConfig: vi.fn(() => ({})), + } as unknown as Config; + + const { stdin, lastFrame, unmount } = renderWithProviders( + + + + + , + { settings, config: mockConfig }, + ); + await wait(); + + // Navigate through wizard + stdin.write('\u001b[B'); + await wait(); // OAUTH -> CODING_PLAN + stdin.write('\u001b[B'); + await wait(); // CODING_PLAN -> API_KEY + stdin.write('\r'); + await wait(); + stdin.write('\u001b[B'); + await wait(); // Custom + stdin.write('\r'); + await wait(); + + stdin.write('\r'); + await wait(); // protocol (OpenAI default) + stdin.write('\r'); + await wait(); // base URL (default) + stdin.write('sk-test'); + await wait(); + stdin.write('\r'); + await wait(); // API key + + await typeText(stdin, 'model-1,model-2'); + stdin.write('\r'); + await wait(); // model IDs -> advanced config + + // Press Enter to skip advanced config (use defaults) + stdin.write('\r'); + await wait(); // advanced config -> review + + // We're now at review screen. Verify and press Enter + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Enter to save'); + }); + + stdin.write('\r'); // Enter to save + await wait(); + + await vi.waitFor(() => { + expect(handleCustomApiKeySubmit).toHaveBeenCalledWith( + AuthType.USE_OPENAI, + 'https://api.openai.com/v1', + 'sk-test', + 'model-1,model-2', + undefined, + ); + }); + + unmount(); + }); + + it('shows advanced config screen after entering model IDs', async () => { + const settings = createStandardSettings(); + const handleCustomApiKeySubmit = vi.fn(); + + const mockUIState = { + authError: null, + pendingAuthType: undefined, + } as UIState; + + const mockUIActions = { + handleAuthSelect: vi.fn(), + handleCodingPlanSubmit: vi.fn(), + handleAlibabaStandardSubmit: vi.fn(), + handleOpenRouterSubmit: vi.fn(), + handleCustomApiKeySubmit, + onAuthError: vi.fn(), + handleRetryLastPrompt: vi.fn(), + } as unknown as UIActions; + + const mockConfig = { + getAuthType: vi.fn(() => undefined), + getContentGeneratorConfig: vi.fn(() => ({})), + } as unknown as Config; + + const { stdin, lastFrame, unmount } = renderWithProviders( + + + + + , + { settings, config: mockConfig }, + ); + await wait(); + + // Quick nav: main -> api-key-type-select -> custom -> protocol -> base-url -> api-key -> model-id -> advanced + stdin.write('\u001b[B'); + await wait(); + stdin.write('\u001b[B'); + await wait(); + stdin.write('\r'); + await wait(); + stdin.write('\u001b[B'); + await wait(); + stdin.write('\r'); + await wait(); + stdin.write('\r'); + await wait(); + stdin.write('\r'); + await wait(); + await typeText(stdin, 'sk-test'); + stdin.write('\r'); + await wait(); + await typeText(stdin, 'model-1,model-2'); + stdin.write('\r'); + await wait(); + + // Should be at advanced config + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Step 5/6 · Advanced Config'); + expect(frame).toContain( + 'Optional: configure advanced generation settings', + ); + expect(frame).toContain('Enable thinking'); + expect(frame).toContain('Enable modality'); + expect(frame).toContain('Enter to continue'); + }); + + unmount(); + }); + + it('passes generationConfig when advanced options are toggled', async () => { + const settings = createStandardSettings(); + const handleCustomApiKeySubmit = vi.fn().mockResolvedValue(undefined); + + const mockUIState = { + authError: null, + pendingAuthType: undefined, + } as UIState; + + const mockUIActions = { + handleAuthSelect: vi.fn(), + handleCodingPlanSubmit: vi.fn(), + handleAlibabaStandardSubmit: vi.fn(), + handleOpenRouterSubmit: vi.fn(), + handleCustomApiKeySubmit, + onAuthError: vi.fn(), + handleRetryLastPrompt: vi.fn(), + } as unknown as UIActions; + + const mockConfig = { + getAuthType: vi.fn(() => undefined), + getContentGeneratorConfig: vi.fn(() => ({})), + } as unknown as Config; + + const { stdin, lastFrame, unmount } = renderWithProviders( + + + + + , + { settings, config: mockConfig }, + ); + await wait(); + + // Quick nav to advanced config + stdin.write('\u001b[B'); + await wait(); + stdin.write('\u001b[B'); + await wait(); + stdin.write('\r'); + await wait(); + stdin.write('\u001b[B'); + await wait(); + stdin.write('\r'); + await wait(); + stdin.write('\r'); + await wait(); + stdin.write('\r'); + await wait(); + await typeText(stdin, 'sk-test'); + stdin.write('\r'); + await wait(); + await typeText(stdin, 'model-1'); + stdin.write('\r'); + await wait(); + + // At advanced config screen + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('Step 5/6 · Advanced Config'); + }); + + // Toggle thinking (press Space — thinking is initially focused) + stdin.write(' '); + await wait(); + + // Navigate down to modality, toggle (press ↓ then Space) + stdin.write('\u001b[B'); + await wait(); + stdin.write(' '); + await wait(); + + // Press Enter to continue to review + stdin.write('\r'); + await wait(); + + // Verify review includes generationConfig + await vi.waitFor(() => { + const frame = lastFrame(); + expect(frame).toContain('"generationConfig"'); + expect(frame).toContain('"enable_thinking"'); + expect(frame).toContain('"image": true'); + expect(frame).toContain('"video": true'); + expect(frame).toContain('"audio": true'); + }); + + // Press Enter to save + stdin.write('\r'); + await wait(); + + await vi.waitFor(() => { + expect(handleCustomApiKeySubmit).toHaveBeenCalledWith( + AuthType.USE_OPENAI, + 'https://api.openai.com/v1', + 'sk-test', + 'model-1', + { + enableThinking: true, + multimodal: { + image: true, + video: true, + audio: true, + }, + }, + ); + }); + + unmount(); + }); +}); diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index c77e3f0c2a3..4d32b003f49 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -26,6 +26,11 @@ import { ALIBABA_STANDARD_API_KEY_ENDPOINTS, type AlibabaStandardRegion, } from '../../constants/alibabaStandardApiKey.js'; +import { + generateCustomApiKeyEnvKey, + normalizeCustomModelIds, + maskApiKey, +} from './useAuth.js'; const MODEL_PROVIDERS_DOCUMENTATION_URL = 'https://qwenlm.github.io/qwen-code-docs/en/users/configuration/model-providers/'; @@ -62,7 +67,12 @@ type ViewLevel = | 'alibaba-standard-region-select' | 'alibaba-standard-api-key-input' | 'alibaba-standard-model-id-input' - | 'custom-info' + | 'custom-protocol-select' + | 'custom-base-url-input' + | 'custom-api-key-input' + | 'custom-model-id-input' + | 'custom-advanced-config' + | 'custom-review-json' | 'oauth-provider-select'; const ALIBABA_STANDARD_MODEL_IDS_PLACEHOLDER = 'qwen3.5-plus,glm-5,kimi-k2.5'; @@ -86,6 +96,7 @@ export function AuthDialog(): React.JSX.Element { handleCodingPlanSubmit, handleAlibabaStandardSubmit, handleOpenRouterSubmit, + handleCustomApiKeySubmit, onAuthError, } = useUIActions(); const config = useConfig(); @@ -110,6 +121,30 @@ export function AuthDialog(): React.JSX.Element { const [alibabaStandardModelIdError, setAlibabaStandardModelIdError] = useState(null); + // Custom API Key wizard state + const [customProtocolIndex, setCustomProtocolIndex] = useState(0); + const [customProtocol, setCustomProtocol] = useState( + AuthType.USE_OPENAI, + ); + const [customBaseUrl, setCustomBaseUrl] = useState(''); + const [customBaseUrlError, setCustomBaseUrlError] = useState( + null, + ); + const [customApiKey, setCustomApiKey] = useState(''); + const [customApiKeyError, setCustomApiKeyError] = useState( + null, + ); + const [customModelIds, setCustomModelIds] = useState(''); + const [customModelIdsError, setCustomModelIdsError] = useState( + null, + ); + + // Advanced generation config state + const [advancedThinkingEnabled, setAdvancedThinkingEnabled] = useState(false); + const [advancedModalityEnabled, setAdvancedModalityEnabled] = useState(false); + const [focusedConfigIndex, setFocusedConfigIndex] = useState(0); + // 0 = thinking, 1 = modality + // Main authentication entries (flat three-option layout) const mainItems = [ { @@ -222,6 +257,38 @@ export function AuthDialog(): React.JSX.Element { }, ]; + const protocolItems = [ + { + key: AuthType.USE_OPENAI, + title: t('OpenAI-compatible'), + label: t('OpenAI-compatible'), + description: t( + 'OpenAI Chat Completions API (OpenRouter, vLLM, Ollama, LM Studio, Fireworks, etc.)', + ), + value: AuthType.USE_OPENAI as AuthType, + }, + { + key: AuthType.USE_ANTHROPIC, + title: t('Anthropic-compatible'), + label: t('Anthropic-compatible'), + description: t('Anthropic Messages API'), + value: AuthType.USE_ANTHROPIC as AuthType, + }, + { + key: AuthType.USE_GEMINI, + title: t('Gemini-compatible'), + label: t('Gemini-compatible'), + description: t('Google Gemini API'), + value: AuthType.USE_GEMINI as AuthType, + }, + ]; + + const DEFAULT_CUSTOM_BASE_URLS: Partial> = { + [AuthType.USE_OPENAI]: 'https://api.openai.com/v1', + [AuthType.USE_ANTHROPIC]: 'https://api.anthropic.com/v1', + [AuthType.USE_GEMINI]: 'https://generativelanguage.googleapis.com', + }; + const apiKeyTypeItems = [ { key: 'ALIBABA_STANDARD_API_KEY', @@ -348,7 +415,19 @@ export function AuthDialog(): React.JSX.Element { return; } - setViewLevel('custom-info'); + // Reset custom wizard state and go to protocol selection + setCustomProtocolIndex(0); + setCustomProtocol(AuthType.USE_OPENAI); + setCustomBaseUrl(''); + setCustomBaseUrlError(null); + setCustomApiKey(''); + setCustomApiKeyError(null); + setCustomModelIds(''); + setCustomModelIdsError(null); + setAdvancedThinkingEnabled(false); + setAdvancedModalityEnabled(false); + setFocusedConfigIndex(0); + setViewLevel('custom-protocol-select'); }; const handleOAuthProviderSelect = async (value: OAuthOption) => { @@ -450,6 +529,89 @@ export function AuthDialog(): React.JSX.Element { ); }; + const handleCustomProtocolSelect = (protocol: AuthType) => { + setErrorMessage(null); + onAuthError(null); + setCustomProtocol(protocol); + const defaultUrl = DEFAULT_CUSTOM_BASE_URLS[protocol] ?? ''; + setCustomBaseUrl(defaultUrl); + setCustomBaseUrlError(null); + setViewLevel('custom-base-url-input'); + }; + + const handleCustomBaseUrlSubmit = () => { + const trimmedUrl = customBaseUrl.trim(); + if (!trimmedUrl) { + setCustomBaseUrlError(t('Base URL cannot be empty.')); + return; + } + if (!/^https?:\/\//i.test(trimmedUrl)) { + setCustomBaseUrlError(t('Base URL must start with http:// or https://.')); + return; + } + setCustomBaseUrlError(null); + setCustomApiKey(''); + setCustomApiKeyError(null); + setViewLevel('custom-api-key-input'); + }; + + const handleCustomApiKeySubmitLocal = () => { + const trimmedKey = customApiKey.trim(); + if (!trimmedKey) { + setCustomApiKeyError(t('API key cannot be empty.')); + return; + } + setCustomApiKeyError(null); + setCustomModelIds(''); + setCustomModelIdsError(null); + setViewLevel('custom-model-id-input'); + }; + + const handleCustomModelIdSubmit = () => { + const normalized = normalizeCustomModelIds(customModelIds); + if (normalized.length === 0) { + setCustomModelIdsError(t('Model IDs cannot be empty.')); + return; + } + setCustomModelIdsError(null); + setViewLevel('custom-advanced-config'); + }; + + const handleAdvancedConfigSubmit = () => { + setViewLevel('custom-review-json'); + }; + + const handleCustomReviewSubmit = () => { + const trimmedBaseUrl = customBaseUrl.trim(); + const trimmedApiKey = customApiKey.trim(); + const trimmedModelIds = customModelIds; + + // Build generationConfig only if any advanced option is set + const hasThinking = advancedThinkingEnabled; + const hasModality = advancedModalityEnabled; + + const generationConfig = + hasThinking || hasModality + ? { + enableThinking: hasThinking ? true : undefined, + multimodal: hasModality + ? { image: true, video: true, audio: true } + : undefined, + } + : undefined; + + void handleCustomApiKeySubmit( + customProtocol as + | AuthType.USE_OPENAI + | AuthType.USE_ANTHROPIC + | AuthType.USE_GEMINI, + trimmedBaseUrl, + trimmedApiKey, + trimmedModelIds, + generationConfig, + ); + }; + const handleGoBack = () => { setErrorMessage(null); onAuthError(null); @@ -460,8 +622,18 @@ export function AuthDialog(): React.JSX.Element { setViewLevel('region-select'); } else if (viewLevel === 'api-key-type-select') { setViewLevel('main'); - } else if (viewLevel === 'custom-info') { + } else if (viewLevel === 'custom-protocol-select') { setViewLevel('api-key-type-select'); + } else if (viewLevel === 'custom-base-url-input') { + setViewLevel('custom-protocol-select'); + } else if (viewLevel === 'custom-api-key-input') { + setViewLevel('custom-base-url-input'); + } else if (viewLevel === 'custom-model-id-input') { + setViewLevel('custom-api-key-input'); + } else if (viewLevel === 'custom-advanced-config') { + setViewLevel('custom-model-id-input'); + } else if (viewLevel === 'custom-review-json') { + setViewLevel('custom-advanced-config'); } else if (viewLevel === 'alibaba-standard-region-select') { setViewLevel('api-key-type-select'); } else if (viewLevel === 'alibaba-standard-api-key-input') { @@ -482,7 +654,18 @@ export function AuthDialog(): React.JSX.Element { return; } - if (viewLevel === 'api-key-input' || viewLevel === 'custom-info') { + if (viewLevel === 'api-key-input') { + handleGoBack(); + return; + } + if ( + viewLevel === 'custom-protocol-select' || + viewLevel === 'custom-base-url-input' || + viewLevel === 'custom-api-key-input' || + viewLevel === 'custom-model-id-input' || + viewLevel === 'custom-advanced-config' || + viewLevel === 'custom-review-json' + ) { handleGoBack(); return; } @@ -515,6 +698,50 @@ export function AuthDialog(): React.JSX.Element { { isActive: true }, ); + // Handle Enter key for review view to save + useKeypress( + (key) => { + if (key.name === 'return' && viewLevel === 'custom-review-json') { + handleCustomReviewSubmit(); + } + }, + { isActive: true }, + ); + + // Advanced config keypress: ↑↓ to navigate, Space to toggle, Enter to submit + useKeypress( + (key) => { + if (viewLevel !== 'custom-advanced-config') return; + + const { name } = key; + + if (name === 'up') { + setFocusedConfigIndex((v) => (v <= 0 ? 1 : v - 1)); + return; + } + + if (name === 'down') { + setFocusedConfigIndex((v) => (v >= 1 ? 0 : v + 1)); + return; + } + + if (name === 'space') { + if (focusedConfigIndex === 0) { + setAdvancedThinkingEnabled((v) => !v); + } else { + setAdvancedModalityEnabled((v) => !v); + } + return; + } + + if (name === 'return') { + handleAdvancedConfigSubmit(); + return; + } + }, + { isActive: true }, + ); + // Render main auth selection const renderMainView = () => ( <> @@ -697,30 +924,274 @@ export function AuthDialog(): React.JSX.Element { ); - // Render custom mode info - const renderCustomInfoView = () => ( + // Render custom protocol selection + const renderCustomProtocolSelectView = () => ( <> + + { + const index = protocolItems.findIndex( + (item) => item.value === value, + ); + setCustomProtocolIndex(index); + }} + itemGap={1} + /> + + + + {t('Enter to select, ↑↓ to navigate, Esc to go back')} + + + + ); + + // Render custom base URL input + const renderCustomBaseUrlInputView = () => ( + - {t('You can configure your API key and models in settings.json')} + {t('Enter the API endpoint for this protocol.')} - {t('Refer to the documentation for setup instructions')} + { + setCustomBaseUrl(value); + if (customBaseUrlError) { + setCustomBaseUrlError(null); + } + }} + onSubmit={handleCustomBaseUrlSubmit} + placeholder="https://api.openai.com/v1" + /> - + {customBaseUrlError && ( + + {customBaseUrlError} + + )} + - {MODEL_PROVIDERS_DOCUMENTATION_URL} + {t( + 'Need advanced generationConfig or capabilities? See documentation', + )} - {t('Esc to go back')} + + {t('Enter to submit, Esc to go back')} + - + ); + // Render custom API key input + const renderCustomApiKeyInputView = () => ( + + + + {t('Enter the API key for this endpoint.')} + + + + { + setCustomApiKey(value); + if (customApiKeyError) { + setCustomApiKeyError(null); + } + }} + onSubmit={handleCustomApiKeySubmitLocal} + placeholder="sk-..." + /> + + {customApiKeyError && ( + + {customApiKeyError} + + )} + + + {t('Enter to submit, Esc to go back')} + + + + ); + + // Render custom model ID input + const renderCustomModelIdInputView = () => ( + + + + {t('Enter one or more model IDs, separated by commas.')} + + + + { + setCustomModelIds(value); + if (customModelIdsError) { + setCustomModelIdsError(null); + } + }} + onSubmit={handleCustomModelIdSubmit} + placeholder="qwen/qwen3-coder,openai/gpt-4.1" + /> + + {customModelIdsError && ( + + {customModelIdsError} + + )} + + + {t('Enter to submit, Esc to go back')} + + + + ); + + // Render custom advanced config + const renderCustomAdvancedConfigView = () => { + const checkmark = (v: boolean) => (v ? '◉' : '○'); + const cursor = (index: number) => + focusedConfigIndex === index ? '›' : ' '; + + return ( + + + + {t('Optional: configure advanced generation settings.')} + + + + + {cursor(0)} {checkmark(advancedThinkingEnabled)}{' '} + {t('Enable thinking')} + + + + + {t( + 'Allows the model to perform extended reasoning before responding.', + )} + + + + + {cursor(1)} {checkmark(advancedModalityEnabled)}{' '} + {t('Enable modality')} + + + + + {t('Enables image, video, and audio input/output capabilities.')} + + + + + {t( + '\u2191\u2193 to navigate, Space to toggle, Enter to continue, Esc to go back', + )} + + + + ); + }; + + // Render custom review JSON + const renderCustomReviewJsonView = () => { + const generatedEnvKey = generateCustomApiKeyEnvKey( + customProtocol, + customBaseUrl.trim(), + ); + const normalizedIds = normalizeCustomModelIds(customModelIds); + const maskedKey = maskApiKey(customApiKey); + + // Build generationConfig preview lines + const hasThinking = advancedThinkingEnabled; + const hasModality = advancedModalityEnabled; + const hasGenConfig = hasThinking || hasModality; + + let genConfig: Record | undefined; + if (hasGenConfig) { + genConfig = {}; + if (hasModality) { + genConfig['modalities'] = { + image: true, + video: true, + audio: true, + }; + } + if (hasThinking) { + genConfig['extra_body'] = { + enable_thinking: true, + }; + } + } + + const modelEntries = normalizedIds.map((id) => { + const entry: Record = { + id, + name: id, + baseUrl: customBaseUrl.trim(), + envKey: generatedEnvKey, + }; + if (genConfig) { + entry['generationConfig'] = genConfig; + } + return entry; + }); + + const preview = { + env: { [generatedEnvKey]: maskedKey }, + modelProviders: { + [customProtocol]: modelEntries, + }, + security: { + auth: { + selectedType: customProtocol, + }, + }, + model: { + name: normalizedIds[0], + }, + }; + + const jsonPreview = JSON.stringify(preview, null, 2); + + return ( + + + + {t('The following JSON will be saved to settings.json:')} + + + + {jsonPreview} + + + + {t('Enter to save, Esc to go back')} + + + + ); + }; + const renderOAuthProviderSelectView = () => ( <> @@ -755,8 +1226,18 @@ export function AuthDialog(): React.JSX.Element { return t('Enter Coding Plan API Key'); case 'api-key-type-select': return t('Select API Key Type'); - case 'custom-info': - return t('Custom Configuration'); + case 'custom-protocol-select': + return t('Step 1/6 \u00B7 Protocol'); + case 'custom-base-url-input': + return t('Step 2/6 \u00B7 Base URL'); + case 'custom-api-key-input': + return t('Step 3/6 \u00B7 API Key'); + case 'custom-model-id-input': + return t('Step 4/6 \u00B7 Model IDs'); + case 'custom-advanced-config': + return t('Step 5/6 \u00B7 Advanced Config'); + case 'custom-review-json': + return t('Step 6/6 \u00B7 Review'); case 'alibaba-standard-region-select': return t( 'Select Region for Alibaba Cloud ModelStudio Standard API Key', @@ -792,7 +1273,14 @@ export function AuthDialog(): React.JSX.Element { renderAlibabaStandardApiKeyInputView()} {viewLevel === 'alibaba-standard-model-id-input' && renderAlibabaStandardModelIdInputView()} - {viewLevel === 'custom-info' && renderCustomInfoView()} + {viewLevel === 'custom-protocol-select' && + renderCustomProtocolSelectView()} + {viewLevel === 'custom-base-url-input' && renderCustomBaseUrlInputView()} + {viewLevel === 'custom-api-key-input' && renderCustomApiKeyInputView()} + {viewLevel === 'custom-model-id-input' && renderCustomModelIdInputView()} + {viewLevel === 'custom-advanced-config' && + renderCustomAdvancedConfigView()} + {viewLevel === 'custom-review-json' && renderCustomReviewJsonView()} {viewLevel === 'oauth-provider-select' && renderOAuthProviderSelectView()} {(authError || errorMessage) && ( diff --git a/packages/cli/src/ui/auth/useAuth.test.ts b/packages/cli/src/ui/auth/useAuth.test.ts index 53dcb0cf02d..1f48532b6b1 100644 --- a/packages/cli/src/ui/auth/useAuth.test.ts +++ b/packages/cli/src/ui/auth/useAuth.test.ts @@ -7,7 +7,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { AuthType } from '@qwen-code/qwen-code-core'; -import { useAuthCommand } from './useAuth.js'; +import { + useAuthCommand, + generateCustomApiKeyEnvKey, + normalizeCustomModelIds, + maskApiKey, +} from './useAuth.js'; import { OPENROUTER_OAUTH_CALLBACK_URL, applyOpenRouterModelsConfiguration, @@ -192,3 +197,126 @@ describe('useAuthCommand', () => { ); }); }); + +describe('generateCustomApiKeyEnvKey', () => { + it('generates env key from openai protocol and base URL', () => { + const key = generateCustomApiKeyEnvKey( + 'openai', + 'https://api.openai.com/v1', + ); + expect(key).toBe('QWEN_CUSTOM_API_KEY_OPENAI_HTTPS_API_OPENAI_COM_V1'); + }); + + it('generates env key from anthropic protocol and base URL', () => { + const key = generateCustomApiKeyEnvKey( + 'anthropic', + 'https://api.anthropic.com/v1', + ); + expect(key).toBe( + 'QWEN_CUSTOM_API_KEY_ANTHROPIC_HTTPS_API_ANTHROPIC_COM_V1', + ); + }); + + it('generates env key from gemini protocol and base URL', () => { + const key = generateCustomApiKeyEnvKey( + 'gemini', + 'https://generativelanguage.googleapis.com', + ); + expect(key).toBe( + 'QWEN_CUSTOM_API_KEY_GEMINI_HTTPS_GENERATIVELANGUAGE_GOOGLEAPIS_COM', + ); + }); + + it('handles localhost URLs', () => { + const key = generateCustomApiKeyEnvKey( + 'openai', + 'http://localhost:11434/v1', + ); + expect(key).toBe('QWEN_CUSTOM_API_KEY_OPENAI_HTTP_LOCALHOST_11434_V1'); + }); + + it('normalizes trailing slashes and special chars', () => { + const key = generateCustomApiKeyEnvKey( + 'openai', + 'https://openrouter.ai/api/v1/', + ); + expect(key).toBe('QWEN_CUSTOM_API_KEY_OPENAI_HTTPS_OPENROUTER_AI_API_V1'); + }); + + it('different protocols with same base URL produce different keys', () => { + const baseUrl = 'https://api.example.com/v1'; + const openaiKey = generateCustomApiKeyEnvKey('openai', baseUrl); + const anthropicKey = generateCustomApiKeyEnvKey('anthropic', baseUrl); + expect(openaiKey).not.toBe(anthropicKey); + expect(openaiKey).toContain('OPENAI'); + expect(anthropicKey).toContain('ANTHROPIC'); + }); +}); + +describe('normalizeCustomModelIds', () => { + it('splits comma-separated model IDs', () => { + const result = normalizeCustomModelIds('qwen/qwen3-coder,openai/gpt-4.1'); + expect(result).toEqual(['qwen/qwen3-coder', 'openai/gpt-4.1']); + }); + + it('trims whitespace from each model ID', () => { + const result = normalizeCustomModelIds( + ' qwen/qwen3-coder , openai/gpt-4.1 ', + ); + expect(result).toEqual(['qwen/qwen3-coder', 'openai/gpt-4.1']); + }); + + it('deduplicates while preserving order', () => { + const result = normalizeCustomModelIds( + 'qwen/qwen3-coder,openai/gpt-4.1,qwen/qwen3-coder', + ); + expect(result).toEqual(['qwen/qwen3-coder', 'openai/gpt-4.1']); + }); + + it('removes empty entries', () => { + const result = normalizeCustomModelIds('qwen/qwen3-coder,,openai/gpt-4.1'); + expect(result).toEqual(['qwen/qwen3-coder', 'openai/gpt-4.1']); + }); + + it('returns empty array for empty input', () => { + const result = normalizeCustomModelIds(''); + expect(result).toEqual([]); + }); + + it('returns empty array for whitespace-only input', () => { + const result = normalizeCustomModelIds(' , , '); + expect(result).toEqual([]); + }); + + it('handles single model ID', () => { + const result = normalizeCustomModelIds('qwen/qwen3-coder'); + expect(result).toEqual(['qwen/qwen3-coder']); + }); +}); + +describe('maskApiKey', () => { + it('masks a standard API key showing first 3 and last 4 chars', () => { + const result = maskApiKey('sk-or-v1-1234567890abcdef'); + expect(result).toBe('sk-...cdef'); + }); + + it('shows placeholder for empty string', () => { + const result = maskApiKey(''); + expect(result).toBe('(not set)'); + }); + + it('masks short keys with asterisks', () => { + const result = maskApiKey('abc'); + expect(result).toBe('***'); + }); + + it('masks 6-char keys with asterisks', () => { + const result = maskApiKey('abcdef'); + expect(result).toBe('***'); + }); + + it('trims whitespace before masking', () => { + const result = maskApiKey(' sk-or-v1-1234567890abcdef '); + expect(result).toBe('sk-...cdef'); + }); +}); diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index 022451eaa4e..0d3a9a938c4 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -46,6 +46,47 @@ import { runOpenRouterOAuthLogin, } from '../../commands/auth/openrouterOAuth.js'; +/** + * Generate a Qwen-managed env key from protocol and base URL. + * Format: QWEN_CUSTOM_API_KEY_${PROTOCOL}_${NORMALIZED_BASE_URL} + */ +export function generateCustomApiKeyEnvKey( + protocol: string, + baseUrl: string, +): string { + const normalize = (value: string) => + value + .trim() + .toUpperCase() + .replace(/[^A-Z0-9]+/g, '_') + .replace(/_+/g, '_') + .replace(/^_+|_+$/g, ''); + + return `QWEN_CUSTOM_API_KEY_${normalize(protocol)}_${normalize(baseUrl)}`; +} + +/** + * Normalize model IDs: split by comma, trim, deduplicate, remove empty. + */ +export function normalizeCustomModelIds(modelIdsInput: string): string[] { + return modelIdsInput + .split(',') + .map((id) => id.trim()) + .filter((id, index, array) => id.length > 0 && array.indexOf(id) === index); +} + +/** + * Mask an API key for display: show first 3 and last 4 chars. + */ +export function maskApiKey(apiKey: string): string { + const trimmed = apiKey.trim(); + if (trimmed.length === 0) return '(not set)'; + if (trimmed.length <= 6) return '***'; + const head = trimmed.slice(0, 3); + const tail = trimmed.slice(-4); + return `${head}...${tail}`; +} + export type { QwenAuthState } from '../hooks/useQwenAuth.js'; export const useAuthCommand = ( @@ -684,6 +725,174 @@ export const useAuthCommand = ( setOpenRouterAuthAbortController, ]); + /** + * Handle custom API key setup wizard submission. + * Persists key to env[generatedEnvKey] and creates modelProviders entries. + */ + const handleCustomApiKeySubmit = useCallback( + async ( + protocol: + | AuthType.USE_OPENAI + | AuthType.USE_ANTHROPIC + | AuthType.USE_GEMINI, + baseUrl: string, + apiKey: string, + modelIdsInput: string, + generationConfig?: { + enableThinking?: boolean; + multimodal?: { + image?: boolean; + video?: boolean; + audio?: boolean; + }; + maxTokens?: number; + }, + ) => { + try { + setIsAuthenticating(true); + setAuthError(null); + + const trimmedApiKey = apiKey.trim(); + const trimmedBaseUrl = baseUrl.trim(); + const modelIds = normalizeCustomModelIds(modelIdsInput); + + if (!trimmedApiKey) { + throw new Error(t('API key cannot be empty.')); + } + if (!trimmedBaseUrl) { + throw new Error(t('Base URL cannot be empty.')); + } + if (!/^https?:\/\//i.test(trimmedBaseUrl)) { + throw new Error(t('Base URL must start with http:// or https://.')); + } + if (modelIds.length === 0) { + throw new Error(t('Model IDs cannot be empty.')); + } + + const generatedEnvKey = generateCustomApiKeyEnvKey( + protocol, + trimmedBaseUrl, + ); + const persistScope = getPersistScopeForModelSelection(settings); + + const settingsFile = settings.forScope(persistScope); + backupSettingsFile(settingsFile.path); + + // Persist API key to env + settings.setValue( + persistScope, + `env.${generatedEnvKey}`, + trimmedApiKey, + ); + process.env[generatedEnvKey] = trimmedApiKey; + + // Build generationConfig if any option is set + let genConfig: ProviderModelConfig['generationConfig'] | undefined; + if (generationConfig) { + const hasThinking = generationConfig.enableThinking === true; + const hasMultimodal = + generationConfig.multimodal && + (generationConfig.multimodal.image === true || + generationConfig.multimodal.video === true || + generationConfig.multimodal.audio === true); + const hasMaxTokens = + generationConfig.maxTokens !== undefined && + generationConfig.maxTokens > 0; + + if (hasThinking || hasMultimodal || hasMaxTokens) { + genConfig = {}; + if (hasMultimodal) { + genConfig.modalities = { + image: generationConfig.multimodal!.image ?? false, + video: generationConfig.multimodal!.video ?? false, + audio: generationConfig.multimodal!.audio ?? false, + }; + } + if (hasThinking) { + genConfig.extra_body = { enable_thinking: true }; + } + if (hasMaxTokens) { + genConfig.samplingParams = { + max_tokens: generationConfig.maxTokens, + }; + } + } + } + + // Build new model configs + const newConfigs: ProviderModelConfig[] = modelIds.map((modelId) => ({ + id: modelId, + name: modelId, + baseUrl: trimmedBaseUrl, + envKey: generatedEnvKey, + ...(genConfig ? { generationConfig: genConfig } : {}), + })); + + // Merge with existing configs: replace same generatedEnvKey, preserve rest + const existingConfigs = + ( + settings.merged.modelProviders as ModelProvidersConfig | undefined + )?.[protocol] || []; + + const preservedConfigs = existingConfigs.filter( + (existing) => existing.envKey !== generatedEnvKey, + ); + + const updatedConfigs = [...newConfigs, ...preservedConfigs]; + + // Persist modelProviders, security, model + settings.setValue( + persistScope, + `modelProviders.${protocol}`, + updatedConfigs, + ); + settings.setValue(persistScope, 'security.auth.selectedType', protocol); + settings.setValue(persistScope, 'model.name', modelIds[0]); + + // Hot-reload before refreshAuth + const updatedModelProviders: ModelProvidersConfig = { + ...(settings.merged.modelProviders as + | ModelProvidersConfig + | undefined), + [protocol]: updatedConfigs, + }; + config.reloadModelProvidersConfig(updatedModelProviders); + await config.refreshAuth(protocol); + + setAuthError(null); + setAuthState(AuthState.Authenticated); + setPendingAuthType(undefined); + setIsAuthDialogOpen(false); + setIsAuthenticating(false); + onAuthChange?.(); + + addItem( + { + type: MessageType.INFO, + text: t( + 'Custom API Key authenticated successfully. Settings updated with generated env key and model provider config.', + ), + }, + Date.now(), + ); + + addItem( + { + type: MessageType.INFO, + text: t('Tip: Use /model to switch between configured models.'), + }, + Date.now(), + ); + + const authEvent = new AuthEvent(protocol, 'manual', 'success'); + logAuth(config, authEvent); + } catch (error) { + handleAuthFailure(error); + } + }, + [settings, config, handleAuthFailure, addItem, onAuthChange], + ); + /** /** * We previously used a useEffect to trigger authentication automatically when @@ -738,6 +947,7 @@ export const useAuthCommand = ( handleCodingPlanSubmit, handleAlibabaStandardSubmit, handleOpenRouterSubmit, + handleCustomApiKeySubmit, openAuthDialog, cancelAuthentication, }; diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 6092710c021..7525f17dd25 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -53,6 +53,24 @@ export interface UIActions { modelIdsInput: string, ) => Promise; handleOpenRouterSubmit: () => Promise; + handleCustomApiKeySubmit: ( + protocol: + | AuthType.USE_OPENAI + | AuthType.USE_ANTHROPIC + | AuthType.USE_GEMINI, + baseUrl: string, + apiKey: string, + modelIdsInput: string, + generationConfig?: { + enableThinking?: boolean; + multimodal?: { + image?: boolean; + video?: boolean; + audio?: boolean; + }; + maxTokens?: number; + }, + ) => Promise; setAuthState: (state: AuthState) => void; onAuthError: (error: string | null) => void; cancelAuthentication: () => void; diff --git a/packages/core/src/utils/fileUtils.test.ts b/packages/core/src/utils/fileUtils.test.ts index 5f1bc1034d9..a1f529cdf3a 100644 --- a/packages/core/src/utils/fileUtils.test.ts +++ b/packages/core/src/utils/fileUtils.test.ts @@ -40,6 +40,40 @@ vi.mock('mime/lite', () => ({ getType: vi.fn(), })); +// Mock execFile so isPdftotextAvailable does not spawn a real process. +// On platforms where pdftotext is not installed (e.g. Windows CI), +// the 5-second execFile timeout can exceed the default 5s test timeout. +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execFile: vi.fn( + ( + _command: string, + _args: string[], + _optionsOrCallback: unknown, + _callback?: unknown, + ) => { + // Resolve the callback (supports both signatures of execFile) + const cb = + typeof _optionsOrCallback === 'function' + ? _optionsOrCallback + : _callback; + const error = Object.assign(new Error('Command not found'), { + code: 'ENOENT', + }); + if (typeof cb === 'function') { + setImmediate(() => cb(error, '', '')); + } + return { + kill: vi.fn(), + on: vi.fn(), + } as unknown as import('node:child_process').ChildProcess; + }, + ), + }; +}); + const mockMimeGetType = mime.getType as Mock; describe('fileUtils', () => {