mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
feat: add Azure OpenAI Codex responses support
This commit is contained in:
parent
430502e7bb
commit
9ae13fcf1c
@ -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
1
.gitignore
vendored
@ -2,3 +2,4 @@
|
||||
.env.*
|
||||
!.env.example
|
||||
node_modules
|
||||
openspec
|
||||
|
||||
82
README.en.md
82
README.en.md
@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
35
README.md
35
README.md
@ -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"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 降级模式
|
||||
|
||||
@ -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()
|
||||
)
|
||||
}
|
||||
|
||||
406
src/services/api/azureOpenAI.ts
Normal file
406
src/services/api/azureOpenAI.ts
Normal 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)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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-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
|
||||
|
||||
@ -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