feat: add Azure OpenAI Codex responses support

This commit is contained in:
言志伟 2026-04-02 18:02:14 +08:00
parent 430502e7bb
commit 9ae13fcf1c
21 changed files with 850 additions and 54 deletions

View File

@ -7,3 +7,10 @@ ANTHROPIC_MODEL=MiniMax-M2.7-highspeed
API_TIMEOUT_MS=3000000
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
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

1
.gitignore vendored
View File

@ -2,3 +2,4 @@
.env.*
!.env.example
node_modules
openspec

View File

@ -1,6 +1,6 @@
# Claude Code Haha
# Claude Code Haha
<p align="right"><a href="./README.md">中文</a> | <strong>English</strong></p>
<p align="right"><a href="./README.md">涓枃</a> | <strong>English</strong></p>
A **locally runnable version** repaired from the leaked Claude Code source, with support for any Anthropic-compatible API endpoint such as MiniMax and OpenRouter.
@ -135,7 +135,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
@ -174,6 +174,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://<resource>.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://<resource>.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
@ -208,19 +258,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
```
---

View File

@ -1,4 +1,4 @@
# Claude Code Haha
# Claude Code Haha
<p align="right"><strong>中文</strong> | <a href="./README.en.md">English</a></p>
@ -174,6 +174,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://<resource>.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"
}
}
```
---
## 降级模式

View File

@ -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() ||

View File

@ -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.).',

View File

@ -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()
)
}

View File

@ -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<string, string> {
const headers: Record<string, string> = {
'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<ToolPermissionContext>
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<ToolPermissionContext>
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<string, unknown> = {
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)
}

View File

@ -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<string>()
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

View File

@ -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
}

View File

@ -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)
)
}

View File

@ -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()
) {

View File

@ -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',

View File

@ -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<string, ModelConfig>
export type ModelKey = keyof typeof ALL_MODEL_CONFIGS

View File

@ -38,6 +38,7 @@ const DEPRECATED_MODELS: Record<string, DeprecationEntry> = {
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<string, DeprecationEntry> = {
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<string, DeprecationEntry> = {
bedrock: null,
vertex: null,
foundry: null,
azureOpenAI: null,
},
},
}

View File

@ -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 (

View File

@ -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 => ({

View File

@ -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'
}

View File

@ -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)) {

View File

@ -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

84
tests/azureOpenAI.test.ts Normal file
View File

@ -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
})