From eb6fbe518eb84b30766c787c6054f4caa940953e Mon Sep 17 00:00:00 2001 From: HitMargin Date: Sun, 5 Apr 2026 03:31:06 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=86=E7=A6=BBOpenAI=E5=92=8CAnthropic?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E7=9A=84=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 2 +- docs/plans/openai-compatibility.md | 16 +- src/components/ConsoleOAuthFlow.tsx | 1166 ++++++++--------- .../api/openai/__tests__/modelMapping.test.ts | 6 + src/services/api/openai/modelMapping.ts | 21 +- src/utils/managedEnvConstants.ts | 34 + src/utils/model/model.ts | 18 + src/utils/model/modelOptions.ts | 69 +- src/utils/model/modelSupportOverrides.ts | 23 +- 9 files changed, 689 insertions(+), 666 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c3ada0762..4cd5556ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -180,7 +180,7 @@ Feature flag `VOICE_MODE`,dev/build 默认启用。Push-to-Talk 语音输入 - **`src/services/api/openai/`** — client、消息/工具转换、流适配、模型映射 - **`src/utils/model/providers.ts`** — 添加 `'openai'` provider 类型(最高优先级) -关键环境变量:`CLAUDE_CODE_USE_OPENAI`、`OPENAI_API_KEY`、`OPENAI_BASE_URL`、`OPENAI_MODEL`、`OPENAI_MODEL_MAP`。详见 `docs/plans/openai-compatibility.md`。 +关键环境变量:`CLAUDE_CODE_USE_OPENAI`、`OPENAI_API_KEY`、`OPENAI_BASE_URL`、`OPENAI_MODEL`、`OPENAI_DEFAULT_OPUS_MODEL`、`OPENAI_DEFAULT_SONNET_MODEL`、`OPENAI_DEFAULT_HAIKU_MODEL`。详见 `docs/plans/openai-compatibility.md`。 ### Key Type Files diff --git a/docs/plans/openai-compatibility.md b/docs/plans/openai-compatibility.md index 68fa9f158..35cc41657 100644 --- a/docs/plans/openai-compatibility.md +++ b/docs/plans/openai-compatibility.md @@ -14,7 +14,9 @@ claude-code 支持通过 OpenAI Chat Completions API(`/v1/chat/completions`) | `OPENAI_API_KEY` | 是 | API key(Ollama 等可设为任意值) | | `OPENAI_BASE_URL` | 推荐 | 端点 URL(如 `http://localhost:11434/v1`) | | `OPENAI_MODEL` | 可选 | 覆盖所有请求的模型名(跳过映射) | -| `OPENAI_MODEL_MAP` | 可选 | JSON 映射,如 `{"claude-sonnet-4-6":"gpt-4o"}` | +| `OPENAI_DEFAULT_OPUS_MODEL` | 可选 | 覆盖 opus 家族对应的模型(如 `o3`, `o3-mini`, `o1-pro`) | +| `OPENAI_DEFAULT_SONNET_MODEL` | 可选 | 覆盖 sonnet 家族对应的模型(如 `gpt-4o`, `gpt-4.1`) | +| `OPENAI_DEFAULT_HAIKU_MODEL` | 可选 | 覆盖 haiku 家族对应的模型(如 `gpt-4o-mini`, `gpt-4.0-mini`) | | `OPENAI_ORG_ID` | 可选 | Organization ID | | `OPENAI_PROJECT_ID` | 可选 | Project ID | @@ -49,11 +51,12 @@ OPENAI_BASE_URL=https://your-one-api.example.com/v1 \ OPENAI_MODEL=gpt-4o \ bun run dev -# 自定义模型映射 +# 自定义模型映射(使用家族变量) CLAUDE_CODE_USE_OPENAI=1 \ OPENAI_API_KEY=sk-xxx \ OPENAI_BASE_URL=https://my-gateway.example.com/v1 \ -OPENAI_MODEL_MAP='{"claude-sonnet-4-6":"gpt-4o-2024-11-20","claude-haiku-4-5":"gpt-4o-mini"}' \ +OPENAI_DEFAULT_SONNET_MODEL="gpt-4o-2024-11-20" \ +OPENAI_DEFAULT_HAIKU_MODEL="gpt-4o-mini" \ bun run dev ``` @@ -85,9 +88,10 @@ queryModel() [claude.ts] `resolveOpenAIModel()` 的解析顺序: 1. `OPENAI_MODEL` 环境变量 → 直接使用,覆盖所有 -2. `OPENAI_MODEL_MAP` JSON 查表 → 自定义映射 -3. 内置默认映射(见下表) -4. 以上都不匹配 → 原名透传 +2. `OPENAI_DEFAULT_{FAMILY}_MODEL` 变量(如 `OPENAI_DEFAULT_SONNET_MODEL`)→ 按模型家族覆盖 +3. `ANTHROPIC_DEFAULT_{FAMILY}_MODEL` 变量(向后兼容) +4. 内置默认映射(见下表) +5. 以上都不匹配 → 原名透传 ### 内置模型映射 diff --git a/src/components/ConsoleOAuthFlow.tsx b/src/components/ConsoleOAuthFlow.tsx index 5ddc67255..b71ab02e5 100644 --- a/src/components/ConsoleOAuthFlow.tsx +++ b/src/components/ConsoleOAuthFlow.tsx @@ -1,52 +1,52 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from 'src/services/analytics/index.js' -import { installOAuthTokens } from '../cli/handlers/auth.js' -import { useTerminalSize } from '../hooks/useTerminalSize.js' -import { setClipboard } from '../ink/termio/osc.js' -import { useTerminalNotification } from '../ink/useTerminalNotification.js' -import { Box, Link, Text } from '../ink.js' -import { useKeybinding } from '../keybindings/useKeybinding.js' -import { getSSLErrorHint } from '../services/api/errorUtils.js' -import { sendNotification } from '../services/notifier.js' -import { OAuthService } from '../services/oauth/index.js' -import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js' -import { logError } from '../utils/log.js' -import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js' -import { Select } from './CustomSelect/select.js' -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' -import { Spinner } from './Spinner.js' -import TextInput from './TextInput.js' +} from 'src/services/analytics/index.js'; +import { installOAuthTokens } from '../cli/handlers/auth.js'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { setClipboard } from '../ink/termio/osc.js'; +import { useTerminalNotification } from '../ink/useTerminalNotification.js'; +import { Box, Link, Text } from '../ink.js'; +import { useKeybinding } from '../keybindings/useKeybinding.js'; +import { getSSLErrorHint } from '../services/api/errorUtils.js'; +import { sendNotification } from '../services/notifier.js'; +import { OAuthService } from '../services/oauth/index.js'; +import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js'; +import { logError } from '../utils/log.js'; +import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js'; +import { Select } from './CustomSelect/select.js'; +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; +import { Spinner } from './Spinner.js'; +import TextInput from './TextInput.js'; type Props = { - onDone(): void - startingMessage?: string - mode?: 'login' | 'setup-token' - forceLoginMethod?: 'claudeai' | 'console' -} + onDone(): void; + startingMessage?: string; + mode?: 'login' | 'setup-token'; + forceLoginMethod?: 'claudeai' | 'console'; +}; type OAuthStatus = | { state: 'idle' } // Initial state, waiting to select login method | { state: 'platform_setup' } // Show platform setup info (Bedrock/Vertex/Foundry) | { - state: 'custom_platform' - baseUrl: string - apiKey: string - haikuModel: string - sonnetModel: string - opusModel: string - activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model' + state: 'custom_platform'; + baseUrl: string; + apiKey: string; + haikuModel: string; + sonnetModel: string; + opusModel: string; + activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; } // Custom platform: configure API endpoint and model names | { - state: 'openai_chat_api' - baseUrl: string - apiKey: string - haikuModel: string - sonnetModel: string - opusModel: string - activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model' + state: 'openai_chat_api'; + baseUrl: string; + apiKey: string; + haikuModel: string; + sonnetModel: string; + opusModel: string; + activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; } // OpenAI Chat Completions API platform | { state: 'ready_to_start' } // Flow started, waiting for browser to open | { state: 'waiting_for_login'; url: string } // Browser opened, waiting for user to login @@ -54,12 +54,12 @@ type OAuthStatus = | { state: 'about_to_retry'; nextState: OAuthStatus } | { state: 'success'; token?: string } | { - state: 'error' - message: string - toRetry?: OAuthStatus - } + state: 'error'; + message: string; + toRetry?: OAuthStatus; + }; -const PASTE_HERE_MSG = 'Paste code here if prompted > ' +const PASTE_HERE_MSG = 'Paste code here if prompted > '; export function ConsoleOAuthFlow({ onDone, @@ -67,158 +67,153 @@ export function ConsoleOAuthFlow({ mode = 'login', forceLoginMethod: forceLoginMethodProp, }: Props): React.ReactNode { - const settings = getSettings_DEPRECATED() || {} - const forceLoginMethod = forceLoginMethodProp ?? settings.forceLoginMethod - const orgUUID = settings.forceLoginOrgUUID + const settings = getSettings_DEPRECATED() || {}; + const forceLoginMethod = forceLoginMethodProp ?? settings.forceLoginMethod; + const orgUUID = settings.forceLoginOrgUUID; const forcedMethodMessage = forceLoginMethod === 'claudeai' ? 'Login method pre-selected: Subscription Plan (Claude Pro/Max)' : forceLoginMethod === 'console' ? 'Login method pre-selected: API Usage Billing (Anthropic Console)' - : null + : null; - const terminal = useTerminalNotification() + const terminal = useTerminalNotification(); const [oauthStatus, setOAuthStatus] = useState(() => { if (mode === 'setup-token') { - return { state: 'ready_to_start' } + return { state: 'ready_to_start' }; } if (forceLoginMethod === 'claudeai' || forceLoginMethod === 'console') { - return { state: 'ready_to_start' } + return { state: 'ready_to_start' }; } - return { state: 'idle' } - }) + return { state: 'idle' }; + }); - const [pastedCode, setPastedCode] = useState('') - const [cursorOffset, setCursorOffset] = useState(0) - const [oauthService] = useState(() => new OAuthService()) + const [pastedCode, setPastedCode] = useState(''); + const [cursorOffset, setCursorOffset] = useState(0); + const [oauthService] = useState(() => new OAuthService()); const [loginWithClaudeAi, setLoginWithClaudeAi] = useState(() => { // Use Claude AI auth for setup-token mode to support user:inference scope - return mode === 'setup-token' || forceLoginMethod === 'claudeai' - }) + return mode === 'setup-token' || forceLoginMethod === 'claudeai'; + }); // After a few seconds we suggest the user to copy/paste url if the // browser did not open automatically. In this flow we expect the user to // copy the code from the browser and paste it in the terminal - const [showPastePrompt, setShowPastePrompt] = useState(false) - const [urlCopied, setUrlCopied] = useState(false) + const [showPastePrompt, setShowPastePrompt] = useState(false); + const [urlCopied, setUrlCopied] = useState(false); - const textInputColumns = useTerminalSize().columns - PASTE_HERE_MSG.length - 1 + const textInputColumns = useTerminalSize().columns - PASTE_HERE_MSG.length - 1; // Log forced login method on mount useEffect(() => { if (forceLoginMethod === 'claudeai') { - logEvent('tengu_oauth_claudeai_forced', {}) + logEvent('tengu_oauth_claudeai_forced', {}); } else if (forceLoginMethod === 'console') { - logEvent('tengu_oauth_console_forced', {}) + logEvent('tengu_oauth_console_forced', {}); } - }, [forceLoginMethod]) + }, [forceLoginMethod]); // Retry logic useEffect(() => { if (oauthStatus.state === 'about_to_retry') { - const timer = setTimeout(setOAuthStatus, 1000, oauthStatus.nextState) - return () => clearTimeout(timer) + const timer = setTimeout(setOAuthStatus, 1000, oauthStatus.nextState); + return () => clearTimeout(timer); } - }, [oauthStatus]) + }, [oauthStatus]); // Handle Enter to continue on success state useKeybinding( 'confirm:yes', () => { - logEvent('tengu_oauth_success', { loginWithClaudeAi }) - onDone() + logEvent('tengu_oauth_success', { loginWithClaudeAi }); + onDone(); }, { context: 'Confirmation', isActive: oauthStatus.state === 'success' && mode !== 'setup-token', }, - ) + ); // Handle Enter to continue from platform setup useKeybinding( 'confirm:yes', () => { - setOAuthStatus({ state: 'idle' }) + setOAuthStatus({ state: 'idle' }); }, { context: 'Confirmation', isActive: oauthStatus.state === 'platform_setup', }, - ) + ); // Handle Enter to retry on error state useKeybinding( 'confirm:yes', () => { if (oauthStatus.state === 'error' && oauthStatus.toRetry) { - setPastedCode('') + setPastedCode(''); setOAuthStatus({ state: 'about_to_retry', nextState: oauthStatus.toRetry, - }) + }); } }, { context: 'Confirmation', isActive: oauthStatus.state === 'error' && !!oauthStatus.toRetry, }, - ) + ); useEffect(() => { - if ( - pastedCode === 'c' && - oauthStatus.state === 'waiting_for_login' && - showPastePrompt && - !urlCopied - ) { + if (pastedCode === 'c' && oauthStatus.state === 'waiting_for_login' && showPastePrompt && !urlCopied) { void setClipboard(oauthStatus.url).then(raw => { - if (raw) process.stdout.write(raw) - setUrlCopied(true) - setTimeout(setUrlCopied, 2000, false) - }) - setPastedCode('') + if (raw) process.stdout.write(raw); + setUrlCopied(true); + setTimeout(setUrlCopied, 2000, false); + }); + setPastedCode(''); } - }, [pastedCode, oauthStatus, showPastePrompt, urlCopied]) + }, [pastedCode, oauthStatus, showPastePrompt, urlCopied]); async function handleSubmitCode(value: string, url: string) { try { // Expecting format "authorizationCode#state" from the authorization callback URL - const [authorizationCode, state] = value.split('#') + const [authorizationCode, state] = value.split('#'); if (!authorizationCode || !state) { setOAuthStatus({ state: 'error', message: 'Invalid code. Please make sure the full code was copied', toRetry: { state: 'waiting_for_login', url }, - }) - return + }); + return; } // Track which path the user is taking (manual code entry) - logEvent('tengu_oauth_manual_entry', {}) + logEvent('tengu_oauth_manual_entry', {}); oauthService.handleManualAuthCodeInput({ authorizationCode, state, - }) + }); } catch (err: unknown) { - logError(err) + logError(err); setOAuthStatus({ state: 'error', message: (err as Error).message, toRetry: { state: 'waiting_for_login', url }, - }) + }); } } const startOAuth = useCallback(async () => { try { - logEvent('tengu_oauth_flow_start', { loginWithClaudeAi }) + logEvent('tengu_oauth_flow_start', { loginWithClaudeAi }); const result = await oauthService .startOAuthFlow( async url => { - setOAuthStatus({ state: 'waiting_for_login', url }) - setTimeout(setShowPastePrompt, 3000, true) + setOAuthStatus({ state: 'waiting_for_login', url }); + setTimeout(setShowPastePrompt, 3000, true); }, { loginWithClaudeAi, @@ -228,13 +223,11 @@ export function ConsoleOAuthFlow({ }, ) .catch(err => { - const isTokenExchangeError = err.message.includes( - 'Token exchange failed', - ) + const isTokenExchangeError = err.message.includes('Token exchange failed'); // Enterprise TLS proxies (Zscaler et al.) intercept the token // exchange POST and cause cryptic SSL errors. Surface an // actionable hint so the user isn't stuck in a login loop. - const sslHint = getSSLErrorHint(err) + const sslHint = getSSLErrorHint(err); setOAuthStatus({ state: 'error', message: @@ -242,80 +235,70 @@ export function ConsoleOAuthFlow({ (isTokenExchangeError ? 'Failed to exchange authorization code for access token. Please try again.' : err.message), - toRetry: - mode === 'setup-token' - ? { state: 'ready_to_start' } - : { state: 'idle' }, - }) + toRetry: mode === 'setup-token' ? { state: 'ready_to_start' } : { state: 'idle' }, + }); logEvent('tengu_oauth_token_exchange_error', { error: err.message, ssl_error: sslHint !== null, - }) - throw err - }) + }); + throw err; + }); if (mode === 'setup-token') { // For setup-token mode, return the OAuth access token directly (it can be used as an API key) // Don't save to keychain - the token is displayed for manual use with CLAUDE_CODE_OAUTH_TOKEN - setOAuthStatus({ state: 'success', token: result.accessToken }) + setOAuthStatus({ state: 'success', token: result.accessToken }); } else { - await installOAuthTokens(result) + await installOAuthTokens(result); - const orgResult = await validateForceLoginOrg() + const orgResult = await validateForceLoginOrg(); if (!orgResult.valid) { - throw new Error(orgResult.message) + throw new Error(orgResult.message); } // Reset modelType to anthropic when using OAuth login - updateSettingsForSource('userSettings', { modelType: 'anthropic' } as any) + updateSettingsForSource('userSettings', { modelType: 'anthropic' } as any); - setOAuthStatus({ state: 'success' }) + setOAuthStatus({ state: 'success' }); void sendNotification( { message: 'Claude Code login successful', notificationType: 'auth_success', }, terminal, - ) + ); } } catch (err) { - const errorMessage = (err as Error).message - const sslHint = getSSLErrorHint(err) + const errorMessage = (err as Error).message; + const sslHint = getSSLErrorHint(err); setOAuthStatus({ state: 'error', message: sslHint ?? errorMessage, toRetry: { state: mode === 'setup-token' ? 'ready_to_start' : 'idle', }, - }) + }); logEvent('tengu_oauth_error', { - error: - errorMessage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + error: errorMessage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ssl_error: sslHint !== null, - }) + }); } - }, [oauthService, setShowPastePrompt, loginWithClaudeAi, mode, orgUUID]) + }, [oauthService, setShowPastePrompt, loginWithClaudeAi, mode, orgUUID]); - const pendingOAuthStartRef = useRef(false) + const pendingOAuthStartRef = useRef(false); useEffect(() => { - if ( - oauthStatus.state === 'ready_to_start' && - !pendingOAuthStartRef.current - ) { - pendingOAuthStartRef.current = true + if (oauthStatus.state === 'ready_to_start' && !pendingOAuthStartRef.current) { + pendingOAuthStartRef.current = true; process.nextTick( - ( - startOAuth: () => Promise, - pendingOAuthStartRef: React.MutableRefObject, - ) => { - void startOAuth() - pendingOAuthStartRef.current = false + (startOAuth: () => Promise, pendingOAuthStartRef: React.MutableRefObject) => { + void startOAuth(); + pendingOAuthStartRef.current = false; }, startOAuth, pendingOAuthStartRef, - ) + ); } - }, [oauthStatus.state, startOAuth]) + }, [oauthStatus.state, startOAuth]); // Auto-exit for setup-token mode useEffect(() => { @@ -323,33 +306,31 @@ export function ConsoleOAuthFlow({ // Delay to ensure static content is fully rendered before exiting const timer = setTimeout( (loginWithClaudeAi, onDone) => { - logEvent('tengu_oauth_success', { loginWithClaudeAi }) + logEvent('tengu_oauth_success', { loginWithClaudeAi }); // Don't clear terminal so the token remains visible - onDone() + onDone(); }, 500, loginWithClaudeAi, onDone, - ) - return () => clearTimeout(timer) + ); + return () => clearTimeout(timer); } - }, [mode, oauthStatus, loginWithClaudeAi, onDone]) + }, [mode, oauthStatus, loginWithClaudeAi, onDone]); // Cleanup OAuth service when component unmounts useEffect(() => { return () => { - oauthService.cleanup() - } - }, [oauthService]) + oauthService.cleanup(); + }; + }, [oauthService]); return ( {oauthStatus.state === 'waiting_for_login' && showPastePrompt && ( - - Browser didn't open? Use the url below to sign in{' '} - + Browser didn't open? Use the url below to sign in {urlCopied ? ( (Copied!) ) : ( @@ -363,27 +344,17 @@ export function ConsoleOAuthFlow({ )} - {mode === 'setup-token' && - oauthStatus.state === 'success' && - oauthStatus.token && ( - - - ✓ Long-lived authentication token created successfully! - - - Your OAuth token (valid for 1 year): - {oauthStatus.token} - - Store this token securely. You won't be able to see it - again. - - - Use this token by setting: export - CLAUDE_CODE_OAUTH_TOKEN=<token> - - + {mode === 'setup-token' && oauthStatus.state === 'success' && oauthStatus.token && ( + + ✓ Long-lived authentication token created successfully! + + Your OAuth token (valid for 1 year): + {oauthStatus.token} + Store this token securely. You won't be able to see it again. + Use this token by setting: export CLAUDE_CODE_OAUTH_TOKEN=<token> - )} + + )} - ) + ); } type OAuthStatusMessageProps = { - oauthStatus: OAuthStatus - mode: 'login' | 'setup-token' - startingMessage: string | undefined - forcedMethodMessage: string | null - showPastePrompt: boolean - pastedCode: string - setPastedCode: (value: string) => void - cursorOffset: number - onDone: () => void - setCursorOffset: (offset: number) => void - textInputColumns: number - handleSubmitCode: (value: string, url: string) => void - setOAuthStatus: (status: OAuthStatus) => void - setLoginWithClaudeAi: (value: boolean) => void -} + oauthStatus: OAuthStatus; + mode: 'login' | 'setup-token'; + startingMessage: string | undefined; + forcedMethodMessage: string | null; + showPastePrompt: boolean; + pastedCode: string; + setPastedCode: (value: string) => void; + cursorOffset: number; + onDone: () => void; + setCursorOffset: (offset: number) => void; + textInputColumns: number; + handleSubmitCode: (value: string, url: string) => void; + setOAuthStatus: (status: OAuthStatus) => void; + setLoginWithClaudeAi: (value: boolean) => void; +}; function OAuthStatusMessage({ oauthStatus, @@ -457,8 +428,7 @@ function OAuthStatusMessage({ { label: ( - Anthropic Compatible ·{' '} - Configure your own API endpoint + Anthropic Compatible · Configure your own API endpoint {'\n'} ), @@ -467,10 +437,7 @@ function OAuthStatusMessage({ { label: ( - OpenAI Compatible ·{' '} - - Ollama, DeepSeek, vLLM, One API, etc. - + OpenAI Compatible · Ollama, DeepSeek, vLLM, One API, etc. {'\n'} ), @@ -479,16 +446,14 @@ function OAuthStatusMessage({ { label: ( - Claude account with subscription ·{' '} - Pro, Max, Team, or Enterprise + Claude account with subscription · Pro, Max, Team, or Enterprise {process.env.USER_TYPE === 'ant' && ( {'\n'} [ANT-ONLY]{' '} - Please use this option unless you need to login to a - special org for accessing sensitive data (e.g. - customer data, HIPI data) with the Console option + Please use this option unless you need to login to a special org for accessing sensitive + data (e.g. customer data, HIPI data) with the Console option )} @@ -500,8 +465,7 @@ function OAuthStatusMessage({ { label: ( - Anthropic Console account ·{' '} - API usage billing + Anthropic Console account · API usage billing {'\n'} ), @@ -510,10 +474,7 @@ function OAuthStatusMessage({ { label: ( - 3rd-party platform ·{' '} - - Amazon Bedrock, Microsoft Foundry, or Vertex AI - + 3rd-party platform · Amazon Bedrock, Microsoft Foundry, or Vertex AI {'\n'} ), @@ -522,7 +483,7 @@ function OAuthStatusMessage({ ]} onChange={value => { if (value === 'custom_platform') { - logEvent('tengu_custom_platform_selected', {}) + logEvent('tengu_custom_platform_selected', {}); setOAuthStatus({ state: 'custom_platform', baseUrl: process.env.ANTHROPIC_BASE_URL ?? '', @@ -531,448 +492,396 @@ function OAuthStatusMessage({ sonnetModel: process.env.ANTHROPIC_DEFAULT_SONNET_MODEL ?? '', opusModel: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL ?? '', activeField: 'base_url', - }) + }); } else if (value === 'openai_chat_api') { - logEvent('tengu_openai_chat_api_selected', {}) + logEvent('tengu_openai_chat_api_selected', {}); setOAuthStatus({ state: 'openai_chat_api', baseUrl: process.env.OPENAI_BASE_URL ?? '', apiKey: process.env.OPENAI_API_KEY ?? '', - haikuModel: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL ?? '', - sonnetModel: process.env.ANTHROPIC_DEFAULT_SONNET_MODEL ?? '', - opusModel: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL ?? '', + haikuModel: process.env.OPENAI_DEFAULT_HAIKU_MODEL ?? '', + sonnetModel: process.env.OPENAI_DEFAULT_SONNET_MODEL ?? '', + opusModel: process.env.OPENAI_DEFAULT_OPUS_MODEL ?? '', activeField: 'base_url', - }) + }); } else if (value === 'platform') { - logEvent('tengu_oauth_platform_selected', {}) - setOAuthStatus({ state: 'platform_setup' }) + logEvent('tengu_oauth_platform_selected', {}); + setOAuthStatus({ state: 'platform_setup' }); } else { - setOAuthStatus({ state: 'ready_to_start' }) + setOAuthStatus({ state: 'ready_to_start' }); if (value === 'claudeai') { - logEvent('tengu_oauth_claudeai_selected', {}) - setLoginWithClaudeAi(true) + logEvent('tengu_oauth_claudeai_selected', {}); + setLoginWithClaudeAi(true); } else { - logEvent('tengu_oauth_console_selected', {}) - setLoginWithClaudeAi(false) + logEvent('tengu_oauth_console_selected', {}); + setLoginWithClaudeAi(false); } } }} /> - ) + ); - case 'custom_platform': - { - type Field = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model' - const FIELDS: Field[] = ['base_url', 'api_key', 'haiku_model', 'sonnet_model', 'opus_model'] - const cp = oauthStatus as { - state: 'custom_platform' - activeField: Field - baseUrl: string - apiKey: string - haikuModel: string - sonnetModel: string - opusModel: string - } - const { activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel } = cp - const displayValues: Record = { - base_url: baseUrl, - api_key: apiKey, - haiku_model: haikuModel, - sonnet_model: sonnetModel, - opus_model: opusModel, - } + case 'custom_platform': { + type Field = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; + const FIELDS: Field[] = ['base_url', 'api_key', 'haiku_model', 'sonnet_model', 'opus_model']; + const cp = oauthStatus as { + state: 'custom_platform'; + activeField: Field; + baseUrl: string; + apiKey: string; + haikuModel: string; + sonnetModel: string; + opusModel: string; + }; + const { activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel } = cp; + const displayValues: Record = { + base_url: baseUrl, + api_key: apiKey, + haiku_model: haikuModel, + sonnet_model: sonnetModel, + opus_model: opusModel, + }; - const [inputValue, setInputValue] = useState(() => displayValues[activeField]) - const [inputCursorOffset, setInputCursorOffset] = useState( - () => displayValues[activeField].length, - ) + const [inputValue, setInputValue] = useState(() => displayValues[activeField]); + const [inputCursorOffset, setInputCursorOffset] = useState(() => displayValues[activeField].length); - const buildState = useCallback( - (field: Field, value: string, newActive?: Field) => { - const s = { - state: 'custom_platform' as const, - activeField: newActive ?? activeField, - baseUrl, - apiKey, - haikuModel, - sonnetModel, - opusModel, - } - switch (field) { - case 'base_url': - return { ...s, baseUrl: value } - case 'api_key': - return { ...s, apiKey: value } - case 'haiku_model': - return { ...s, haikuModel: value } - case 'sonnet_model': - return { ...s, sonnetModel: value } - case 'opus_model': - return { ...s, opusModel: value } - } - }, - [activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel], - ) - - const switchTo = useCallback( - (target: Field) => { - setOAuthStatus(buildState(activeField, inputValue, target)) - setInputValue(displayValues[target] ?? '') - setInputCursorOffset((displayValues[target] ?? '').length) - }, - [activeField, inputValue, displayValues, buildState, setOAuthStatus], - ) - - const doSave = useCallback(() => { - const finalVals = { ...displayValues, [activeField]: inputValue } - const env: Record = {} - if (finalVals.base_url) env.ANTHROPIC_BASE_URL = finalVals.base_url - if (finalVals.api_key) env.ANTHROPIC_AUTH_TOKEN = finalVals.api_key - if (finalVals.haiku_model) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = finalVals.haiku_model - if (finalVals.sonnet_model) env.ANTHROPIC_DEFAULT_SONNET_MODEL = finalVals.sonnet_model - if (finalVals.opus_model) env.ANTHROPIC_DEFAULT_OPUS_MODEL = finalVals.opus_model - const { error } = updateSettingsForSource('userSettings', { - modelType: 'anthropic' as any, - env, - } as any) - if (error) { - setOAuthStatus({ - state: 'error', - message: `Failed to save: ${error.message}`, - toRetry: { - state: 'custom_platform', - baseUrl: '', - apiKey: '', - haikuModel: '', - sonnetModel: '', - opusModel: '', - activeField: 'base_url', - }, - }) - } else { - for (const [k, v] of Object.entries(env)) process.env[k] = v - setOAuthStatus({ state: 'success' }) - void onDone() + const buildState = useCallback( + (field: Field, value: string, newActive?: Field) => { + const s = { + state: 'custom_platform' as const, + activeField: newActive ?? activeField, + baseUrl, + apiKey, + haikuModel, + sonnetModel, + opusModel, + }; + switch (field) { + case 'base_url': + return { ...s, baseUrl: value }; + case 'api_key': + return { ...s, apiKey: value }; + case 'haiku_model': + return { ...s, haikuModel: value }; + case 'sonnet_model': + return { ...s, sonnetModel: value }; + case 'opus_model': + return { ...s, opusModel: value }; } - }, [activeField, inputValue, displayValues, setOAuthStatus, onDone]) + }, + [activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel], + ); - const handleEnter = useCallback(() => { - const idx = FIELDS.indexOf(activeField) - setOAuthStatus(buildState(activeField, inputValue)) - if (idx === FIELDS.length - 1) { - doSave() - } else { - const next = FIELDS[idx + 1]! - setInputValue(displayValues[next] ?? '') - setInputCursorOffset((displayValues[next] ?? '').length) - } - }, [activeField, inputValue, buildState, doSave, displayValues, setOAuthStatus]) + const switchTo = useCallback( + (target: Field) => { + setOAuthStatus(buildState(activeField, inputValue, target)); + setInputValue(displayValues[target] ?? ''); + setInputCursorOffset((displayValues[target] ?? '').length); + }, + [activeField, inputValue, displayValues, buildState, setOAuthStatus], + ); - useKeybinding( - 'tabs:next', - () => { - const idx = FIELDS.indexOf(activeField) - if (idx < FIELDS.length - 1) { - setOAuthStatus(buildState(activeField, inputValue, FIELDS[idx + 1])) - setInputValue(displayValues[FIELDS[idx + 1]!] ?? '') - setInputCursorOffset((displayValues[FIELDS[idx + 1]!] ?? '').length) - } - }, - { context: 'Tabs' }, - ) - useKeybinding( - 'tabs:previous', - () => { - const idx = FIELDS.indexOf(activeField) - if (idx > 0) { - setOAuthStatus(buildState(activeField, inputValue, FIELDS[idx - 1])) - setInputValue(displayValues[FIELDS[idx - 1]!] ?? '') - setInputCursorOffset((displayValues[FIELDS[idx - 1]!] ?? '').length) - } - }, - { context: 'Tabs' }, - ) - useKeybinding( - 'confirm:no', - () => { - setOAuthStatus({ state: 'idle' }) - }, - { context: 'Confirmation' }, - ) - - const columns = useTerminalSize().columns - 20 - - const renderRow = ( - field: Field, - label: string, - opts?: { mask?: boolean; placeholder?: string }, - ) => { - const active = activeField === field - const val = displayValues[field] - return ( - - - {` ${label} `} - - - {active ? ( - - ) : val ? ( - - {opts?.mask - ? val.slice(0, 8) + '\u00b7'.repeat(Math.max(0, val.length - 8)) - : val} - - ) : null} - - ) + const doSave = useCallback(() => { + const finalVals = { ...displayValues, [activeField]: inputValue }; + const env: Record = {}; + if (finalVals.base_url) env.ANTHROPIC_BASE_URL = finalVals.base_url; + if (finalVals.api_key) env.ANTHROPIC_AUTH_TOKEN = finalVals.api_key; + if (finalVals.haiku_model) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = finalVals.haiku_model; + if (finalVals.sonnet_model) env.ANTHROPIC_DEFAULT_SONNET_MODEL = finalVals.sonnet_model; + if (finalVals.opus_model) env.ANTHROPIC_DEFAULT_OPUS_MODEL = finalVals.opus_model; + const { error } = updateSettingsForSource('userSettings', { + modelType: 'anthropic' as any, + env, + } as any); + if (error) { + setOAuthStatus({ + state: 'error', + message: `Failed to save: ${error.message}`, + toRetry: { + state: 'custom_platform', + baseUrl: '', + apiKey: '', + haikuModel: '', + sonnetModel: '', + opusModel: '', + activeField: 'base_url', + }, + }); + } else { + for (const [k, v] of Object.entries(env)) process.env[k] = v; + setOAuthStatus({ state: 'success' }); + void onDone(); } + }, [activeField, inputValue, displayValues, setOAuthStatus, onDone]); + const handleEnter = useCallback(() => { + const idx = FIELDS.indexOf(activeField); + setOAuthStatus(buildState(activeField, inputValue)); + if (idx === FIELDS.length - 1) { + doSave(); + } else { + const next = FIELDS[idx + 1]!; + setInputValue(displayValues[next] ?? ''); + setInputCursorOffset((displayValues[next] ?? '').length); + } + }, [activeField, inputValue, buildState, doSave, displayValues, setOAuthStatus]); + + useKeybinding( + 'tabs:next', + () => { + const idx = FIELDS.indexOf(activeField); + if (idx < FIELDS.length - 1) { + setOAuthStatus(buildState(activeField, inputValue, FIELDS[idx + 1])); + setInputValue(displayValues[FIELDS[idx + 1]!] ?? ''); + setInputCursorOffset((displayValues[FIELDS[idx + 1]!] ?? '').length); + } + }, + { context: 'Tabs' }, + ); + useKeybinding( + 'tabs:previous', + () => { + const idx = FIELDS.indexOf(activeField); + if (idx > 0) { + setOAuthStatus(buildState(activeField, inputValue, FIELDS[idx - 1])); + setInputValue(displayValues[FIELDS[idx - 1]!] ?? ''); + setInputCursorOffset((displayValues[FIELDS[idx - 1]!] ?? '').length); + } + }, + { context: 'Tabs' }, + ); + useKeybinding( + 'confirm:no', + () => { + setOAuthStatus({ state: 'idle' }); + }, + { context: 'Confirmation' }, + ); + + const columns = useTerminalSize().columns - 20; + + const renderRow = (field: Field, label: string, opts?: { mask?: boolean; placeholder?: string }) => { + const active = activeField === field; + const val = displayValues[field]; return ( - - Anthropic Compatible Setup - - {renderRow('base_url', 'Base URL ')} - {renderRow('api_key', 'API Key ', { mask: true })} - {renderRow('haiku_model', 'Haiku ')} - {renderRow('sonnet_model', 'Sonnet ')} - {renderRow('opus_model', 'Opus ')} - - - Tab to switch · Enter on last field to save · Esc to go back + + + {` ${label} `} - - ) - } - - case 'openai_chat_api': - { - type OpenAIField = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model' - const OPENAI_FIELDS: OpenAIField[] = [ - 'base_url', - 'api_key', - 'haiku_model', - 'sonnet_model', - 'opus_model', - ] - const op = oauthStatus as { - state: 'openai_chat_api' - activeField: OpenAIField - baseUrl: string - apiKey: string - haikuModel: string - sonnetModel: string - opusModel: string - } - const { activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel } = op - const openaiDisplayValues: Record = { - base_url: baseUrl, - api_key: apiKey, - haiku_model: haikuModel, - sonnet_model: sonnetModel, - opus_model: opusModel, - } - - const [openaiInputValue, setOpenaiInputValue] = useState( - () => openaiDisplayValues[activeField], - ) - const [openaiInputCursorOffset, setOpenaiInputCursorOffset] = useState( - () => openaiDisplayValues[activeField].length, - ) - - const buildOpenAIState = useCallback( - (field: OpenAIField, value: string, newActive?: OpenAIField) => { - const s = { - state: 'openai_chat_api' as const, - activeField: newActive ?? activeField, - baseUrl, - apiKey, - haikuModel, - sonnetModel, - opusModel, - } - switch (field) { - case 'base_url': - return { ...s, baseUrl: value } - case 'api_key': - return { ...s, apiKey: value } - case 'haiku_model': - return { ...s, haikuModel: value } - case 'sonnet_model': - return { ...s, sonnetModel: value } - case 'opus_model': - return { ...s, opusModel: value } - } - }, - [activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel], - ) - - const doOpenAISave = useCallback(() => { - const finalVals = { ...openaiDisplayValues, [activeField]: openaiInputValue } - const env: Record = {} - if (finalVals.base_url) env.OPENAI_BASE_URL = finalVals.base_url - if (finalVals.api_key) env.OPENAI_API_KEY = finalVals.api_key - if (finalVals.haiku_model) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = finalVals.haiku_model - if (finalVals.sonnet_model) env.ANTHROPIC_DEFAULT_SONNET_MODEL = finalVals.sonnet_model - if (finalVals.opus_model) env.ANTHROPIC_DEFAULT_OPUS_MODEL = finalVals.opus_model - const { error } = updateSettingsForSource('userSettings', { - modelType: 'openai' as any, - env, - } as any) - if (error) { - setOAuthStatus({ - state: 'error', - message: `Failed to save: ${error.message}`, - toRetry: { - state: 'openai_chat_api', - baseUrl: '', - apiKey: '', - haikuModel: '', - sonnetModel: '', - opusModel: '', - activeField: 'base_url', - }, - }) - } else { - for (const [k, v] of Object.entries(env)) process.env[k] = v - setOAuthStatus({ state: 'success' }) - void onDone() - } - }, [activeField, openaiInputValue, openaiDisplayValues, setOAuthStatus, onDone]) - - const handleOpenAIEnter = useCallback(() => { - const idx = OPENAI_FIELDS.indexOf(activeField) - setOAuthStatus(buildOpenAIState(activeField, openaiInputValue)) - if (idx === OPENAI_FIELDS.length - 1) { - doOpenAISave() - } else { - const next = OPENAI_FIELDS[idx + 1]! - setOpenaiInputValue(openaiDisplayValues[next] ?? '') - setOpenaiInputCursorOffset((openaiDisplayValues[next] ?? '').length) - } - }, [ - activeField, - openaiInputValue, - buildOpenAIState, - doOpenAISave, - openaiDisplayValues, - setOAuthStatus, - ]) - - useKeybinding( - 'tabs:next', - () => { - const idx = OPENAI_FIELDS.indexOf(activeField) - if (idx < OPENAI_FIELDS.length - 1) { - setOAuthStatus( - buildOpenAIState(activeField, openaiInputValue, OPENAI_FIELDS[idx + 1]), - ) - setOpenaiInputValue(openaiDisplayValues[OPENAI_FIELDS[idx + 1]!] ?? '') - setOpenaiInputCursorOffset( - (openaiDisplayValues[OPENAI_FIELDS[idx + 1]!] ?? '').length, - ) - } - }, - { context: 'Tabs' }, - ) - useKeybinding( - 'tabs:previous', - () => { - const idx = OPENAI_FIELDS.indexOf(activeField) - if (idx > 0) { - setOAuthStatus( - buildOpenAIState(activeField, openaiInputValue, OPENAI_FIELDS[idx - 1]), - ) - setOpenaiInputValue(openaiDisplayValues[OPENAI_FIELDS[idx - 1]!] ?? '') - setOpenaiInputCursorOffset( - (openaiDisplayValues[OPENAI_FIELDS[idx - 1]!] ?? '').length, - ) - } - }, - { context: 'Tabs' }, - ) - useKeybinding( - 'confirm:no', - () => { - setOAuthStatus({ state: 'idle' }) - }, - { context: 'Confirmation' }, - ) - - const openaiColumns = useTerminalSize().columns - 20 - - const renderOpenAIRow = ( - field: OpenAIField, - label: string, - opts?: { mask?: boolean }, - ) => { - const active = activeField === field - const val = openaiDisplayValues[field] - return ( - - - {` ${label} `} + + {active ? ( + + ) : val ? ( + + {opts?.mask ? val.slice(0, 8) + '\u00b7'.repeat(Math.max(0, val.length - 8)) : val} - - {active ? ( - - ) : val ? ( - - {opts?.mask - ? val.slice(0, 8) + '\u00b7'.repeat(Math.max(0, val.length - 8)) - : val} - - ) : null} - - ) - } - - return ( - - OpenAI Compatible API Setup - - Configure an OpenAI Chat Completions compatible endpoint (e.g. - Ollama, DeepSeek, vLLM). - - - {renderOpenAIRow('base_url', 'Base URL ')} - {renderOpenAIRow('api_key', 'API Key ', { mask: true })} - {renderOpenAIRow('haiku_model', 'Haiku ')} - {renderOpenAIRow('sonnet_model', 'Sonnet ')} - {renderOpenAIRow('opus_model', 'Opus ')} - - - Tab to switch · Enter on last field to save · Esc to go back - + ) : null} - ) - } + ); + }; + + return ( + + Anthropic Compatible Setup + + {renderRow('base_url', 'Base URL ')} + {renderRow('api_key', 'API Key ', { mask: true })} + {renderRow('haiku_model', 'Haiku ')} + {renderRow('sonnet_model', 'Sonnet ')} + {renderRow('opus_model', 'Opus ')} + + Tab to switch · Enter on last field to save · Esc to go back + + ); + } + + case 'openai_chat_api': { + type OpenAIField = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; + const OPENAI_FIELDS: OpenAIField[] = ['base_url', 'api_key', 'haiku_model', 'sonnet_model', 'opus_model']; + const op = oauthStatus as { + state: 'openai_chat_api'; + activeField: OpenAIField; + baseUrl: string; + apiKey: string; + haikuModel: string; + sonnetModel: string; + opusModel: string; + }; + const { activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel } = op; + const openaiDisplayValues: Record = { + base_url: baseUrl, + api_key: apiKey, + haiku_model: haikuModel, + sonnet_model: sonnetModel, + opus_model: opusModel, + }; + + const [openaiInputValue, setOpenaiInputValue] = useState(() => openaiDisplayValues[activeField]); + const [openaiInputCursorOffset, setOpenaiInputCursorOffset] = useState( + () => openaiDisplayValues[activeField].length, + ); + + const buildOpenAIState = useCallback( + (field: OpenAIField, value: string, newActive?: OpenAIField) => { + const s = { + state: 'openai_chat_api' as const, + activeField: newActive ?? activeField, + baseUrl, + apiKey, + haikuModel, + sonnetModel, + opusModel, + }; + switch (field) { + case 'base_url': + return { ...s, baseUrl: value }; + case 'api_key': + return { ...s, apiKey: value }; + case 'haiku_model': + return { ...s, haikuModel: value }; + case 'sonnet_model': + return { ...s, sonnetModel: value }; + case 'opus_model': + return { ...s, opusModel: value }; + } + }, + [activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel], + ); + + const doOpenAISave = useCallback(() => { + const finalVals = { ...openaiDisplayValues, [activeField]: openaiInputValue }; + const env: Record = {}; + if (finalVals.base_url) env.OPENAI_BASE_URL = finalVals.base_url; + if (finalVals.api_key) env.OPENAI_API_KEY = finalVals.api_key; + if (finalVals.haiku_model) env.OPENAI_DEFAULT_HAIKU_MODEL = finalVals.haiku_model; + if (finalVals.sonnet_model) env.OPENAI_DEFAULT_SONNET_MODEL = finalVals.sonnet_model; + if (finalVals.opus_model) env.OPENAI_DEFAULT_OPUS_MODEL = finalVals.opus_model; + const { error } = updateSettingsForSource('userSettings', { + modelType: 'openai' as any, + env, + } as any); + if (error) { + setOAuthStatus({ + state: 'error', + message: `Failed to save: ${error.message}`, + toRetry: { + state: 'openai_chat_api', + baseUrl: '', + apiKey: '', + haikuModel: '', + sonnetModel: '', + opusModel: '', + activeField: 'base_url', + }, + }); + } else { + for (const [k, v] of Object.entries(env)) process.env[k] = v; + setOAuthStatus({ state: 'success' }); + void onDone(); + } + }, [activeField, openaiInputValue, openaiDisplayValues, setOAuthStatus, onDone]); + + const handleOpenAIEnter = useCallback(() => { + const idx = OPENAI_FIELDS.indexOf(activeField); + setOAuthStatus(buildOpenAIState(activeField, openaiInputValue)); + if (idx === OPENAI_FIELDS.length - 1) { + doOpenAISave(); + } else { + const next = OPENAI_FIELDS[idx + 1]!; + setOpenaiInputValue(openaiDisplayValues[next] ?? ''); + setOpenaiInputCursorOffset((openaiDisplayValues[next] ?? '').length); + } + }, [activeField, openaiInputValue, buildOpenAIState, doOpenAISave, openaiDisplayValues, setOAuthStatus]); + + useKeybinding( + 'tabs:next', + () => { + const idx = OPENAI_FIELDS.indexOf(activeField); + if (idx < OPENAI_FIELDS.length - 1) { + setOAuthStatus(buildOpenAIState(activeField, openaiInputValue, OPENAI_FIELDS[idx + 1])); + setOpenaiInputValue(openaiDisplayValues[OPENAI_FIELDS[idx + 1]!] ?? ''); + setOpenaiInputCursorOffset((openaiDisplayValues[OPENAI_FIELDS[idx + 1]!] ?? '').length); + } + }, + { context: 'Tabs' }, + ); + useKeybinding( + 'tabs:previous', + () => { + const idx = OPENAI_FIELDS.indexOf(activeField); + if (idx > 0) { + setOAuthStatus(buildOpenAIState(activeField, openaiInputValue, OPENAI_FIELDS[idx - 1])); + setOpenaiInputValue(openaiDisplayValues[OPENAI_FIELDS[idx - 1]!] ?? ''); + setOpenaiInputCursorOffset((openaiDisplayValues[OPENAI_FIELDS[idx - 1]!] ?? '').length); + } + }, + { context: 'Tabs' }, + ); + useKeybinding( + 'confirm:no', + () => { + setOAuthStatus({ state: 'idle' }); + }, + { context: 'Confirmation' }, + ); + + const openaiColumns = useTerminalSize().columns - 20; + + const renderOpenAIRow = (field: OpenAIField, label: string, opts?: { mask?: boolean }) => { + const active = activeField === field; + const val = openaiDisplayValues[field]; + return ( + + + {` ${label} `} + + + {active ? ( + + ) : val ? ( + + {opts?.mask ? val.slice(0, 8) + '\u00b7'.repeat(Math.max(0, val.length - 8)) : val} + + ) : null} + + ); + }; + + return ( + + OpenAI Compatible API Setup + Configure an OpenAI Chat Completions compatible endpoint (e.g. Ollama, DeepSeek, vLLM). + + {renderOpenAIRow('base_url', 'Base URL ')} + {renderOpenAIRow('api_key', 'API Key ', { mask: true })} + {renderOpenAIRow('haiku_model', 'Haiku ')} + {renderOpenAIRow('sonnet_model', 'Sonnet ')} + {renderOpenAIRow('opus_model', 'Opus ')} + + Tab to switch · Enter on last field to save · Esc to go back + + ); + } case 'platform_setup': return ( @@ -981,14 +890,12 @@ function OAuthStatusMessage({ - Claude Code supports Amazon Bedrock, Microsoft Foundry, and Vertex - AI. Set the required environment variables, then restart Claude - Code. + Claude Code supports Amazon Bedrock, Microsoft Foundry, and Vertex AI. Set the required environment + variables, then restart Claude Code. - If you are part of an enterprise organization, contact your - administrator for setup instructions. + If you are part of an enterprise organization, contact your administrator for setup instructions. @@ -1020,7 +927,7 @@ function OAuthStatusMessage({ - ) + ); case 'waiting_for_login': return ( @@ -1044,9 +951,7 @@ function OAuthStatusMessage({ - handleSubmitCode(value, oauthStatus.url) - } + onSubmit={(value: string) => handleSubmitCode(value, oauthStatus.url)} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} columns={textInputColumns} @@ -1055,7 +960,7 @@ function OAuthStatusMessage({ )} - ) + ); case 'creating_api_key': return ( @@ -1065,14 +970,14 @@ function OAuthStatusMessage({ Creating API key for Claude Code… - ) + ); case 'about_to_retry': return ( Retrying… - ) + ); case 'success': return ( @@ -1081,8 +986,7 @@ function OAuthStatusMessage({ <> {getOauthAccountInfo()?.emailAddress ? ( - Logged in as{' '} - {getOauthAccountInfo()?.emailAddress} + Logged in as {getOauthAccountInfo()?.emailAddress} ) : null} @@ -1091,7 +995,7 @@ function OAuthStatusMessage({ )} - ) + ); case 'error': return ( @@ -1106,9 +1010,9 @@ function OAuthStatusMessage({ )} - ) + ); default: - return null + return null; } } diff --git a/src/services/api/openai/__tests__/modelMapping.test.ts b/src/services/api/openai/__tests__/modelMapping.test.ts index 89bf976ac..983b46e33 100644 --- a/src/services/api/openai/__tests__/modelMapping.test.ts +++ b/src/services/api/openai/__tests__/modelMapping.test.ts @@ -4,6 +4,9 @@ import { resolveOpenAIModel } from '../modelMapping.js' describe('resolveOpenAIModel', () => { const originalEnv = { OPENAI_MODEL: process.env.OPENAI_MODEL, + OPENAI_DEFAULT_HAIKU_MODEL: process.env.OPENAI_DEFAULT_HAIKU_MODEL, + OPENAI_DEFAULT_SONNET_MODEL: process.env.OPENAI_DEFAULT_SONNET_MODEL, + OPENAI_DEFAULT_OPUS_MODEL: process.env.OPENAI_DEFAULT_OPUS_MODEL, ANTHROPIC_DEFAULT_HAIKU_MODEL: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL, ANTHROPIC_DEFAULT_SONNET_MODEL: process.env.ANTHROPIC_DEFAULT_SONNET_MODEL, ANTHROPIC_DEFAULT_OPUS_MODEL: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL, @@ -11,6 +14,9 @@ describe('resolveOpenAIModel', () => { beforeEach(() => { delete process.env.OPENAI_MODEL + delete process.env.OPENAI_DEFAULT_HAIKU_MODEL + delete process.env.OPENAI_DEFAULT_SONNET_MODEL + delete process.env.OPENAI_DEFAULT_OPUS_MODEL delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL diff --git a/src/services/api/openai/modelMapping.ts b/src/services/api/openai/modelMapping.ts index ba546fe48..7cb49c7f9 100644 --- a/src/services/api/openai/modelMapping.ts +++ b/src/services/api/openai/modelMapping.ts @@ -31,9 +31,10 @@ function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null { * * Priority: * 1. OPENAI_MODEL env var (override all) - * 2. ANTHROPIC_DEFAULT_{FAMILY}_MODEL env var (e.g. ANTHROPIC_DEFAULT_SONNET_MODEL) - * 3. DEFAULT_MODEL_MAP lookup - * 4. Pass through original model name + * 2. OPENAI_DEFAULT_{FAMILY}_MODEL env var (e.g. OPENAI_DEFAULT_SONNET_MODEL) + * 3. ANTHROPIC_DEFAULT_{FAMILY}_MODEL env var (backward compatibility) + * 4. DEFAULT_MODEL_MAP lookup + * 5. Pass through original model name */ export function resolveOpenAIModel(anthropicModel: string): string { // Highest priority: explicit override @@ -44,12 +45,18 @@ export function resolveOpenAIModel(anthropicModel: string): string { // Strip [1m] suffix if present (Claude-specific modifier) const cleanModel = anthropicModel.replace(/\[1m\]$/, '') - // Check ANTHROPIC_DEFAULT_*_MODEL env vars based on model family + // Check family-specific overrides const family = getModelFamily(cleanModel) if (family) { - const envVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL` - const override = process.env[envVar] - if (override) return override + // OpenAI-specific family override (preferred for openai provider) + const openaiEnvVar = `OPENAI_DEFAULT_${family.toUpperCase()}_MODEL` + const openaiOverride = process.env[openaiEnvVar] + if (openaiOverride) return openaiOverride + + // Anthropic env var (backward compatibility) + const anthropicEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL` + const anthropicOverride = process.env[anthropicEnvVar] + if (anthropicOverride) return anthropicOverride } return DEFAULT_MODEL_MAP[cleanModel] ?? cleanModel diff --git a/src/utils/managedEnvConstants.ts b/src/utils/managedEnvConstants.ts index 12c565658..8e92f5c08 100644 --- a/src/utils/managedEnvConstants.ts +++ b/src/utils/managedEnvConstants.ts @@ -10,6 +10,10 @@ * @[MODEL LAUNCH]: New models usually don't need changes here — * VERTEX_REGION_CLAUDE_* is prefix-matched. New providers or new routing * config vars (endpoint, project, region, auth) do. + * + * Note: OpenAI provider uses OPENAI_* env vars (OPENAI_API_KEY, OPENAI_BASE_URL, + * OPENAI_MODEL, OPENAI_DEFAULT_*_MODEL, OPENAI_SMALL_FAST_MODEL) which are all + * provider-managed to keep routing config isolated from Anthropic settings. */ const PROVIDER_MANAGED_ENV_VARS = new Set([ // The flag itself — settings can't unset it once the host set it @@ -50,6 +54,23 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([ 'ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION', 'ANTHROPIC_DEFAULT_SONNET_MODEL_NAME', 'ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES', + // OpenAI provider specific + 'OPENAI_API_KEY', + 'OPENAI_BASE_URL', + 'OPENAI_MODEL', + 'OPENAI_DEFAULT_HAIKU_MODEL', + 'OPENAI_DEFAULT_HAIKU_MODEL_DESCRIPTION', + 'OPENAI_DEFAULT_HAIKU_MODEL_NAME', + 'OPENAI_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES', + 'OPENAI_DEFAULT_OPUS_MODEL', + 'OPENAI_DEFAULT_OPUS_MODEL_DESCRIPTION', + 'OPENAI_DEFAULT_OPUS_MODEL_NAME', + 'OPENAI_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES', + 'OPENAI_DEFAULT_SONNET_MODEL', + 'OPENAI_DEFAULT_SONNET_MODEL_DESCRIPTION', + 'OPENAI_DEFAULT_SONNET_MODEL_NAME', + 'OPENAI_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES', + 'OPENAI_SMALL_FAST_MODEL', 'ANTHROPIC_SMALL_FAST_MODEL', 'ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION', 'CLAUDE_CODE_SUBAGENT_MODEL', @@ -122,6 +143,19 @@ export const SAFE_ENV_VARS = new Set([ 'ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION', 'ANTHROPIC_DEFAULT_SONNET_MODEL_NAME', 'ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES', + // OpenAI provider specific + 'OPENAI_DEFAULT_HAIKU_MODEL', + 'OPENAI_DEFAULT_HAIKU_MODEL_DESCRIPTION', + 'OPENAI_DEFAULT_HAIKU_MODEL_NAME', + 'OPENAI_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES', + 'OPENAI_DEFAULT_OPUS_MODEL', + 'OPENAI_DEFAULT_OPUS_MODEL_DESCRIPTION', + 'OPENAI_DEFAULT_OPUS_MODEL_NAME', + 'OPENAI_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES', + 'OPENAI_DEFAULT_SONNET_MODEL', + 'OPENAI_DEFAULT_SONNET_MODEL_DESCRIPTION', + 'OPENAI_DEFAULT_SONNET_MODEL_NAME', + 'OPENAI_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES', 'ANTHROPIC_FOUNDRY_API_KEY', 'ANTHROPIC_MODEL', 'ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION', diff --git a/src/utils/model/model.ts b/src/utils/model/model.ts index 695076c86..c21b0ba73 100644 --- a/src/utils/model/model.ts +++ b/src/utils/model/model.ts @@ -104,6 +104,11 @@ export function getBestModel(): ModelName { // @[MODEL LAUNCH]: Update the default Opus model (3P providers may lag so keep defaults unchanged). export function getDefaultOpusModel(): ModelName { + // For OpenAI provider, check OPENAI_DEFAULT_OPUS_MODEL first + if (getAPIProvider() === 'openai' && process.env.OPENAI_DEFAULT_OPUS_MODEL) { + return process.env.OPENAI_DEFAULT_OPUS_MODEL + } + // Anthropic-specific override (for first-party and other 3P providers) if (process.env.ANTHROPIC_DEFAULT_OPUS_MODEL) { return process.env.ANTHROPIC_DEFAULT_OPUS_MODEL } @@ -118,6 +123,14 @@ export function getDefaultOpusModel(): ModelName { // @[MODEL LAUNCH]: Update the default Sonnet model (3P providers may lag so keep defaults unchanged). export function getDefaultSonnetModel(): ModelName { + // For OpenAI provider, check OPENAI_DEFAULT_SONNET_MODEL first + if ( + getAPIProvider() === 'openai' && + process.env.OPENAI_DEFAULT_SONNET_MODEL + ) { + return process.env.OPENAI_DEFAULT_SONNET_MODEL + } + // Anthropic-specific override (for first-party and other 3P providers) if (process.env.ANTHROPIC_DEFAULT_SONNET_MODEL) { return process.env.ANTHROPIC_DEFAULT_SONNET_MODEL } @@ -130,6 +143,11 @@ export function getDefaultSonnetModel(): ModelName { // @[MODEL LAUNCH]: Update the default Haiku model (3P providers may lag so keep defaults unchanged). export function getDefaultHaikuModel(): ModelName { + // For OpenAI provider, check OPENAI_DEFAULT_HAIKU_MODEL first + if (getAPIProvider() === 'openai' && process.env.OPENAI_DEFAULT_HAIKU_MODEL) { + return process.env.OPENAI_DEFAULT_HAIKU_MODEL + } + // Anthropic-specific override (for first-party and other 3P providers) if (process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL) { return process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL } diff --git a/src/utils/model/modelOptions.ts b/src/utils/model/modelOptions.ts index 30d8f0f0e..33ed77735 100644 --- a/src/utils/model/modelOptions.ts +++ b/src/utils/model/modelOptions.ts @@ -76,18 +76,29 @@ export function getDefaultOptionForUser(fastMode = false): ModelOption { function getCustomSonnetOption(): ModelOption | undefined { const is3P = getAPIProvider() !== 'firstParty' - const customSonnetModel = process.env.ANTHROPIC_DEFAULT_SONNET_MODEL + // For OpenAI provider, use OPENAI_DEFAULT_SONNET_MODEL; for other 3P, use ANTHROPIC_DEFAULT_SONNET_MODEL + const customSonnetModel = + getAPIProvider() === 'openai' + ? process.env.OPENAI_DEFAULT_SONNET_MODEL + : process.env.ANTHROPIC_DEFAULT_SONNET_MODEL // When a 3P user has a custom sonnet model string, show it directly if (is3P && customSonnetModel) { const is1m = has1mContext(customSonnetModel) + // Use appropriate NAME/DESCRIPTION env vars based on provider + const nameEnv = + getAPIProvider() === 'openai' + ? process.env.OPENAI_DEFAULT_SONNET_MODEL_NAME + : process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_NAME + const descEnv = + getAPIProvider() === 'openai' + ? process.env.OPENAI_DEFAULT_SONNET_MODEL_DESCRIPTION + : process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION return { value: 'sonnet', - label: - process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_NAME ?? customSonnetModel, + label: nameEnv ?? customSonnetModel, description: - process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION ?? - `Custom Sonnet model${is1m ? ' (1M context)' : ''}`, - descriptionForModel: `${process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION ?? `Custom Sonnet model${is1m ? ' with 1M context' : ''}`} (${customSonnetModel})`, + descEnv ?? `Custom Sonnet model${is1m ? ' (1M context)' : ''}`, + descriptionForModel: `${descEnv ?? `Custom Sonnet model${is1m ? ' with 1M context' : ''}`} (${customSonnetModel})`, } } } @@ -107,17 +118,28 @@ function getSonnet46Option(): ModelOption { function getCustomOpusOption(): ModelOption | undefined { const is3P = getAPIProvider() !== 'firstParty' - const customOpusModel = process.env.ANTHROPIC_DEFAULT_OPUS_MODEL + // For OpenAI provider, use OPENAI_DEFAULT_OPUS_MODEL; for other 3P, use ANTHROPIC_DEFAULT_OPUS_MODEL + const customOpusModel = + getAPIProvider() === 'openai' + ? process.env.OPENAI_DEFAULT_OPUS_MODEL + : process.env.ANTHROPIC_DEFAULT_OPUS_MODEL // When a 3P user has a custom opus model string, show it directly if (is3P && customOpusModel) { const is1m = has1mContext(customOpusModel) + // Use appropriate NAME/DESCRIPTION env vars based on provider + const nameEnv = + getAPIProvider() === 'openai' + ? process.env.OPENAI_DEFAULT_OPUS_MODEL_NAME + : process.env.ANTHROPIC_DEFAULT_OPUS_MODEL_NAME + const descEnv = + getAPIProvider() === 'openai' + ? process.env.OPENAI_DEFAULT_OPUS_MODEL_DESCRIPTION + : process.env.ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION return { value: 'opus', - label: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL_NAME ?? customOpusModel, - description: - process.env.ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION ?? - `Custom Opus model${is1m ? ' (1M context)' : ''}`, - descriptionForModel: `${process.env.ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION ?? `Custom Opus model${is1m ? ' with 1M context' : ''}`} (${customOpusModel})`, + label: nameEnv ?? customOpusModel, + description: descEnv ?? `Custom Opus model${is1m ? ' (1M context)' : ''}`, + descriptionForModel: `${descEnv ?? `Custom Opus model${is1m ? ' with 1M context' : ''}`} (${customOpusModel})`, } } } @@ -165,16 +187,27 @@ export function getOpus46_1MOption(fastMode = false): ModelOption { function getCustomHaikuOption(): ModelOption | undefined { const is3P = getAPIProvider() !== 'firstParty' - const customHaikuModel = process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL + // For OpenAI provider, use OPENAI_DEFAULT_HAIKU_MODEL; for other 3P, use ANTHROPIC_DEFAULT_HAIKU_MODEL + const customHaikuModel = + getAPIProvider() === 'openai' + ? process.env.OPENAI_DEFAULT_HAIKU_MODEL + : process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL // When a 3P user has a custom haiku model string, show it directly if (is3P && customHaikuModel) { + // Use appropriate NAME/DESCRIPTION env vars based on provider + const nameEnv = + getAPIProvider() === 'openai' + ? process.env.OPENAI_DEFAULT_HAIKU_MODEL_NAME + : process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME + const descEnv = + getAPIProvider() === 'openai' + ? process.env.OPENAI_DEFAULT_HAIKU_MODEL_DESCRIPTION + : process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION return { value: 'haiku', - label: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME ?? customHaikuModel, - description: - process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION ?? - 'Custom Haiku model', - descriptionForModel: `${process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION ?? 'Custom Haiku model'} (${customHaikuModel})`, + label: nameEnv ?? customHaikuModel, + description: descEnv ?? 'Custom Haiku model', + descriptionForModel: `${descEnv ?? 'Custom Haiku model'} (${customHaikuModel})`, } } } diff --git a/src/utils/model/modelSupportOverrides.ts b/src/utils/model/modelSupportOverrides.ts index d3003272d..14ea0de0a 100644 --- a/src/utils/model/modelSupportOverrides.ts +++ b/src/utils/model/modelSupportOverrides.ts @@ -8,7 +8,7 @@ export type ModelCapabilityOverride = | 'adaptive_thinking' | 'interleaved_thinking' -const TIERS = [ +const ANTHROPIC_TIERS = [ { modelEnvVar: 'ANTHROPIC_DEFAULT_OPUS_MODEL', capabilitiesEnvVar: 'ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES', @@ -23,9 +23,24 @@ const TIERS = [ }, ] as const +const OPENAI_TIERS = [ + { + modelEnvVar: 'OPENAI_DEFAULT_OPUS_MODEL', + capabilitiesEnvVar: 'OPENAI_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES', + }, + { + modelEnvVar: 'OPENAI_DEFAULT_SONNET_MODEL', + capabilitiesEnvVar: 'OPENAI_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES', + }, + { + modelEnvVar: 'OPENAI_DEFAULT_HAIKU_MODEL', + capabilitiesEnvVar: 'OPENAI_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES', + }, +] as const + /** * Check whether a 3p model capability override is set for a model that matches one of - * the pinned ANTHROPIC_DEFAULT_*_MODEL env vars. + * the pinned ANTHROPIC_DEFAULT_*_MODEL or OPENAI_DEFAULT_*_MODEL env vars. */ export const get3PModelCapabilityOverride = memoize( (model: string, capability: ModelCapabilityOverride): boolean | undefined => { @@ -33,7 +48,9 @@ export const get3PModelCapabilityOverride = memoize( return undefined } const m = model.toLowerCase() - for (const tier of TIERS) { + // Choose the appropriate tier list based on provider + const tiers = getAPIProvider() === 'openai' ? OPENAI_TIERS : ANTHROPIC_TIERS + for (const tier of tiers) { const pinned = process.env[tier.modelEnvVar] const capabilities = process.env[tier.capabilitiesEnvVar] if (!pinned || capabilities === undefined) continue