cc-haha/src/services/api/azureOpenAI.ts
程序员阿江(Relakkes) 2459488703 Harden provider auth, adapter paths, and notification retries
This captures the pending worktree fixes before applying them to the
current local main. The changes tighten IM adapter path and credential
handling, preserve retry behavior for failed desktop notifications, and
make Azure/OpenAI provider auth and stop reasons reflect actual runtime
state.

Constraint: Worktree was detached from an older local main with pending uncommitted fixes
Rejected: Merge the detached HEAD directly | would also replay unrelated stale history
Rejected: Leave notification dedupe as fire-and-forget | failed sends consumed retry keys
Confidence: high
Scope-risk: broad
Directive: Keep adapter absolute-path matching constrained to configured work roots
Tested: git diff --check
Not-tested: full quality gate before local main integration
2026-05-04 21:37:30 +08:00

426 lines
12 KiB
TypeScript

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
status?: 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\/responses$/i.test(path)) {
url.pathname = path
} else if (/\/openai(?:\/.*)?$/i.test(path)) {
url.pathname = path.replace(/\/openai(?:\/.*)?$/i, '/openai/responses')
} else {
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
stopReason: 'end_turn' | 'tool_use' | 'max_tokens'
} {
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
const stopReason =
response.status === 'incomplete'
? 'max_tokens'
: contentBlocks.some(block => block.type === 'tool_use')
? 'tool_use'
: 'end_turn'
return { content: contentBlocks, usage, responseId: response.id, stopReason }
}
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; stopReason: 'end_turn' | 'tool_use' | 'max_tokens' }>{
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)
}