mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Merge pull request #21 from YanZhiwei/feat/azure-openai-codex-series
feat: add Azure OpenAI Codex responses support
This commit is contained in:
commit
e72047adfb
@ -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
1
.gitignore
vendored
@ -2,6 +2,7 @@
|
||||
.env.*
|
||||
!.env.example
|
||||
node_modules
|
||||
openspec
|
||||
.DS_Store
|
||||
|
||||
# Computer Use runtime (auto-generated)
|
||||
|
||||
@ -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() ||
|
||||
|
||||
@ -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.).',
|
||||
|
||||
@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
412
src/services/api/azureOpenAI.ts
Normal file
412
src/services/api/azureOpenAI.ts
Normal 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
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
) {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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 => ({
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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
84
tests/azureOpenAI.test.ts
Normal 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
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user