mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-22 08:15:53 +00:00
支持 OpenAI Chat 兼容协议 (#99)
* feat: 完成 openai 接口兼容 * feat: 完成 openai 协议兼容 * fix: 修复测试用例
This commit is contained in:
208
src/services/api/openai/index.ts
Normal file
208
src/services/api/openai/index.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||
import type { SystemPrompt } from '../../../utils/systemPromptType.js'
|
||||
import type { Message, StreamEvent, SystemAPIErrorMessage, AssistantMessage } from '../../../types/message.js'
|
||||
import type { Tools } from '../../../Tool.js'
|
||||
import { getOpenAIClient } from './client.js'
|
||||
import { anthropicMessagesToOpenAI } from './convertMessages.js'
|
||||
import { anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI } from './convertTools.js'
|
||||
import { adaptOpenAIStreamToAnthropic } from './streamAdapter.js'
|
||||
import { resolveOpenAIModel } from './modelMapping.js'
|
||||
import { normalizeMessagesForAPI } from '../../../utils/messages.js'
|
||||
import { toolToAPISchema } from '../../../utils/api.js'
|
||||
import { getEmptyToolPermissionContext } from '../../../Tool.js'
|
||||
import { logForDebugging } from '../../../utils/debug.js'
|
||||
import type { Options } from '../claude.js'
|
||||
import { randomUUID } from 'crypto'
|
||||
import {
|
||||
createAssistantAPIErrorMessage,
|
||||
normalizeContentFromAPI,
|
||||
} from '../../../utils/messages.js'
|
||||
|
||||
/**
|
||||
* OpenAI-compatible query path. Converts Anthropic-format messages/tools to
|
||||
* OpenAI format, calls the OpenAI-compatible endpoint, and converts the
|
||||
* SSE stream back to Anthropic BetaRawMessageStreamEvent for consumption
|
||||
* by the existing query pipeline.
|
||||
*/
|
||||
export async function* queryModelOpenAI(
|
||||
messages: Message[],
|
||||
systemPrompt: SystemPrompt,
|
||||
tools: Tools,
|
||||
signal: AbortSignal,
|
||||
options: Options,
|
||||
): AsyncGenerator<
|
||||
StreamEvent | AssistantMessage | SystemAPIErrorMessage,
|
||||
void
|
||||
> {
|
||||
try {
|
||||
// 1. Resolve model name
|
||||
const openaiModel = resolveOpenAIModel(options.model)
|
||||
|
||||
// 2. Normalize messages using shared preprocessing
|
||||
const messagesForAPI = normalizeMessagesForAPI(messages, tools)
|
||||
|
||||
// 3. Build tool schemas
|
||||
const toolSchemas = await Promise.all(
|
||||
tools.map(tool =>
|
||||
toolToAPISchema(tool, {
|
||||
getToolPermissionContext: options.getToolPermissionContext,
|
||||
tools,
|
||||
agents: options.agents,
|
||||
allowedAgentTypes: options.allowedAgentTypes,
|
||||
model: options.model,
|
||||
}),
|
||||
),
|
||||
)
|
||||
// Filter out non-standard tools (server tools like advisor)
|
||||
const standardTools = toolSchemas.filter(
|
||||
(t): t is BetaToolUnion & { type: string } => {
|
||||
const anyT = t as Record<string, unknown>
|
||||
return anyT.type !== 'advisor_20260301' && anyT.type !== 'computer_20250124'
|
||||
},
|
||||
)
|
||||
|
||||
// 4. Convert messages and tools to OpenAI format
|
||||
const openaiMessages = anthropicMessagesToOpenAI(messagesForAPI, systemPrompt)
|
||||
const openaiTools = anthropicToolsToOpenAI(standardTools)
|
||||
const openaiToolChoice = anthropicToolChoiceToOpenAI(options.toolChoice)
|
||||
|
||||
// 5. Get client and make streaming request
|
||||
const client = getOpenAIClient({
|
||||
maxRetries: 0,
|
||||
fetchOverride: options.fetchOverride,
|
||||
source: options.querySource,
|
||||
})
|
||||
|
||||
logForDebugging(`[OpenAI] Calling model=${openaiModel}, messages=${openaiMessages.length}, tools=${openaiTools.length}`)
|
||||
|
||||
// 6. Call OpenAI API with streaming
|
||||
const stream = await client.chat.completions.create(
|
||||
{
|
||||
model: openaiModel,
|
||||
messages: openaiMessages,
|
||||
...(openaiTools.length > 0 && {
|
||||
tools: openaiTools,
|
||||
...(openaiToolChoice && { tool_choice: openaiToolChoice }),
|
||||
}),
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
...(options.temperatureOverride !== undefined && {
|
||||
temperature: options.temperatureOverride,
|
||||
}),
|
||||
},
|
||||
{
|
||||
signal,
|
||||
},
|
||||
)
|
||||
|
||||
// 7. Convert OpenAI stream to Anthropic events, then process into
|
||||
// AssistantMessage + StreamEvent (matching the Anthropic path behavior)
|
||||
const adaptedStream = adaptOpenAIStreamToAnthropic(stream, openaiModel)
|
||||
|
||||
// Accumulate content blocks and usage, same as the Anthropic path in claude.ts
|
||||
const contentBlocks: Record<number, any> = {}
|
||||
let partialMessage: any = undefined
|
||||
let usage = {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
}
|
||||
let ttftMs = 0
|
||||
const start = Date.now()
|
||||
|
||||
for await (const event of adaptedStream) {
|
||||
switch (event.type) {
|
||||
case 'message_start': {
|
||||
partialMessage = (event as any).message
|
||||
ttftMs = Date.now() - start
|
||||
if ((event as any).message?.usage) {
|
||||
usage = {
|
||||
...usage,
|
||||
...((event as any).message.usage),
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'content_block_start': {
|
||||
const idx = (event as any).index
|
||||
const cb = (event as any).content_block
|
||||
if (cb.type === 'tool_use') {
|
||||
contentBlocks[idx] = { ...cb, input: '' }
|
||||
} else if (cb.type === 'text') {
|
||||
contentBlocks[idx] = { ...cb, text: '' }
|
||||
} else if (cb.type === 'thinking') {
|
||||
contentBlocks[idx] = { ...cb, thinking: '', signature: '' }
|
||||
} else {
|
||||
contentBlocks[idx] = { ...cb }
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'content_block_delta': {
|
||||
const idx = (event as any).index
|
||||
const delta = (event as any).delta
|
||||
const block = contentBlocks[idx]
|
||||
if (!block) break
|
||||
if (delta.type === 'text_delta') {
|
||||
block.text = (block.text || '') + delta.text
|
||||
} else if (delta.type === 'input_json_delta') {
|
||||
block.input = (block.input || '') + delta.partial_json
|
||||
} else if (delta.type === 'thinking_delta') {
|
||||
block.thinking = (block.thinking || '') + delta.thinking
|
||||
} else if (delta.type === 'signature_delta') {
|
||||
block.signature = delta.signature
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'content_block_stop': {
|
||||
const idx = (event as any).index
|
||||
const block = contentBlocks[idx]
|
||||
if (!block || !partialMessage) break
|
||||
|
||||
const m: AssistantMessage = {
|
||||
message: {
|
||||
...partialMessage,
|
||||
content: normalizeContentFromAPI(
|
||||
[block],
|
||||
tools,
|
||||
options.agentId,
|
||||
),
|
||||
},
|
||||
requestId: undefined,
|
||||
type: 'assistant',
|
||||
uuid: randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
yield m
|
||||
break
|
||||
}
|
||||
case 'message_delta': {
|
||||
const deltaUsage = (event as any).usage
|
||||
if (deltaUsage) {
|
||||
usage = { ...usage, ...deltaUsage }
|
||||
}
|
||||
// Update the stop_reason on the last yielded message
|
||||
// (we don't have a reference here, but the consumer handles this)
|
||||
break
|
||||
}
|
||||
case 'message_stop':
|
||||
break
|
||||
}
|
||||
|
||||
// Also yield as StreamEvent for real-time display (matching Anthropic path)
|
||||
yield {
|
||||
type: 'stream_event',
|
||||
event,
|
||||
...(event.type === 'message_start' ? { ttftMs } : undefined),
|
||||
} as StreamEvent
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
logForDebugging(`[OpenAI] Error: ${errorMessage}`, { level: 'error' })
|
||||
yield createAssistantAPIErrorMessage({
|
||||
content: `API Error: ${errorMessage}`,
|
||||
apiError: 'api_error',
|
||||
error: error instanceof Error ? error : new Error(String(error)),
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user