diff --git a/.env.example b/.env.example index 99f367cc..389caeaa 100644 --- a/.env.example +++ b/.env.example @@ -47,4 +47,11 @@ # 通用设置(建议始终开启) # ============================================================ DISABLE_TELEMETRY=1 + +# Azure OpenAI (Codex) +CLAUDE_CODE_USE_AZURE_OPENAI=1 +AZURE_OPENAI_BASE_URL=https://your-resource.cognitiveservices.azure.com +AZURE_OPENAI_API_VERSION=2025-04-01-preview +AZURE_OPENAI_API_KEY=your_azure_openai_key +AZURE_OPENAI_CODEX_DEPLOYMENT=your_codex_deployment CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 diff --git a/.gitignore b/.gitignore index 2dc4818c..9007fabc 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .env.* !.env.example node_modules +openspec diff --git a/README.en.md b/README.en.md index afb19c87..c2101a88 100644 --- a/README.en.md +++ b/README.en.md @@ -1,6 +1,6 @@ -# Claude Code Haha +# Claude Code Haha -

中文 | English

+

涓枃 | English

A **locally runnable version** repaired from the leaked Claude Code source, with support for any Anthropic-compatible API endpoint such as MiniMax and OpenRouter. @@ -163,7 +163,7 @@ echo "explain this code" | ./bin/claude-haha -p The startup script `bin/claude-haha` is a bash script and cannot run directly in cmd or PowerShell. Use one of the following methods: -**Option 1: PowerShell / cmd — call Bun directly (recommended)** +**Option 1: PowerShell / cmd 鈥?call Bun directly (recommended)** ```powershell # Interactive TUI mode @@ -202,6 +202,56 @@ bun --env-file=.env ./src/localRecoveryCli.ts | `DISABLE_TELEMETRY` | No | Set to `1` to disable telemetry | | `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` | No | Set to `1` to disable non-essential network traffic | +### Azure OpenAI (Codex) + +```env +CLAUDE_CODE_USE_AZURE_OPENAI=1 +AZURE_OPENAI_BASE_URL=https://.cognitiveservices.azure.com +AZURE_OPENAI_API_VERSION=2025-04-01-preview +AZURE_OPENAI_API_KEY=... +AZURE_OPENAI_CODEX_DEPLOYMENT=... +``` + +You can either set a single default deployment via env: + +```env +AZURE_OPENAI_CODEX_DEPLOYMENT=... +``` + +Or map Codex models to deployments via settings (example `~/.claude/settings.json`): + +```json +{ + "modelOverrides": { + "gpt-5.2-codex": "codex-deployment-52", + "gpt-5.3-codex": "codex-deployment-53", + "gpt-5.4-codex": "codex-deployment-54" + } +} +``` + + +### Azure OpenAI (Codex) + +Set these env vars to route requests to Azure OpenAI: + +```env +CLAUDE_CODE_USE_AZURE_OPENAI=1 +AZURE_OPENAI_BASE_URL=https://.cognitiveservices.azure.com +AZURE_OPENAI_API_VERSION=2025-04-01-preview +AZURE_OPENAI_API_KEY=... +``` + +Map the Codex model to your deployment name via settings (example `~/.claude/settings.json`): + +```json +{ + "modelOverrides": { + "gpt-5.2-codex": "my-codex-deployment" + } +} +``` + --- ## Fallback Mode @@ -236,19 +286,19 @@ bin/claude-haha # Entry script preload.ts # Bun preload (sets MACRO globals) .env.example # Environment variable template src/ -├── entrypoints/cli.tsx # Main CLI entry -├── main.tsx # Main TUI logic (Commander.js + React/Ink) -├── localRecoveryCli.ts # Fallback Recovery CLI -├── setup.ts # Startup initialization -├── screens/REPL.tsx # Interactive REPL screen -├── ink/ # Ink terminal rendering engine -├── components/ # UI components -├── tools/ # Agent tools (Bash, Edit, Grep, etc.) -├── commands/ # Slash commands (/commit, /review, etc.) -├── skills/ # Skill system -├── services/ # Service layer (API, MCP, OAuth, etc.) -├── hooks/ # React hooks -└── utils/ # Utility functions +鈹溾攢鈹€ entrypoints/cli.tsx # Main CLI entry +鈹溾攢鈹€ main.tsx # Main TUI logic (Commander.js + React/Ink) +鈹溾攢鈹€ localRecoveryCli.ts # Fallback Recovery CLI +鈹溾攢鈹€ setup.ts # Startup initialization +鈹溾攢鈹€ screens/REPL.tsx # Interactive REPL screen +鈹溾攢鈹€ ink/ # Ink terminal rendering engine +鈹溾攢鈹€ components/ # UI components +鈹溾攢鈹€ tools/ # Agent tools (Bash, Edit, Grep, etc.) +鈹溾攢鈹€ commands/ # Slash commands (/commit, /review, etc.) +鈹溾攢鈹€ skills/ # Skill system +鈹溾攢鈹€ services/ # Service layer (API, MCP, OAuth, etc.) +鈹溾攢鈹€ hooks/ # React hooks +鈹斺攢鈹€ utils/ # Utility functions ``` --- diff --git a/README.md b/README.md index c715228d..6d3ad38b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Claude Code Haha +# Claude Code Haha

中文 | English

@@ -202,6 +202,39 @@ bun --env-file=.env ./src/localRecoveryCli.ts | `DISABLE_TELEMETRY` | 否 | 设为 `1` 禁用遥测 | | `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` | 否 | 设为 `1` 禁用非必要网络请求 | +### Azure OpenAI (Codex) + +```env +CLAUDE_CODE_USE_AZURE_OPENAI=1 +AZURE_OPENAI_BASE_URL=https://.cognitiveservices.azure.com +AZURE_OPENAI_API_VERSION=2025-04-01-preview +AZURE_OPENAI_API_KEY=... +``` + +AZURE_OPENAI_CODEX_DEPLOYMENT=... +``` + +也可以只用一个默认 deployment: + +```env +AZURE_OPENAI_CODEX_DEPLOYMENT=... +``` + +或在 `~/.claude/settings.json` 中按模型映射: + +`~/.claude/settings.json`: + +```json +{ + "modelOverrides": { + "gpt-5.2-codex": "codex-deployment-52", + "gpt-5.3-codex": "codex-deployment-53", + "gpt-5.4-codex": "codex-deployment-54" + } +} +``` + + --- ## 降级模式 diff --git a/src/commands/feedback/index.ts b/src/commands/feedback/index.ts index ec092c8c..0df5bbb9 100644 --- a/src/commands/feedback/index.ts +++ b/src/commands/feedback/index.ts @@ -14,6 +14,7 @@ const feedback = { isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) || isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) || isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) || + isEnvTruthy(process.env.CLAUDE_CODE_USE_AZURE_OPENAI) || isEnvTruthy(process.env.DISABLE_FEEDBACK_COMMAND) || isEnvTruthy(process.env.DISABLE_BUG_COMMAND) || isEssentialTrafficOnly() || diff --git a/src/entrypoints/sdk/coreSchemas.ts b/src/entrypoints/sdk/coreSchemas.ts index 4d5b9d0a..10dd4f7b 100644 --- a/src/entrypoints/sdk/coreSchemas.ts +++ b/src/entrypoints/sdk/coreSchemas.ts @@ -1087,7 +1087,7 @@ export const AccountInfoSchema = lazySchema(() => tokenSource: z.string().optional(), apiKeySource: z.string().optional(), apiProvider: z - .enum(['firstParty', 'bedrock', 'vertex', 'foundry']) + .enum(['firstParty', 'bedrock', 'vertex', 'foundry', 'azureOpenAI']) .optional() .describe( 'Active API backend. Anthropic OAuth login only applies when "firstParty"; for 3P providers the other fields are absent and auth is external (AWS creds, gcloud ADC, etc.).', diff --git a/src/services/analytics/config.ts b/src/services/analytics/config.ts index 9e80601b..9334c72c 100644 --- a/src/services/analytics/config.ts +++ b/src/services/analytics/config.ts @@ -22,6 +22,7 @@ export function isAnalyticsDisabled(): boolean { isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) || isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) || isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) || + isEnvTruthy(process.env.CLAUDE_CODE_USE_AZURE_OPENAI) || isTelemetryDisabled() ) } diff --git a/src/services/api/azureOpenAI.ts b/src/services/api/azureOpenAI.ts new file mode 100644 index 00000000..ad040f50 --- /dev/null +++ b/src/services/api/azureOpenAI.ts @@ -0,0 +1,406 @@ +import type { BetaContentBlock, BetaUsage } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' +import { randomUUID } from 'crypto' +import type { Tools, ToolPermissionContext } from 'src/Tool.js' +import { toolMatchesName } from 'src/Tool.js' +import { TOOL_SEARCH_TOOL_NAME } from 'src/tools/ToolSearchTool/prompt.js' +import { getUserAgent } from 'src/utils/http.js' +import { safeParseJSON } from 'src/utils/json.js' +import { logForDebugging } from 'src/utils/debug.js' +import { getProxyFetchOptions } from 'src/utils/proxy.js' +import { getModelStrings } from 'src/utils/model/modelStrings.js' +import { isEnvTruthy } from 'src/utils/envUtils.js' +import { toolToAPISchema } from 'src/utils/api.js' +import type { AgentDefinition } from 'src/tools/AgentTool/loadAgentsDir.js' + +const DEFAULT_API_VERSION = '2025-04-01-preview' + +type OpenAIToolCall = { + id: string + type: 'function' + function: { + name: string + arguments: string + } +} + +type OpenAIMessage = { + role: 'system' | 'user' | 'assistant' | 'tool' + content?: string | null + tool_calls?: OpenAIToolCall[] + tool_call_id?: string +} + +type OpenAIResponseOutputItem = { + type?: string + role?: string + id?: string + call_id?: string + tool_call_id?: string + name?: string + arguments?: string + function?: { name?: string; arguments?: string } + content?: Array<{ type?: string; text?: string }> + output?: string +} + +type OpenAIResponse = { + id?: string + output?: OpenAIResponseOutputItem[] + output_text?: string + usage?: { + input_tokens?: number + output_tokens?: number + prompt_tokens?: number + completion_tokens?: number + } +} + +export function resolveAzureOpenAIEndpoint(): string { + const baseUrl = + process.env.AZURE_OPENAI_BASE_URL || process.env.AZURE_OPENAI_ENDPOINT + if (!baseUrl) { + throw new Error( + 'Missing Azure OpenAI base URL. Set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_ENDPOINT.', + ) + } + + const apiVersion = process.env.AZURE_OPENAI_API_VERSION || DEFAULT_API_VERSION + const url = new URL(baseUrl) + const path = url.pathname.replace(/\/$/, '') + if (!/\/openai\//i.test(path)) { + url.pathname = `${path}/openai/responses` + } + + if (!url.searchParams.has('api-version') || process.env.AZURE_OPENAI_API_VERSION) { + url.searchParams.set('api-version', apiVersion) + } + + return url.toString() +} + +function resolveCodexDeployment(model: string): string | null { + const envDefault = process.env.AZURE_OPENAI_CODEX_DEPLOYMENT + if (envDefault) { + return envDefault + } + + switch (model.toLowerCase()) { + case 'gpt-5.2-codex': + return getModelStrings().gpt52codex + case 'gpt-5.3-codex': + return getModelStrings().gpt53codex + case 'gpt-5.4-codex': + return getModelStrings().gpt54codex + default: + return null + } +} + +export function resolveAzureOpenAIDeployment(model: string): string { + const trimmed = model.trim() + const envDefault = process.env.AZURE_OPENAI_CODEX_DEPLOYMENT + if (envDefault) { + return envDefault + } + + const codex = resolveCodexDeployment(trimmed) + if (codex) { + if (codex === trimmed || codex.toLowerCase().includes('codex')) { + throw new Error( + `Missing Azure OpenAI deployment mapping for ${trimmed}. Set AZURE_OPENAI_CODEX_DEPLOYMENT or settings.modelOverrides["${trimmed}"] to your deployment name.`, + ) + } + return codex + } + + return trimmed +} + +export function getAzureOpenAIHeaders(): Record { + const headers: Record = { + 'Content-Type': 'application/json', + 'User-Agent': getUserAgent(), + } + + if (!isEnvTruthy(process.env.CLAUDE_CODE_SKIP_AZURE_OPENAI_AUTH)) { + const apiKey = process.env.AZURE_OPENAI_API_KEY + if (!apiKey) { + throw new Error( + 'Missing Azure OpenAI API key. Set AZURE_OPENAI_API_KEY or enable CLAUDE_CODE_SKIP_AZURE_OPENAI_AUTH for testing.', + ) + } + headers['api-key'] = apiKey + } + + return headers +} + +export async function buildAzureOpenAITools(params: { + tools: Tools + getToolPermissionContext: () => Promise + agents: AgentDefinition[] + allowedAgentTypes?: string[] + model?: string +}): Promise< + { + type: 'function' + name: string + description: string + parameters: object + }[] +> { + const toolSchemas = await Promise.all( + params.tools + .filter(t => !toolMatchesName(t, TOOL_SEARCH_TOOL_NAME)) + .map(tool => + toolToAPISchema(tool, { + getToolPermissionContext: params.getToolPermissionContext, + tools: params.tools, + agents: params.agents, + allowedAgentTypes: params.allowedAgentTypes, + model: params.model, + }), + ), + ) + + return toolSchemas.map(schema => ({ + type: 'function', + name: schema.name, + description: schema.description ?? '', + parameters: schema.input_schema ?? {}, + })) +} + +function contentBlocksToText(content: unknown): string { + if (typeof content === 'string') return content + if (!Array.isArray(content)) return '' + return content + .map(block => { + if (block && typeof block === 'object' && 'type' in block) { + const typed = block as { type?: string; text?: string } + if (typed.type === 'text' && typeof typed.text === 'string') { + return typed.text + } + } + return '' + }) + .filter(Boolean) + .join('\n') +} + +export function buildAzureOpenAIInput(messages: Array<{ type: string; message: { content: unknown } }>): OpenAIMessage[] { + const inputs: OpenAIMessage[] = [] + + for (const msg of messages) { + if (msg.type !== 'user' && msg.type !== 'assistant') continue + + const content = msg.message.content + if (!Array.isArray(content)) { + const text = contentBlocksToText(content) + if (text.trim().length > 0) { + inputs.push({ role: msg.type, content: text }) + } + continue + } + + const textParts: string[] = [] + const toolCalls: OpenAIToolCall[] = [] + + for (const block of content) { + if (!block || typeof block !== 'object' || !('type' in block)) continue + const typed = block as { + type?: string + text?: string + id?: string + name?: string + input?: unknown + tool_use_id?: string + content?: unknown + } + + if (typed.type === 'text' && typeof typed.text === 'string') { + textParts.push(typed.text) + } + + if (typed.type === 'tool_use' && typed.name) { + const args = + typeof typed.input === 'string' + ? typed.input + : JSON.stringify(typed.input ?? {}) + toolCalls.push({ + id: typed.id ?? randomUUID(), + type: 'function', + function: { + name: typed.name, + arguments: args, + }, + }) + } + + if (typed.type === 'tool_result' && msg.type === 'user') { + const resultText = contentBlocksToText(typed.content) + inputs.push({ + role: 'tool', + tool_call_id: typed.tool_use_id ?? randomUUID(), + content: resultText, + }) + } + } + + if (msg.type === 'assistant') { + const contentText = textParts.join('\n') + if (contentText || toolCalls.length > 0) { + inputs.push({ + role: 'assistant', + content: contentText.length > 0 ? contentText : null, + ...(toolCalls.length > 0 && { tool_calls: toolCalls }), + }) + } + continue + } + + if (msg.type === 'user') { + const contentText = textParts.join('\n') + if (contentText.length > 0) { + inputs.push({ role: 'user', content: contentText }) + } + } + } + + return inputs +} + +function mapOutputItemToBlocks(item: OpenAIResponseOutputItem): BetaContentBlock[] { + const blocks: BetaContentBlock[] = [] + if (!item) return blocks + + if (item.type === 'message' && Array.isArray(item.content)) { + for (const content of item.content) { + if (!content || typeof content !== 'object') continue + if (content.type === 'output_text' || content.type === 'text') { + const text = content.text ?? '' + blocks.push({ type: 'text', text }) + } + } + } + + if (item.type === 'tool_call' || item.type === 'function_call') { + const name = item.name ?? item.function?.name + if (name) { + const rawArgs = item.arguments ?? item.function?.arguments ?? '{}' + const parsed = + typeof rawArgs === 'string' ? safeParseJSON(rawArgs) : rawArgs + blocks.push({ + type: 'tool_use', + id: item.id ?? item.call_id ?? item.tool_call_id ?? randomUUID(), + name, + input: parsed ?? {}, + } as BetaContentBlock) + } + } + + return blocks +} + +export function parseAzureOpenAIResponse(response: OpenAIResponse): { + content: BetaContentBlock[] + usage: BetaUsage + responseId?: string +} { + const contentBlocks: BetaContentBlock[] = [] + + if (Array.isArray(response.output)) { + for (const item of response.output) { + contentBlocks.push(...mapOutputItemToBlocks(item)) + } + } + + if (contentBlocks.length === 0 && response.output_text) { + contentBlocks.push({ type: 'text', text: response.output_text }) + } + + const usage: BetaUsage = { + input_tokens: response.usage?.input_tokens ?? response.usage?.prompt_tokens ?? 0, + output_tokens: response.usage?.output_tokens ?? response.usage?.completion_tokens ?? 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + } as BetaUsage + + return { content: contentBlocks, usage, responseId: response.id } +} + +export async function requestAzureOpenAI(params: { + model: string + systemPrompt: string + messages: Array<{ type: string; message: { content: unknown } }> + tools: Tools + toolChoice?: { type?: string; name?: string } + maxOutputTokens: number + temperature?: number + getToolPermissionContext: () => Promise + agents: AgentDefinition[] + allowedAgentTypes?: string[] + signal: AbortSignal +}): Promise<{ content: BetaContentBlock[]; usage: BetaUsage; responseId?: string }>{ + const deployment = resolveAzureOpenAIDeployment(params.model) + const endpoint = resolveAzureOpenAIEndpoint() + const headers = getAzureOpenAIHeaders() + + const tools = await buildAzureOpenAITools({ + tools: params.tools, + getToolPermissionContext: params.getToolPermissionContext, + agents: params.agents, + allowedAgentTypes: params.allowedAgentTypes, + model: params.model, + }) + + const input = buildAzureOpenAIInput(params.messages) + + const body: Record = { + model: deployment, + input, + instructions: params.systemPrompt, + max_output_tokens: params.maxOutputTokens, + } + + if (tools.length > 0) { + body.tools = tools + } + + if (params.toolChoice?.type === 'tool' && params.toolChoice.name) { + body.tool_choice = { + type: 'function', + name: params.toolChoice.name, + } + } else if (tools.length > 0) { + body.tool_choice = 'auto' + } + + if (params.temperature !== undefined) { + body.temperature = params.temperature + } + + logForDebugging( + `[AzureOpenAI] POST ${endpoint} model=${deployment} tools=${tools.length}`, + ) + + const fetchOptions = getProxyFetchOptions() + // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins + const response = await fetch(endpoint, { + method: 'POST', + headers, + body: JSON.stringify(body), + signal: params.signal, + ...fetchOptions, + }) + + if (!response.ok) { + const errorBody = await response.text() + throw new Error( + `Azure OpenAI request failed (${response.status}): ${errorBody}`, + ) + } + + const data = (await response.json()) as OpenAIResponse + return parseAzureOpenAIResponse(data) +} diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index 89a6e661..7e2e695c 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -1,4 +1,4 @@ -import type { +import type { BetaContentBlock, BetaContentBlockParam, BetaImageBlockParam, @@ -229,6 +229,7 @@ import { getInitializationStatus } from '../lsp/manager.js' import { isToolFromMcpServer } from '../mcp/utils.js' import { withStreamingVCR, withVCR } from '../vcr.js' import { CLIENT_REQUEST_ID_HEADER, getAnthropicClient } from './client.js' +import { requestAzureOpenAI } from './azureOpenAI.js' import { API_ERROR_MESSAGE_PREFIX, CUSTOM_OFF_SWITCH_MESSAGE, @@ -280,7 +281,7 @@ export function getExtraBodyParams(betaHeaders?: string[]): JsonObject { const parsed = safeParseJSON(extraBodyStr) // We expect an object with key-value pairs to spread into API parameters if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - // Shallow clone — safeParseJSON is LRU-cached and returns the same + // Shallow clone 鈥?safeParseJSON is LRU-cached and returns the same // object reference for the same string. Mutating `result` below // would poison the cache, causing stale values to persist. result = { ...(parsed as JsonObject) } @@ -383,15 +384,15 @@ export function getCacheControl({ * GrowthBook config shape: { allowlist: string[] } * Patterns support trailing '*' for prefix matching. * Examples: - * - { allowlist: ["repl_main_thread*", "sdk"] } — main thread + SDK only - * - { allowlist: ["repl_main_thread*", "sdk", "agent:*"] } — also subagents - * - { allowlist: ["*"] } — all sources + * - { allowlist: ["repl_main_thread*", "sdk"] } 鈥?main thread + SDK only + * - { allowlist: ["repl_main_thread*", "sdk", "agent:*"] } 鈥?also subagents + * - { allowlist: ["*"] } 鈥?all sources * - * The allowlist is cached in STATE for session stability — prevents mixed + * The allowlist is cached in STATE for session stability 鈥?prevents mixed * TTLs when GrowthBook's disk cache updates mid-request. */ function should1hCacheTTL(querySource?: QuerySource): boolean { - // 3P Bedrock users get 1h TTL when opted in via env var — they manage their own billing + // 3P Bedrock users get 1h TTL when opted in via env var 鈥?they manage their own billing // No GrowthBook gating needed since 3P users don't have GrowthBook configured if ( getAPIProvider() === 'bedrock' && @@ -400,7 +401,7 @@ function should1hCacheTTL(querySource?: QuerySource): boolean { return true } - // Latch eligibility in bootstrap state for session stability — prevents + // Latch eligibility in bootstrap state for session stability 鈥?prevents // mid-session overage flips from changing the cache_control TTL, which // would bust the server-side prompt cache (~20K tokens per flip). let userEligible = getPromptCache1hEligible() @@ -412,7 +413,7 @@ function should1hCacheTTL(querySource?: QuerySource): boolean { } if (!userEligible) return false - // Cache allowlist in bootstrap state for session stability — prevents mixed + // Cache allowlist in bootstrap state for session stability 鈥?prevents mixed // TTLs when GrowthBook's disk cache updates mid-request let allowlist = getPromptCache1hAllowlist() if (allowlist === null) { @@ -465,7 +466,7 @@ function configureEffortParams( } } -// output_config.task_budget — API-side token budget awareness for the model. +// output_config.task_budget 鈥?API-side token budget awareness for the model. // Stainless SDK types don't yet include task_budget on BetaOutputConfig, so we // define the wire shape locally and cast. The API validates on receipt; see // api/api/schemas/messages/request/output_config.py:12-39 in the monorepo. @@ -700,7 +701,7 @@ export type Options = { advisorModel?: string addNotification?: (notif: Notification) => void // API-side task budget (output_config.task_budget). Distinct from the - // tokenBudget.ts +500k auto-continue feature — this one is sent to the API + // tokenBudget.ts +500k auto-continue feature 鈥?this one is sent to the API // so the model can pace itself. `remaining` is computed by the caller // (query.ts decrements across the agentic loop). taskBudget?: { total: number; remaining?: number } @@ -801,7 +802,7 @@ function shouldDeferLspTool(tool: Tool): boolean { * (~5min) so a hung fallback to a wedged backend surfaces a clean * APIConnectionTimeoutError instead of stalling past SIGKILL. * - * Otherwise defaults to 300s — long enough for slow backends without + * Otherwise defaults to 300s 鈥?long enough for slow backends without * approaching the API's 10-minute non-streaming boundary. */ function getNonstreamingFallbackTimeoutMs(): number { @@ -872,7 +873,7 @@ export async function* executeNonStreamingRequest( }, ) } catch (err) { - // User aborts are not errors — re-throw immediately without logging + // User aborts are not errors 鈥?re-throw immediately without logging if (err instanceof APIUserAbortError) throw err // Instrumentation: record when the non-streaming request errors (including @@ -1025,7 +1026,54 @@ async function* queryModel( StreamEvent | AssistantMessage | SystemAPIErrorMessage, void > { - // Check cheap conditions first — the off-switch await blocks on GrowthBook + if (getAPIProvider() === 'azureOpenAI') { + try { + const systemText = systemPrompt.join('\\n') + const maxOutputTokens = + options.maxOutputTokensOverride ?? + getModelMaxOutputTokens(options.model).default + const temperature = + thinkingConfig.type === 'disabled' + ? (options.temperatureOverride ?? 1) + : undefined + + const result = await requestAzureOpenAI({ + model: options.model, + systemPrompt: systemText, + messages, + tools, + toolChoice: options.toolChoice as { type?: string; name?: string }, + maxOutputTokens, + temperature, + getToolPermissionContext: options.getToolPermissionContext, + agents: options.agents, + allowedAgentTypes: options.allowedAgentTypes, + signal, + }) + + const assistantMessage: AssistantMessage = { + message: { + id: result.responseId ?? randomUUID(), + model: options.model, + role: 'assistant', + content: result.content, + stop_reason: 'end_turn', + usage: result.usage, + }, + requestId: result.responseId ?? undefined, + type: 'assistant', + uuid: randomUUID(), + timestamp: new Date().toISOString(), + } + + yield assistantMessage + } catch (error) { + yield getAssistantMessageFromError(error, options.model, { messages }) + } + return + } + + // Check cheap conditions first 鈥?the off-switch await blocks on GrowthBook // init (~10ms). For non-Opus models (haiku, sonnet) this skips the await // entirely. Subscribers don't hit this path at all. if ( @@ -1125,7 +1173,7 @@ async function* queryModel( 'query', ) - // Precompute once — isDeferredTool does 2 GrowthBook lookups per call + // Precompute once 鈥?isDeferredTool does 2 GrowthBook lookups per call const deferredToolNames = new Set() if (useToolSearch) { for (const t of tools) { @@ -1207,7 +1255,7 @@ async function* queryModel( const useGlobalCacheFeature = shouldUseGlobalCacheScope() const willDefer = (t: Tool) => useToolSearch && (deferredToolNames.has(t.name) || shouldDeferLspTool(t)) - // MCP tools are per-user → dynamic tool section → can't globally cache. + // MCP tools are per-user 鈫?dynamic tool section 鈫?can't globally cache. // Only gate when an MCP tool will actually render (not defer_loading). const needsToolBasedCacheMarker = useGlobalCacheFeature && @@ -1274,7 +1322,7 @@ async function* queryModel( // called from ~20 places (analytics, feedback, sharing, etc.), many of which // don't have model context. Adding model to its signature would be a large refactor. // - This post-processing uses the model-aware isToolSearchEnabled() check - // - This handles mid-conversation model switching (e.g., Sonnet → Haiku) where + // - This handles mid-conversation model switching (e.g., Sonnet 鈫?Haiku) where // stale tool-search fields from the previous model would cause 400 errors // // Note: For assistant messages, normalizeMessagesForAPI already normalized the @@ -1300,7 +1348,7 @@ async function* queryModel( // tool_uses and strips orphaned tool_results referencing non-existent tool_uses. messagesForAPI = ensureToolResultPairing(messagesForAPI) - // Strip advisor blocks — the API rejects them without the beta header. + // Strip advisor blocks 鈥?the API rejects them without the beta header. if (!betas.includes(ADVISOR_BETA_HEADER)) { messagesForAPI = stripAdvisorBlocks(messagesForAPI) } @@ -1688,7 +1736,7 @@ async function* queryModel( ) } - // Only send temperature when thinking is disabled — the API requires + // Only send temperature when thinking is disabled 鈥?the API requires // temperature: 1 when thinking is enabled, which is already the default. const temperature = !hasThinking ? (options.temperatureOverride ?? 1) @@ -1730,7 +1778,7 @@ async function* queryModel( // Compute log scalars synchronously so the fire-and-forget .then() closure // captures only primitives instead of paramsFromContext's full closure scope - // (messagesForAPI, system, allTools, betas — the entire request-building + // (messagesForAPI, system, allTools, betas 鈥?the entire request-building // context), which would otherwise be pinned until the promise resolves. { const queryParams = paramsFromContext({ @@ -1809,13 +1857,13 @@ async function* queryModel( // Generate and track client request ID so timeouts (which return no // server request ID) can still be correlated with server logs. - // First-party only — 3P providers don't log it (inc-4029 class). + // First-party only 鈥?3P providers don't log it (inc-4029 class). clientRequestId = getAPIProvider() === 'firstParty' && isFirstPartyAnthropicBaseUrl() ? randomUUID() : undefined - // Use raw stream instead of BetaMessageStream to avoid O(n²) partial JSON parsing + // Use raw stream instead of BetaMessageStream to avoid O(n虏) partial JSON parsing // BetaMessageStream calls partialParse() on every input_json_delta, which we don't need // since we handle tool input accumulation ourselves // biome-ignore lint/plugin: main conversation loop handles attribution separately @@ -2281,7 +2329,7 @@ async function* queryModel( max_tokens: maxOutputTokens, output_tokens: usage.output_tokens, }) - // Reuse the max_output_tokens recovery path — from the model's + // Reuse the max_output_tokens recovery path 鈥?from the model's // perspective, both mean "response was cut off, continue from // where you left off." yield createAssistantAPIErrorMessage({ @@ -2598,7 +2646,7 @@ async function* queryModel( } catch (errorFromRetry) { // FallbackTriggeredError must propagate to query.ts, which performs the // actual model switch. Swallowing it here would turn the fallback into a - // no-op — the user would just see "Model fallback triggered: X -> Y" as + // no-op 鈥?the user would just see "Model fallback triggered: X -> Y" as // an error message with no actual retry on the fallback model. if (errorFromRetry instanceof FallbackTriggeredError) { throw errorFromRetry @@ -2617,7 +2665,7 @@ async function* queryModel( if (is404StreamCreationError) { // 404 is thrown at .withResponse() before streamRequestId is assigned, - // and CannotRetryError means every retry failed — so grab the failed + // and CannotRetryError means every retry failed 鈥?so grab the failed // request's ID from the error header instead. const failedRequestId = (errorFromRetry.originalError as APIError).requestID ?? 'unknown' @@ -2838,7 +2886,7 @@ async function* queryModel( // Track the last requestId for the main conversation chain so shutdown // can send a cache eviction hint to inference. Exclude backgrounded // sessions (Ctrl+B) which share the repl_main_thread querySource but - // run inside an agent context — they are independent conversation chains + // run inside an agent context 鈥?they are independent conversation chains // whose cache should not be evicted when the foreground session clears. if ( streamRequestId && @@ -3019,7 +3067,7 @@ export function accumulateUsage( totalUsage.cache_creation.ephemeral_5m_input_tokens + messageUsage.cache_creation.ephemeral_5m_input_tokens, }, - // See comment in updateUsage — field is not on NonNullableUsage to keep + // See comment in updateUsage 鈥?field is not on NonNullableUsage to keep // the string out of external builds. ...(feature('CACHED_MICROCOMPACT') ? { @@ -3080,7 +3128,7 @@ export function addCacheBreakpoints( // local-attention KV pages at any cached prefix position NOT in // cache_store_int_token_boundaries. With two markers the second-to-last // position is protected and its locals survive an extra turn even though - // nothing will ever resume from there — with one marker they're freed + // nothing will ever resume from there 鈥?with one marker they're freed // immediately. For fire-and-forget forks (skipCacheWrite) we shift the // marker to the second-to-last message: that's the last shared-prefix // point, so the write is a no-op merge on mycro (entry already exists) @@ -3179,7 +3227,7 @@ export function addCacheBreakpoints( // Add cache_reference to tool_result blocks that are strictly before // the last cache_control marker. The API requires cache_reference to - // appear "before or on" the last cache_control — we use strict "before" + // appear "before or on" the last cache_control 鈥?we use strict "before" // to avoid edge cases where cache_edits splicing shifts block indices. // // Create new objects instead of mutating in-place to avoid contaminating @@ -3349,7 +3397,7 @@ export async function queryWithModel({ // Non-streaming requests have a 10min max per the docs: // https://platform.claude.com/docs/en/api/errors#long-requests -// The SDK's 21333-token cap is derived from 10min × 128k tokens/hour, but we +// The SDK's 21333-token cap is derived from 10min 脳 128k tokens/hour, but we // bypass it by setting a client-level timeout, so we can cap higher. export const MAX_NON_STREAMING_TOKENS = 64_000 @@ -3400,7 +3448,7 @@ export function getMaxOutputTokensForModel(model: string): number { const maxOutputTokens = getModelMaxOutputTokens(model) // Slot-reservation cap: drop default to 8k for all models. BQ p99 output - // = 4,911 tokens; 32k/64k defaults over-reserve 8-16× slot capacity. + // = 4,911 tokens; 32k/64k defaults over-reserve 8-16脳 slot capacity. // Requests hitting the cap get one clean retry at 64k (query.ts // max_output_tokens_escalate). Math.min keeps models with lower native // defaults (e.g. claude-3-opus at 4k) at their native value. Applied diff --git a/src/utils/apiPreconnect.ts b/src/utils/apiPreconnect.ts index 6a8de649..7ee24c1b 100644 --- a/src/utils/apiPreconnect.ts +++ b/src/utils/apiPreconnect.ts @@ -36,7 +36,8 @@ export function preconnectAnthropicApi(): void { if ( isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) || isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) || - isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) + isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) || + isEnvTruthy(process.env.CLAUDE_CODE_USE_AZURE_OPENAI) ) { return } diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 64a61808..386d2f99 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -115,7 +115,8 @@ export function isAnthropicAuthEnabled(): boolean { const is3P = isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) || isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) || - isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) + isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) || + isEnvTruthy(process.env.CLAUDE_CODE_USE_AZURE_OPENAI) // Check if user has configured an external API key source // This allows externally-provided API keys to work (without requiring proxy configuration) @@ -1594,7 +1595,8 @@ export function is1PApiCustomer(): boolean { if ( isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) || isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) || - isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) + isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) || + isEnvTruthy(process.env.CLAUDE_CODE_USE_AZURE_OPENAI) ) { return false } @@ -1733,7 +1735,8 @@ export function isUsing3PServices(): boolean { return !!( isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) || isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) || - isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) + isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) || + isEnvTruthy(process.env.CLAUDE_CODE_USE_AZURE_OPENAI) ) } diff --git a/src/utils/log.ts b/src/utils/log.ts index bc4df3e1..827c3a81 100644 --- a/src/utils/log.ts +++ b/src/utils/log.ts @@ -170,6 +170,7 @@ export function logError(error: unknown): void { isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) || isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) || isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) || + isEnvTruthy(process.env.CLAUDE_CODE_USE_AZURE_OPENAI) || process.env.DISABLE_ERROR_REPORTING || isEssentialTrafficOnly() ) { diff --git a/src/utils/managedEnvConstants.ts b/src/utils/managedEnvConstants.ts index 12c56565..23c01eec 100644 --- a/src/utils/managedEnvConstants.ts +++ b/src/utils/managedEnvConstants.ts @@ -18,12 +18,15 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([ 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_USE_AZURE_OPENAI', // Endpoint config (base URLs, project/resource identifiers) 'ANTHROPIC_BASE_URL', 'ANTHROPIC_BEDROCK_BASE_URL', 'ANTHROPIC_VERTEX_BASE_URL', 'ANTHROPIC_FOUNDRY_BASE_URL', 'ANTHROPIC_FOUNDRY_RESOURCE', + 'AZURE_OPENAI_BASE_URL', + 'AZURE_OPENAI_API_VERSION', 'ANTHROPIC_VERTEX_PROJECT_ID', // Region routing (per-model VERTEX_REGION_CLAUDE_* handled by prefix below) 'CLOUD_ML_REGION', @@ -33,9 +36,11 @@ const PROVIDER_MANAGED_ENV_VARS = new Set([ 'CLAUDE_CODE_OAUTH_TOKEN', 'AWS_BEARER_TOKEN_BEDROCK', 'ANTHROPIC_FOUNDRY_API_KEY', + 'AZURE_OPENAI_API_KEY', 'CLAUDE_CODE_SKIP_BEDROCK_AUTH', 'CLAUDE_CODE_SKIP_VERTEX_AUTH', 'CLAUDE_CODE_SKIP_FOUNDRY_AUTH', + 'CLAUDE_CODE_SKIP_AZURE_OPENAI_AUTH', // Model defaults — often set to provider-specific ID formats 'ANTHROPIC_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', @@ -148,6 +153,8 @@ export const SAFE_ENV_VARS = new Set([ 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_FOUNDRY', 'CLAUDE_CODE_USE_VERTEX', + 'CLAUDE_CODE_USE_AZURE_OPENAI', + 'AZURE_OPENAI_API_VERSION', 'DISABLE_AUTOUPDATER', 'DISABLE_BUG_COMMAND', 'DISABLE_COST_WARNINGS', diff --git a/src/utils/model/configs.ts b/src/utils/model/configs.ts index 89f243d8..63877dcf 100644 --- a/src/utils/model/configs.ts +++ b/src/utils/model/configs.ts @@ -11,6 +11,7 @@ export const CLAUDE_3_7_SONNET_CONFIG = { bedrock: 'us.anthropic.claude-3-7-sonnet-20250219-v1:0', vertex: 'claude-3-7-sonnet@20250219', foundry: 'claude-3-7-sonnet', + azureOpenAI: 'claude-3-7-sonnet-20250219', } as const satisfies ModelConfig export const CLAUDE_3_5_V2_SONNET_CONFIG = { @@ -18,6 +19,7 @@ export const CLAUDE_3_5_V2_SONNET_CONFIG = { bedrock: 'anthropic.claude-3-5-sonnet-20241022-v2:0', vertex: 'claude-3-5-sonnet-v2@20241022', foundry: 'claude-3-5-sonnet', + azureOpenAI: 'claude-3-5-sonnet-20241022', } as const satisfies ModelConfig export const CLAUDE_3_5_HAIKU_CONFIG = { @@ -25,6 +27,7 @@ export const CLAUDE_3_5_HAIKU_CONFIG = { bedrock: 'us.anthropic.claude-3-5-haiku-20241022-v1:0', vertex: 'claude-3-5-haiku@20241022', foundry: 'claude-3-5-haiku', + azureOpenAI: 'claude-3-5-haiku-20241022', } as const satisfies ModelConfig export const CLAUDE_HAIKU_4_5_CONFIG = { @@ -32,6 +35,7 @@ export const CLAUDE_HAIKU_4_5_CONFIG = { bedrock: 'us.anthropic.claude-haiku-4-5-20251001-v1:0', vertex: 'claude-haiku-4-5@20251001', foundry: 'claude-haiku-4-5', + azureOpenAI: 'claude-haiku-4-5-20251001', } as const satisfies ModelConfig export const CLAUDE_SONNET_4_CONFIG = { @@ -39,6 +43,7 @@ export const CLAUDE_SONNET_4_CONFIG = { bedrock: 'us.anthropic.claude-sonnet-4-20250514-v1:0', vertex: 'claude-sonnet-4@20250514', foundry: 'claude-sonnet-4', + azureOpenAI: 'claude-sonnet-4-20250514', } as const satisfies ModelConfig export const CLAUDE_SONNET_4_5_CONFIG = { @@ -46,6 +51,7 @@ export const CLAUDE_SONNET_4_5_CONFIG = { bedrock: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', vertex: 'claude-sonnet-4-5@20250929', foundry: 'claude-sonnet-4-5', + azureOpenAI: 'claude-sonnet-4-5-20250929', } as const satisfies ModelConfig export const CLAUDE_OPUS_4_CONFIG = { @@ -53,6 +59,7 @@ export const CLAUDE_OPUS_4_CONFIG = { bedrock: 'us.anthropic.claude-opus-4-20250514-v1:0', vertex: 'claude-opus-4@20250514', foundry: 'claude-opus-4', + azureOpenAI: 'claude-opus-4-20250514', } as const satisfies ModelConfig export const CLAUDE_OPUS_4_1_CONFIG = { @@ -60,6 +67,7 @@ export const CLAUDE_OPUS_4_1_CONFIG = { bedrock: 'us.anthropic.claude-opus-4-1-20250805-v1:0', vertex: 'claude-opus-4-1@20250805', foundry: 'claude-opus-4-1', + azureOpenAI: 'claude-opus-4-1-20250805', } as const satisfies ModelConfig export const CLAUDE_OPUS_4_5_CONFIG = { @@ -67,6 +75,7 @@ export const CLAUDE_OPUS_4_5_CONFIG = { bedrock: 'us.anthropic.claude-opus-4-5-20251101-v1:0', vertex: 'claude-opus-4-5@20251101', foundry: 'claude-opus-4-5', + azureOpenAI: 'claude-opus-4-5-20251101', } as const satisfies ModelConfig export const CLAUDE_OPUS_4_6_CONFIG = { @@ -74,6 +83,7 @@ export const CLAUDE_OPUS_4_6_CONFIG = { bedrock: 'us.anthropic.claude-opus-4-6-v1', vertex: 'claude-opus-4-6', foundry: 'claude-opus-4-6', + azureOpenAI: 'claude-opus-4-6', } as const satisfies ModelConfig export const CLAUDE_SONNET_4_6_CONFIG = { @@ -81,6 +91,31 @@ export const CLAUDE_SONNET_4_6_CONFIG = { bedrock: 'us.anthropic.claude-sonnet-4-6', vertex: 'claude-sonnet-4-6', foundry: 'claude-sonnet-4-6', + azureOpenAI: 'claude-sonnet-4-6', +} as const satisfies ModelConfig + +export const GPT_5_2_CODEX_CONFIG = { + firstParty: 'gpt-5.2-codex', + bedrock: 'gpt-5.2-codex', + vertex: 'gpt-5.2-codex', + foundry: 'gpt-5.2-codex', + azureOpenAI: 'gpt-5.2-codex', +} as const satisfies ModelConfig + +export const GPT_5_3_CODEX_CONFIG = { + firstParty: 'gpt-5.3-codex', + bedrock: 'gpt-5.3-codex', + vertex: 'gpt-5.3-codex', + foundry: 'gpt-5.3-codex', + azureOpenAI: 'gpt-5.3-codex', +} as const satisfies ModelConfig + +export const GPT_5_4_CODEX_CONFIG = { + firstParty: 'gpt-5.4-codex', + bedrock: 'gpt-5.4-codex', + vertex: 'gpt-5.4-codex', + foundry: 'gpt-5.4-codex', + azureOpenAI: 'gpt-5.4-codex', } as const satisfies ModelConfig // @[MODEL LAUNCH]: Register the new config here. @@ -96,6 +131,9 @@ export const ALL_MODEL_CONFIGS = { opus41: CLAUDE_OPUS_4_1_CONFIG, opus45: CLAUDE_OPUS_4_5_CONFIG, opus46: CLAUDE_OPUS_4_6_CONFIG, + gpt52codex: GPT_5_2_CODEX_CONFIG, + gpt53codex: GPT_5_3_CODEX_CONFIG, + gpt54codex: GPT_5_4_CODEX_CONFIG, } as const satisfies Record export type ModelKey = keyof typeof ALL_MODEL_CONFIGS diff --git a/src/utils/model/deprecation.ts b/src/utils/model/deprecation.ts index a8b0ee23..6c057272 100644 --- a/src/utils/model/deprecation.ts +++ b/src/utils/model/deprecation.ts @@ -38,6 +38,7 @@ const DEPRECATED_MODELS: Record = { bedrock: 'January 15, 2026', vertex: 'January 5, 2026', foundry: 'January 5, 2026', + azureOpenAI: null, }, }, 'claude-3-7-sonnet': { @@ -47,6 +48,7 @@ const DEPRECATED_MODELS: Record = { bedrock: 'April 28, 2026', vertex: 'May 11, 2026', foundry: 'February 19, 2026', + azureOpenAI: null, }, }, 'claude-3-5-haiku': { @@ -56,6 +58,7 @@ const DEPRECATED_MODELS: Record = { bedrock: null, vertex: null, foundry: null, + azureOpenAI: null, }, }, } diff --git a/src/utils/model/model.ts b/src/utils/model/model.ts index 85e369fa..8d2a652f 100644 --- a/src/utils/model/model.ts +++ b/src/utils/model/model.ts @@ -176,6 +176,10 @@ export function getRuntimeMainLoopModel(params: { * @returns The default model setting to use */ export function getDefaultMainLoopModelSetting(): ModelName | ModelAlias { + if (getAPIProvider() === 'azureOpenAI') { + return 'gpt-5.2-codex' + } + // Ants default to defaultModel from flag config, or Opus 1M if not configured if (process.env.USER_TYPE === 'ant') { return ( diff --git a/src/utils/model/modelOptions.ts b/src/utils/model/modelOptions.ts index f8ef2966..cf7b0a93 100644 --- a/src/utils/model/modelOptions.ts +++ b/src/utils/model/modelOptions.ts @@ -266,9 +266,42 @@ function getOpusPlanOption(): ModelOption { } } +function getAzureOpenAICodexOption(): ModelOption { + return { + value: 'gpt-5.2-codex', + label: 'GPT-5.2 Codex', + description: 'Azure OpenAI Codex model', + descriptionForModel: 'GPT-5.2 Codex via Azure OpenAI', + } +} + +function getAzureOpenAICodexOptions(): ModelOption[] { + return [ + { + value: 'gpt-5.2-codex', + label: 'GPT-5.2 Codex', + description: 'Azure OpenAI Codex model', + }, + { + value: 'gpt-5.3-codex', + label: 'GPT-5.3 Codex', + description: 'Azure OpenAI Codex model', + }, + { + value: 'gpt-5.4-codex', + label: 'GPT-5.4 Codex', + description: 'Azure OpenAI Codex model', + }, + ] +} + // @[MODEL LAUNCH]: Update the model picker lists below to include/reorder options for the new model. // Each user tier (ant, Max/Team Premium, Pro/Team Standard/Enterprise, PAYG 1P, PAYG 3P) has its own list. function getModelOptionsBase(fastMode = false): ModelOption[] { + if (getAPIProvider() === 'azureOpenAI') { + return [getDefaultOptionForUser(), ...getAzureOpenAICodexOptions()] + } + if (process.env.USER_TYPE === 'ant') { // Build options from antModels config const antModelOptions: ModelOption[] = getAntModels().map(m => ({ diff --git a/src/utils/model/providers.ts b/src/utils/model/providers.ts index aba9b7d7..16f7eb28 100644 --- a/src/utils/model/providers.ts +++ b/src/utils/model/providers.ts @@ -1,7 +1,12 @@ import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../services/analytics/index.js' import { isEnvTruthy } from '../envUtils.js' -export type APIProvider = 'firstParty' | 'bedrock' | 'vertex' | 'foundry' +export type APIProvider = + | 'firstParty' + | 'bedrock' + | 'vertex' + | 'foundry' + | 'azureOpenAI' export function getAPIProvider(): APIProvider { return isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) @@ -10,6 +15,8 @@ export function getAPIProvider(): APIProvider { ? 'vertex' : isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) ? 'foundry' + : isEnvTruthy(process.env.CLAUDE_CODE_USE_AZURE_OPENAI) + ? 'azureOpenAI' : 'firstParty' } diff --git a/src/utils/model/validateModel.ts b/src/utils/model/validateModel.ts index 14b81675..c19f170b 100644 --- a/src/utils/model/validateModel.ts +++ b/src/utils/model/validateModel.ts @@ -35,6 +35,69 @@ export async function validateModel( } } + if (getAPIProvider() === 'azureOpenAI') { + const endpoint = + process.env.AZURE_OPENAI_BASE_URL || process.env.AZURE_OPENAI_ENDPOINT + if (!endpoint) { + return { + valid: false, + error: + 'AZURE_OPENAI_BASE_URL is required when using Azure OpenAI provider', + } + } + if ( + !process.env.AZURE_OPENAI_API_KEY && + !process.env.CLAUDE_CODE_SKIP_AZURE_OPENAI_AUTH + ) { + return { + valid: false, + error: + 'AZURE_OPENAI_API_KEY is required when using Azure OpenAI provider', + } + } + const lower = normalizedModel.toLowerCase() + if (lower === 'gpt-5.2-codex') { + if (process.env.AZURE_OPENAI_CODEX_DEPLOYMENT) { + return { valid: true } + } + const mapped = getModelStrings().gpt52codex + if (!mapped || mapped === 'gpt-5.2-codex') { + return { + valid: false, + error: + 'Missing Azure OpenAI deployment mapping for gpt-5.2-codex. Set AZURE_OPENAI_CODEX_DEPLOYMENT or settings.modelOverrides[\"gpt-5.2-codex\"] to your deployment name.', + } + } + } + if (lower === 'gpt-5.3-codex') { + if (process.env.AZURE_OPENAI_CODEX_DEPLOYMENT) { + return { valid: true } + } + const mapped = getModelStrings().gpt53codex + if (!mapped || mapped === 'gpt-5.3-codex') { + return { + valid: false, + error: + 'Missing Azure OpenAI deployment mapping for gpt-5.3-codex. Set AZURE_OPENAI_CODEX_DEPLOYMENT or settings.modelOverrides[\"gpt-5.3-codex\"] to your deployment name.', + } + } + } + if (lower === 'gpt-5.4-codex') { + if (process.env.AZURE_OPENAI_CODEX_DEPLOYMENT) { + return { valid: true } + } + const mapped = getModelStrings().gpt54codex + if (!mapped || mapped === 'gpt-5.4-codex') { + return { + valid: false, + error: + 'Missing Azure OpenAI deployment mapping for gpt-5.4-codex. Set AZURE_OPENAI_CODEX_DEPLOYMENT or settings.modelOverrides[\"gpt-5.4-codex\"] to your deployment name.', + } + } + } + return { valid: true } + } + // Check if it's a known alias (these are always valid) const lowerModel = normalizedModel.toLowerCase() if ((MODEL_ALIASES as readonly string[]).includes(lowerModel)) { diff --git a/src/utils/swarm/spawnUtils.ts b/src/utils/swarm/spawnUtils.ts index cfccdf5a..cc6c2e2d 100644 --- a/src/utils/swarm/spawnUtils.ts +++ b/src/utils/swarm/spawnUtils.ts @@ -99,8 +99,13 @@ const TEAMMATE_ENV_VARS = [ 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_USE_AZURE_OPENAI', // Custom API endpoint 'ANTHROPIC_BASE_URL', + 'AZURE_OPENAI_BASE_URL', + 'AZURE_OPENAI_ENDPOINT', + 'AZURE_OPENAI_API_VERSION', + 'AZURE_OPENAI_API_KEY', // Config directory override 'CLAUDE_CONFIG_DIR', // CCR marker — teammates need this for CCR-aware code paths. Auth finds diff --git a/tests/azureOpenAI.test.ts b/tests/azureOpenAI.test.ts new file mode 100644 index 00000000..fb570dc2 --- /dev/null +++ b/tests/azureOpenAI.test.ts @@ -0,0 +1,84 @@ +import { expect, test } from "bun:test" +import { + buildAzureOpenAIInput, + resolveAzureOpenAIEndpoint, + resolveAzureOpenAIDeployment, +} from "../src/services/api/azureOpenAI.js" + +test("resolveAzureOpenAIEndpoint appends responses path and api-version", () => { + const prevBase = process.env.AZURE_OPENAI_BASE_URL + const prevVersion = process.env.AZURE_OPENAI_API_VERSION + process.env.AZURE_OPENAI_BASE_URL = + "https://example.cognitiveservices.azure.com/" + process.env.AZURE_OPENAI_API_VERSION = "2025-04-01-preview" + + const url = resolveAzureOpenAIEndpoint() + expect(url).toContain("/openai/responses") + expect(url).toContain("api-version=2025-04-01-preview") + + process.env.AZURE_OPENAI_BASE_URL = prevBase + process.env.AZURE_OPENAI_API_VERSION = prevVersion +}) + +test("buildAzureOpenAIInput maps tool_use and tool_result", () => { + const input = buildAzureOpenAIInput([ + { + type: "assistant", + message: { + content: [ + { type: "text", text: "Running tool" }, + { + type: "tool_use", + id: "tool_1", + name: "my_tool", + input: { foo: "bar" }, + }, + ], + }, + }, + { + type: "user", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "tool_1", + content: [{ type: "text", text: "ok" }], + }, + ], + }, + }, + ]) + + expect(input.some(msg => msg.role === "assistant")).toBe(true) + expect(input.some(msg => msg.role === "tool")).toBe(true) +}) + +test("resolveAzureOpenAIDeployment throws when codex mapping is missing", () => { + const prevBase = process.env.AZURE_OPENAI_BASE_URL + const prevEnv = process.env.AZURE_OPENAI_CODEX_DEPLOYMENT + process.env.AZURE_OPENAI_BASE_URL = + "https://example.cognitiveservices.azure.com/" + delete process.env.AZURE_OPENAI_CODEX_DEPLOYMENT + + expect(() => resolveAzureOpenAIDeployment("gpt-5.2-codex")).toThrow() + expect(() => resolveAzureOpenAIDeployment("gpt-5.3-codex")).toThrow() + expect(() => resolveAzureOpenAIDeployment("gpt-5.4-codex")).toThrow() + + process.env.AZURE_OPENAI_BASE_URL = prevBase + process.env.AZURE_OPENAI_CODEX_DEPLOYMENT = prevEnv +}) + +test("resolveAzureOpenAIDeployment uses env default even if name matches", () => { + const prevBase = process.env.AZURE_OPENAI_BASE_URL + const prevEnv = process.env.AZURE_OPENAI_CODEX_DEPLOYMENT + process.env.AZURE_OPENAI_BASE_URL = + "https://example.cognitiveservices.azure.com/" + process.env.AZURE_OPENAI_CODEX_DEPLOYMENT = "gpt-5.2-codex" + + const resolved = resolveAzureOpenAIDeployment("gpt-5.2-codex") + expect(resolved).toBe("gpt-5.2-codex") + + process.env.AZURE_OPENAI_BASE_URL = prevBase + process.env.AZURE_OPENAI_CODEX_DEPLOYMENT = prevEnv +})