Merge pull request #21 from YanZhiwei/feat/azure-openai-codex-series

feat: add Azure OpenAI Codex responses support
This commit is contained in:
程序员阿江-Relakkes 2026-05-02 23:51:19 +08:00 committed by GitHub
commit e72047adfb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1752 additions and 1028 deletions

View File

@ -50,4 +50,11 @@
# 通用设置(建议始终开启)
# ============================================================
DISABLE_TELEMETRY=1
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=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,6 +2,7 @@
.env.*
!.env.example
node_modules
openspec
.DS_Store
# Computer Use runtime (auto-generated)

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,412 @@
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) {
const codexLower = codex.toLowerCase()
if (
codex === trimmed ||
codexLower === 'gpt-5.2-codex' ||
codexLower === 'gpt-5.3-codex' ||
codexLower === 'gpt-5.4-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)
}

File diff suppressed because it is too large Load Diff

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-7-v1',
vertex: 'claude-opus-4-7',
foundry: 'claude-opus-4-7',
azureOpenAI: 'claude-opus-4-7',
} 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
})