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', () => {