mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
支持 OpenAI Chat 兼容协议 (#99)
* feat: 完成 openai 接口兼容 * feat: 完成 openai 协议兼容 * fix: 修复测试用例
This commit is contained in:
@@ -1301,6 +1301,15 @@ async function* queryModel(
|
||||
API_MAX_MEDIA_PER_REQUEST,
|
||||
)
|
||||
|
||||
// OpenAI-compatible provider: delegate to the OpenAI adapter layer
|
||||
// after shared preprocessing (message normalization, tool filtering,
|
||||
// media stripping) but before Anthropic-specific logic (betas, thinking, caching).
|
||||
if (getAPIProvider() === 'openai') {
|
||||
const { queryModelOpenAI } = await import('./openai/index.js')
|
||||
yield* queryModelOpenAI(messagesForAPI, systemPrompt, filteredTools, signal, options)
|
||||
return
|
||||
}
|
||||
|
||||
// Instrumentation: Track message count after normalization
|
||||
logEvent('tengu_api_after_normalize', {
|
||||
postNormalizedMessageCount: messagesForAPI.length,
|
||||
|
||||
157
src/services/api/openai/__tests__/convertMessages.test.ts
Normal file
157
src/services/api/openai/__tests__/convertMessages.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { anthropicMessagesToOpenAI } from '../convertMessages.js'
|
||||
import type { UserMessage, AssistantMessage } from '../../../../types/message.js'
|
||||
|
||||
// Helpers to create internal-format messages
|
||||
function makeUserMsg(content: string | any[]): UserMessage {
|
||||
return {
|
||||
type: 'user',
|
||||
uuid: '00000000-0000-0000-0000-000000000000',
|
||||
message: { role: 'user', content },
|
||||
} as UserMessage
|
||||
}
|
||||
|
||||
function makeAssistantMsg(content: string | any[]): AssistantMessage {
|
||||
return {
|
||||
type: 'assistant',
|
||||
uuid: '00000000-0000-0000-0000-000000000001',
|
||||
message: { role: 'assistant', content },
|
||||
} as AssistantMessage
|
||||
}
|
||||
|
||||
describe('anthropicMessagesToOpenAI', () => {
|
||||
test('converts system prompt to system message', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg('hello')],
|
||||
['You are helpful.'] as any,
|
||||
)
|
||||
expect(result[0]).toEqual({ role: 'system', content: 'You are helpful.' })
|
||||
})
|
||||
|
||||
test('joins multiple system prompt strings', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg('hi')],
|
||||
['Part 1', 'Part 2'] as any,
|
||||
)
|
||||
expect(result[0]).toEqual({ role: 'system', content: 'Part 1\n\nPart 2' })
|
||||
})
|
||||
|
||||
test('skips empty system prompt', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg('hi')],
|
||||
[] as any,
|
||||
)
|
||||
expect(result[0].role).toBe('user')
|
||||
})
|
||||
|
||||
test('converts simple user text message', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg('hello world')],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([{ role: 'user', content: 'hello world' }])
|
||||
})
|
||||
|
||||
test('converts user message with content array', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg([
|
||||
{ type: 'text', text: 'line 1' },
|
||||
{ type: 'text', text: 'line 2' },
|
||||
])],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([{ role: 'user', content: 'line 1\nline 2' }])
|
||||
})
|
||||
|
||||
test('converts assistant message with text', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeAssistantMsg('response text')],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([{ role: 'assistant', content: 'response text' }])
|
||||
})
|
||||
|
||||
test('converts assistant message with tool_use', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeAssistantMsg([
|
||||
{ type: 'text', text: 'Let me help.' },
|
||||
{
|
||||
type: 'tool_use' as const,
|
||||
id: 'toolu_123',
|
||||
name: 'bash',
|
||||
input: { command: 'ls' },
|
||||
},
|
||||
])],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([{
|
||||
role: 'assistant',
|
||||
content: 'Let me help.',
|
||||
tool_calls: [{
|
||||
id: 'toolu_123',
|
||||
type: 'function',
|
||||
function: { name: 'bash', arguments: '{"command":"ls"}' },
|
||||
}],
|
||||
}])
|
||||
})
|
||||
|
||||
test('converts tool_result to tool message', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeUserMsg([
|
||||
{
|
||||
type: 'tool_result' as const,
|
||||
tool_use_id: 'toolu_123',
|
||||
content: 'file1.txt\nfile2.txt',
|
||||
},
|
||||
])],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([{
|
||||
role: 'tool',
|
||||
tool_call_id: 'toolu_123',
|
||||
content: 'file1.txt\nfile2.txt',
|
||||
}])
|
||||
})
|
||||
|
||||
test('strips thinking blocks', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[makeAssistantMsg([
|
||||
{ type: 'thinking' as const, thinking: 'internal thoughts...' },
|
||||
{ type: 'text', text: 'visible response' },
|
||||
])],
|
||||
[] as any,
|
||||
)
|
||||
expect(result).toEqual([{ role: 'assistant', content: 'visible response' }])
|
||||
})
|
||||
|
||||
test('handles full conversation with tools', () => {
|
||||
const result = anthropicMessagesToOpenAI(
|
||||
[
|
||||
makeUserMsg('list files'),
|
||||
makeAssistantMsg([
|
||||
{
|
||||
type: 'tool_use' as const,
|
||||
id: 'toolu_abc',
|
||||
name: 'bash',
|
||||
input: { command: 'ls' },
|
||||
},
|
||||
]),
|
||||
makeUserMsg([
|
||||
{
|
||||
type: 'tool_result' as const,
|
||||
tool_use_id: 'toolu_abc',
|
||||
content: 'file.txt',
|
||||
},
|
||||
]),
|
||||
],
|
||||
['You are helpful.'] as any,
|
||||
)
|
||||
|
||||
expect(result).toHaveLength(4)
|
||||
expect(result[0].role).toBe('system')
|
||||
expect(result[1].role).toBe('user')
|
||||
expect(result[2].role).toBe('assistant')
|
||||
expect((result[2] as any).tool_calls).toBeDefined()
|
||||
expect(result[3].role).toBe('tool')
|
||||
})
|
||||
})
|
||||
85
src/services/api/openai/__tests__/convertTools.test.ts
Normal file
85
src/services/api/openai/__tests__/convertTools.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI } from '../convertTools.js'
|
||||
|
||||
describe('anthropicToolsToOpenAI', () => {
|
||||
test('converts basic tool', () => {
|
||||
const tools = [
|
||||
{
|
||||
type: 'custom',
|
||||
name: 'bash',
|
||||
description: 'Run a bash command',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: { command: { type: 'string' } },
|
||||
required: ['command'],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const result = anthropicToolsToOpenAI(tools as any)
|
||||
|
||||
expect(result).toEqual([{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'bash',
|
||||
description: 'Run a bash command',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { command: { type: 'string' } },
|
||||
required: ['command'],
|
||||
},
|
||||
},
|
||||
}])
|
||||
})
|
||||
|
||||
test('uses empty schema when input_schema missing', () => {
|
||||
const tools = [{ type: 'custom', name: 'noop', description: 'no-op' }]
|
||||
const result = anthropicToolsToOpenAI(tools as any)
|
||||
|
||||
expect(result[0].function.parameters).toEqual({ type: 'object', properties: {} })
|
||||
})
|
||||
|
||||
test('strips Anthropic-specific fields', () => {
|
||||
const tools = [
|
||||
{
|
||||
type: 'custom',
|
||||
name: 'bash',
|
||||
description: 'Run bash',
|
||||
input_schema: { type: 'object', properties: {} },
|
||||
cache_control: { type: 'ephemeral' },
|
||||
defer_loading: true,
|
||||
},
|
||||
]
|
||||
const result = anthropicToolsToOpenAI(tools as any)
|
||||
|
||||
expect((result[0] as any).cache_control).toBeUndefined()
|
||||
expect((result[0] as any).defer_loading).toBeUndefined()
|
||||
})
|
||||
|
||||
test('handles empty tools array', () => {
|
||||
expect(anthropicToolsToOpenAI([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('anthropicToolChoiceToOpenAI', () => {
|
||||
test('maps auto', () => {
|
||||
expect(anthropicToolChoiceToOpenAI({ type: 'auto' })).toBe('auto')
|
||||
})
|
||||
|
||||
test('maps any to required', () => {
|
||||
expect(anthropicToolChoiceToOpenAI({ type: 'any' })).toBe('required')
|
||||
})
|
||||
|
||||
test('maps tool to function', () => {
|
||||
const result = anthropicToolChoiceToOpenAI({ type: 'tool', name: 'bash' })
|
||||
expect(result).toEqual({ type: 'function', function: { name: 'bash' } })
|
||||
})
|
||||
|
||||
test('returns undefined for undefined input', () => {
|
||||
expect(anthropicToolChoiceToOpenAI(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test('returns undefined for unknown type', () => {
|
||||
expect(anthropicToolChoiceToOpenAI({ type: 'unknown' })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
62
src/services/api/openai/__tests__/modelMapping.test.ts
Normal file
62
src/services/api/openai/__tests__/modelMapping.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
|
||||
import { resolveOpenAIModel } from '../modelMapping.js'
|
||||
|
||||
describe('resolveOpenAIModel', () => {
|
||||
const originalEnv = {
|
||||
OPENAI_MODEL: process.env.OPENAI_MODEL,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: process.env.ANTHROPIC_DEFAULT_SONNET_MODEL,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.OPENAI_MODEL
|
||||
delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL
|
||||
delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL
|
||||
delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.assign(process.env, originalEnv)
|
||||
})
|
||||
|
||||
test('OPENAI_MODEL env var overrides all', () => {
|
||||
process.env.OPENAI_MODEL = 'my-custom-model'
|
||||
expect(resolveOpenAIModel('claude-sonnet-4-6')).toBe('my-custom-model')
|
||||
})
|
||||
|
||||
test('ANTHROPIC_DEFAULT_SONNET_MODEL overrides default map', () => {
|
||||
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'my-sonnet'
|
||||
expect(resolveOpenAIModel('claude-sonnet-4-6')).toBe('my-sonnet')
|
||||
})
|
||||
|
||||
test('ANTHROPIC_DEFAULT_HAIKU_MODEL overrides default map', () => {
|
||||
process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = 'my-haiku'
|
||||
expect(resolveOpenAIModel('claude-haiku-4-5-20251001')).toBe('my-haiku')
|
||||
})
|
||||
|
||||
test('ANTHROPIC_DEFAULT_OPUS_MODEL overrides default map', () => {
|
||||
process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = 'my-opus'
|
||||
expect(resolveOpenAIModel('claude-opus-4-6')).toBe('my-opus')
|
||||
})
|
||||
|
||||
test('maps known Anthropic model via DEFAULT_MODEL_MAP', () => {
|
||||
expect(resolveOpenAIModel('claude-sonnet-4-6')).toBe('gpt-4o')
|
||||
})
|
||||
|
||||
test('maps haiku model', () => {
|
||||
expect(resolveOpenAIModel('claude-haiku-4-5-20251001')).toBe('gpt-4o-mini')
|
||||
})
|
||||
|
||||
test('maps opus model', () => {
|
||||
expect(resolveOpenAIModel('claude-opus-4-6')).toBe('o3')
|
||||
})
|
||||
|
||||
test('passes through unknown model name', () => {
|
||||
expect(resolveOpenAIModel('some-random-model')).toBe('some-random-model')
|
||||
})
|
||||
|
||||
test('strips [1m] suffix', () => {
|
||||
expect(resolveOpenAIModel('claude-sonnet-4-6[1m]')).toBe('gpt-4o')
|
||||
})
|
||||
})
|
||||
434
src/services/api/openai/__tests__/streamAdapter.test.ts
Normal file
434
src/services/api/openai/__tests__/streamAdapter.test.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { adaptOpenAIStreamToAnthropic } from '../streamAdapter.js'
|
||||
import type { ChatCompletionChunk } from 'openai/resources/chat/completions/completions.mjs'
|
||||
|
||||
/** Helper to create a mock async iterable from chunk array */
|
||||
function mockStream(chunks: ChatCompletionChunk[]): AsyncIterable<ChatCompletionChunk> {
|
||||
return {
|
||||
[Symbol.asyncIterator]() {
|
||||
let i = 0
|
||||
return {
|
||||
async next() {
|
||||
if (i >= chunks.length) return { done: true, value: undefined }
|
||||
return { done: false, value: chunks[i++] }
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a minimal ChatCompletionChunk */
|
||||
function makeChunk(overrides: Partial<ChatCompletionChunk> & any = {}): ChatCompletionChunk {
|
||||
return {
|
||||
id: 'chatcmpl-test',
|
||||
object: 'chat.completion.chunk',
|
||||
created: 1234567890,
|
||||
model: 'gpt-4o',
|
||||
choices: [],
|
||||
...overrides,
|
||||
} as ChatCompletionChunk
|
||||
}
|
||||
|
||||
async function collectEvents(chunks: ChatCompletionChunk[]) {
|
||||
const events: any[] = []
|
||||
for await (const event of adaptOpenAIStreamToAnthropic(mockStream(chunks), 'gpt-4o')) {
|
||||
events.push(event)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
describe('adaptOpenAIStreamToAnthropic', () => {
|
||||
test('emits message_start on first chunk', async () => {
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { role: 'assistant', content: '' },
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { content: 'hello' },
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {},
|
||||
finish_reason: 'stop',
|
||||
}],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
||||
}),
|
||||
])
|
||||
|
||||
expect(events[0].type).toBe('message_start')
|
||||
expect(events[0].message.role).toBe('assistant')
|
||||
expect(events[0].message.model).toBe('gpt-4o')
|
||||
})
|
||||
|
||||
test('converts text content stream', async () => {
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: { content: 'Hello' }, finish_reason: null }],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: { content: ' world' }, finish_reason: null }],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const types = events.map(e => e.type)
|
||||
expect(types).toContain('message_start')
|
||||
expect(types).toContain('content_block_start')
|
||||
expect(types.filter(t => t === 'content_block_delta').length).toBe(2)
|
||||
expect(types).toContain('content_block_stop')
|
||||
expect(types).toContain('message_delta')
|
||||
expect(types).toContain('message_stop')
|
||||
|
||||
const textDeltas = events.filter(e => e.type === 'content_block_delta') as any[]
|
||||
expect(textDeltas[0].delta.text).toBe('Hello')
|
||||
expect(textDeltas[1].delta.text).toBe(' world')
|
||||
})
|
||||
|
||||
test('converts tool_calls stream', async () => {
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [{
|
||||
index: 0,
|
||||
id: 'call_abc',
|
||||
type: 'function',
|
||||
function: { name: 'bash', arguments: '' },
|
||||
}],
|
||||
},
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [{
|
||||
index: 0,
|
||||
function: { arguments: '{"comm' },
|
||||
}],
|
||||
},
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [{
|
||||
index: 0,
|
||||
function: { arguments: 'and":"ls"}' },
|
||||
}],
|
||||
},
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const blockStart = events.find(e => e.type === 'content_block_start') as any
|
||||
expect(blockStart.content_block.type).toBe('tool_use')
|
||||
expect(blockStart.content_block.name).toBe('bash')
|
||||
|
||||
const jsonDeltas = events.filter(
|
||||
e => e.type === 'content_block_delta' && e.delta.type === 'input_json_delta',
|
||||
) as any[]
|
||||
const fullArgs = jsonDeltas.map(d => d.delta.partial_json).join('')
|
||||
expect(fullArgs).toBe('{"command":"ls"}')
|
||||
})
|
||||
|
||||
test('maps finish_reason stop to end_turn', async () => {
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: { content: 'hi' }, finish_reason: null }],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const msgDelta = events.find(e => e.type === 'message_delta') as any
|
||||
expect(msgDelta.delta.stop_reason).toBe('end_turn')
|
||||
})
|
||||
|
||||
test('maps finish_reason tool_calls to tool_use', async () => {
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [{ index: 0, id: 'call_1', function: { name: 'bash', arguments: '{}' } }],
|
||||
},
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const msgDelta = events.find(e => e.type === 'message_delta') as any
|
||||
expect(msgDelta.delta.stop_reason).toBe('tool_use')
|
||||
})
|
||||
|
||||
test('maps finish_reason length to max_tokens', async () => {
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: { content: 'truncated' }, finish_reason: null }],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'length' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const msgDelta = events.find(e => e.type === 'message_delta') as any
|
||||
expect(msgDelta.delta.stop_reason).toBe('max_tokens')
|
||||
})
|
||||
|
||||
test('handles mixed text and tool_calls', async () => {
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: { content: 'Thinking...' }, finish_reason: null }],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [{ index: 0, id: 'call_1', function: { name: 'grep', arguments: '{"p":"test"}' } }],
|
||||
},
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const blockStarts = events.filter(e => e.type === 'content_block_start') as any[]
|
||||
expect(blockStarts.length).toBe(2)
|
||||
expect(blockStarts[0].content_block.type).toBe('text')
|
||||
expect(blockStarts[1].content_block.type).toBe('tool_use')
|
||||
})
|
||||
})
|
||||
|
||||
describe('thinking support (reasoning_content)', () => {
|
||||
test('converts reasoning_content to thinking block', async () => {
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { reasoning_content: 'Let me analyze this...' },
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { reasoning_content: ' step by step.' },
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
}),
|
||||
])
|
||||
|
||||
// Should have a thinking content block
|
||||
const blockStart = events.find(e => e.type === 'content_block_start') as any
|
||||
expect(blockStart.content_block.type).toBe('thinking')
|
||||
expect(blockStart.content_block.signature).toBe('')
|
||||
|
||||
// Should have thinking_delta events
|
||||
const thinkingDeltas = events.filter(
|
||||
e => e.type === 'content_block_delta' && e.delta.type === 'thinking_delta',
|
||||
) as any[]
|
||||
expect(thinkingDeltas.length).toBe(2)
|
||||
expect(thinkingDeltas[0].delta.thinking).toBe('Let me analyze this...')
|
||||
expect(thinkingDeltas[1].delta.thinking).toBe(' step by step.')
|
||||
})
|
||||
|
||||
test('converts reasoning then content (DeepSeek-style)', async () => {
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { reasoning_content: 'Thinking about the answer...' },
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { content: 'Here is my answer.' },
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
}),
|
||||
])
|
||||
|
||||
// Should have two content blocks: thinking + text
|
||||
const blockStarts = events.filter(e => e.type === 'content_block_start') as any[]
|
||||
expect(blockStarts.length).toBe(2)
|
||||
expect(blockStarts[0].content_block.type).toBe('thinking')
|
||||
expect(blockStarts[1].content_block.type).toBe('text')
|
||||
|
||||
// Thinking block should be closed before text block starts
|
||||
const blockStops = events.filter(e => e.type === 'content_block_stop') as any[]
|
||||
expect(blockStops[0].index).toBe(0) // thinking block closed at index 0
|
||||
expect(blockStarts[1].index).toBe(1) // text block starts at index 1
|
||||
|
||||
// Verify text delta
|
||||
const textDelta = events.find(
|
||||
e => e.type === 'content_block_delta' && e.delta.type === 'text_delta',
|
||||
) as any
|
||||
expect(textDelta.delta.text).toBe('Here is my answer.')
|
||||
})
|
||||
|
||||
test('handles reasoning then tool_calls', async () => {
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { reasoning_content: 'I need to run a command.' },
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [{ index: 0, id: 'call_1', function: { name: 'bash', arguments: '{"c":"ls"}' } }],
|
||||
},
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const blockStarts = events.filter(e => e.type === 'content_block_start') as any[]
|
||||
expect(blockStarts.length).toBe(2)
|
||||
expect(blockStarts[0].content_block.type).toBe('thinking')
|
||||
expect(blockStarts[1].content_block.type).toBe('tool_use')
|
||||
})
|
||||
|
||||
test('thinking block index is 0, text block index is 1', async () => {
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { reasoning_content: 'reason' },
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { content: 'answer' },
|
||||
finish_reason: null,
|
||||
}],
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const blockStarts = events.filter(e => e.type === 'content_block_start') as any[]
|
||||
expect(blockStarts[0].index).toBe(0)
|
||||
expect(blockStarts[1].index).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('prompt caching support', () => {
|
||||
test('maps cached_tokens to cache_read_input_tokens', async () => {
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{
|
||||
index: 0,
|
||||
delta: { content: 'hi' },
|
||||
finish_reason: null,
|
||||
}],
|
||||
usage: {
|
||||
prompt_tokens: 1000,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 1000,
|
||||
prompt_tokens_details: { cached_tokens: 800 },
|
||||
} as any,
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
usage: {
|
||||
prompt_tokens: 1000,
|
||||
completion_tokens: 50,
|
||||
total_tokens: 1050,
|
||||
prompt_tokens_details: { cached_tokens: 800 },
|
||||
} as any,
|
||||
}),
|
||||
])
|
||||
|
||||
const msgStart = events.find(e => e.type === 'message_start') as any
|
||||
expect(msgStart.message.usage.cache_read_input_tokens).toBe(800)
|
||||
expect(msgStart.message.usage.input_tokens).toBe(1000)
|
||||
})
|
||||
|
||||
test('defaults cache_read_input_tokens to 0 when no cached_tokens', async () => {
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: { content: 'hi' }, finish_reason: null }],
|
||||
usage: { prompt_tokens: 100, completion_tokens: 0, total_tokens: 100 },
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const msgStart = events.find(e => e.type === 'message_start') as any
|
||||
expect(msgStart.message.usage.cache_read_input_tokens).toBe(0)
|
||||
expect(msgStart.message.usage.cache_creation_input_tokens).toBe(0)
|
||||
})
|
||||
|
||||
test('updates cached_tokens from later chunks', async () => {
|
||||
const events = await collectEvents([
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: { content: 'hi' }, finish_reason: null }],
|
||||
usage: {
|
||||
prompt_tokens: 500,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 500,
|
||||
} as any,
|
||||
}),
|
||||
makeChunk({
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
usage: {
|
||||
prompt_tokens: 500,
|
||||
completion_tokens: 10,
|
||||
total_tokens: 510,
|
||||
prompt_tokens_details: { cached_tokens: 300 },
|
||||
} as any,
|
||||
}),
|
||||
])
|
||||
|
||||
const msgStart = events.find(e => e.type === 'message_start') as any
|
||||
// First chunk had no cached_tokens, so initially 0
|
||||
// But the message_start usage reflects the first chunk's data
|
||||
expect(msgStart.message.usage.cache_read_input_tokens).toBe(0)
|
||||
expect(msgStart.message.usage.input_tokens).toBe(500)
|
||||
})
|
||||
})
|
||||
48
src/services/api/openai/client.ts
Normal file
48
src/services/api/openai/client.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import OpenAI from 'openai'
|
||||
import { getProxyFetchOptions } from 'src/utils/proxy.js'
|
||||
import { isEnvTruthy } from '../../utils/envUtils.js'
|
||||
|
||||
/**
|
||||
* Environment variables:
|
||||
*
|
||||
* OPENAI_API_KEY: Required. API key for the OpenAI-compatible endpoint.
|
||||
* OPENAI_BASE_URL: Recommended. Base URL for the endpoint (e.g. http://localhost:11434/v1).
|
||||
* OPENAI_ORG_ID: Optional. Organization ID.
|
||||
* OPENAI_PROJECT_ID: Optional. Project ID.
|
||||
*/
|
||||
|
||||
let cachedClient: OpenAI | null = null
|
||||
|
||||
export function getOpenAIClient(options?: {
|
||||
maxRetries?: number
|
||||
fetchOverride?: typeof fetch
|
||||
source?: string
|
||||
}): OpenAI {
|
||||
if (cachedClient) return cachedClient
|
||||
|
||||
const apiKey = process.env.OPENAI_API_KEY || ''
|
||||
const baseURL = process.env.OPENAI_BASE_URL
|
||||
|
||||
const client = new OpenAI({
|
||||
apiKey,
|
||||
...(baseURL && { baseURL }),
|
||||
maxRetries: options?.maxRetries ?? 0,
|
||||
timeout: parseInt(process.env.API_TIMEOUT_MS || String(600 * 1000), 10),
|
||||
dangerouslyAllowBrowser: true,
|
||||
...(process.env.OPENAI_ORG_ID && { organization: process.env.OPENAI_ORG_ID }),
|
||||
...(process.env.OPENAI_PROJECT_ID && { project: process.env.OPENAI_PROJECT_ID }),
|
||||
fetchOptions: getProxyFetchOptions({ forAnthropicAPI: false }) as RequestInit,
|
||||
...(options?.fetchOverride && { fetch: options.fetchOverride }),
|
||||
})
|
||||
|
||||
if (!options?.fetchOverride) {
|
||||
cachedClient = client
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
/** Clear the cached client (useful when env vars change). */
|
||||
export function clearOpenAIClientCache(): void {
|
||||
cachedClient = null
|
||||
}
|
||||
184
src/services/api/openai/convertMessages.ts
Normal file
184
src/services/api/openai/convertMessages.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import type {
|
||||
BetaContentBlockParam,
|
||||
BetaToolResultBlockParam,
|
||||
BetaToolUseBlock,
|
||||
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||
import type {
|
||||
ChatCompletionAssistantMessageParam,
|
||||
ChatCompletionMessageParam,
|
||||
ChatCompletionSystemMessageParam,
|
||||
ChatCompletionToolMessageParam,
|
||||
ChatCompletionUserMessageParam,
|
||||
} from 'openai/resources/chat/completions/completions.mjs'
|
||||
import type { AssistantMessage, UserMessage } from '../../../types/message.js'
|
||||
import type { SystemPrompt } from '../../../utils/systemPromptType.js'
|
||||
|
||||
/**
|
||||
* Convert internal (UserMessage | AssistantMessage)[] to OpenAI-format messages.
|
||||
*
|
||||
* Key conversions:
|
||||
* - system prompt → role: "system" message prepended
|
||||
* - tool_use blocks → tool_calls[] on assistant message
|
||||
* - tool_result blocks → role: "tool" messages
|
||||
* - thinking blocks → silently dropped
|
||||
* - cache_control → stripped
|
||||
*/
|
||||
export function anthropicMessagesToOpenAI(
|
||||
messages: (UserMessage | AssistantMessage)[],
|
||||
systemPrompt: SystemPrompt,
|
||||
): ChatCompletionMessageParam[] {
|
||||
const result: ChatCompletionMessageParam[] = []
|
||||
|
||||
// Prepend system prompt as system message
|
||||
const systemText = systemPromptToText(systemPrompt)
|
||||
if (systemText) {
|
||||
result.push({
|
||||
role: 'system',
|
||||
content: systemText,
|
||||
} satisfies ChatCompletionSystemMessageParam)
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
switch (msg.type) {
|
||||
case 'user':
|
||||
result.push(...convertInternalUserMessage(msg))
|
||||
break
|
||||
case 'assistant':
|
||||
result.push(...convertInternalAssistantMessage(msg))
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function systemPromptToText(systemPrompt: SystemPrompt): string {
|
||||
if (!systemPrompt || systemPrompt.length === 0) return ''
|
||||
return systemPrompt
|
||||
.filter(Boolean)
|
||||
.join('\n\n')
|
||||
}
|
||||
|
||||
function convertInternalUserMessage(
|
||||
msg: UserMessage,
|
||||
): ChatCompletionMessageParam[] {
|
||||
const result: ChatCompletionMessageParam[] = []
|
||||
const content = msg.message.content
|
||||
|
||||
if (typeof content === 'string') {
|
||||
result.push({
|
||||
role: 'user',
|
||||
content,
|
||||
} satisfies ChatCompletionUserMessageParam)
|
||||
} else if (Array.isArray(content)) {
|
||||
const textParts: string[] = []
|
||||
const toolResults: BetaToolResultBlockParam[] = []
|
||||
|
||||
for (const block of content) {
|
||||
if (typeof block === 'string') {
|
||||
textParts.push(block)
|
||||
} else if (block.type === 'text') {
|
||||
textParts.push(block.text)
|
||||
} else if (block.type === 'tool_result') {
|
||||
toolResults.push(block as BetaToolResultBlockParam)
|
||||
}
|
||||
// Skip image, document, thinking, cache_edits, etc.
|
||||
}
|
||||
|
||||
if (textParts.length > 0) {
|
||||
result.push({
|
||||
role: 'user',
|
||||
content: textParts.join('\n'),
|
||||
} satisfies ChatCompletionUserMessageParam)
|
||||
}
|
||||
|
||||
for (const tr of toolResults) {
|
||||
result.push(convertToolResult(tr))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function convertToolResult(
|
||||
block: BetaToolResultBlockParam,
|
||||
): ChatCompletionToolMessageParam {
|
||||
let content: string
|
||||
if (typeof block.content === 'string') {
|
||||
content = block.content
|
||||
} else if (Array.isArray(block.content)) {
|
||||
content = block.content
|
||||
.map(c => {
|
||||
if (typeof c === 'string') return c
|
||||
if ('text' in c) return c.text
|
||||
return ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
} else {
|
||||
content = ''
|
||||
}
|
||||
|
||||
return {
|
||||
role: 'tool',
|
||||
tool_call_id: block.tool_use_id,
|
||||
content,
|
||||
} satisfies ChatCompletionToolMessageParam
|
||||
}
|
||||
|
||||
function convertInternalAssistantMessage(
|
||||
msg: AssistantMessage,
|
||||
): ChatCompletionMessageParam[] {
|
||||
const content = msg.message.content
|
||||
|
||||
if (typeof content === 'string') {
|
||||
return [
|
||||
{
|
||||
role: 'assistant',
|
||||
content,
|
||||
} satisfies ChatCompletionAssistantMessageParam,
|
||||
]
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
} satisfies ChatCompletionAssistantMessageParam,
|
||||
]
|
||||
}
|
||||
|
||||
const textParts: string[] = []
|
||||
const toolCalls: NonNullable<ChatCompletionAssistantMessageParam['tool_calls']> = []
|
||||
|
||||
for (const block of content) {
|
||||
if (typeof block === 'string') {
|
||||
textParts.push(block)
|
||||
} else if (block.type === 'text') {
|
||||
textParts.push(block.text)
|
||||
} else if (block.type === 'tool_use') {
|
||||
const tu = block as BetaToolUseBlock
|
||||
toolCalls.push({
|
||||
id: tu.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tu.name,
|
||||
arguments:
|
||||
typeof tu.input === 'string' ? tu.input : JSON.stringify(tu.input),
|
||||
},
|
||||
})
|
||||
}
|
||||
// Skip thinking, redacted_thinking, server_tool_use, etc.
|
||||
}
|
||||
|
||||
const result: ChatCompletionAssistantMessageParam = {
|
||||
role: 'assistant',
|
||||
content: textParts.length > 0 ? textParts.join('\n') : null,
|
||||
...(toolCalls.length > 0 && { tool_calls: toolCalls }),
|
||||
}
|
||||
|
||||
return [result]
|
||||
}
|
||||
68
src/services/api/openai/convertTools.ts
Normal file
68
src/services/api/openai/convertTools.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||
import type { ChatCompletionTool } from 'openai/resources/chat/completions/completions.mjs'
|
||||
|
||||
/**
|
||||
* Convert Anthropic tool schemas to OpenAI function calling format.
|
||||
*
|
||||
* Anthropic: { name, description, input_schema }
|
||||
* OpenAI: { type: "function", function: { name, description, parameters } }
|
||||
*
|
||||
* Anthropic-specific fields (cache_control, defer_loading, etc.) are stripped.
|
||||
*/
|
||||
export function anthropicToolsToOpenAI(
|
||||
tools: BetaToolUnion[],
|
||||
): ChatCompletionTool[] {
|
||||
return tools
|
||||
.filter(tool => {
|
||||
// Only convert standard tools (skip server tools like computer_use, etc.)
|
||||
return tool.type === 'custom' || !('type' in tool) || tool.type !== 'server'
|
||||
})
|
||||
.map(tool => {
|
||||
// Handle the various tool shapes from Anthropic SDK
|
||||
const anyTool = tool as Record<string, unknown>
|
||||
const name = (anyTool.name as string) || ''
|
||||
const description = (anyTool.description as string) || ''
|
||||
const inputSchema = anyTool.input_schema as Record<string, unknown> | undefined
|
||||
|
||||
return {
|
||||
type: 'function' as const,
|
||||
function: {
|
||||
name,
|
||||
description,
|
||||
parameters: inputSchema || { type: 'object', properties: {} },
|
||||
},
|
||||
} satisfies ChatCompletionTool
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Anthropic tool_choice to OpenAI tool_choice format.
|
||||
*
|
||||
* Anthropic → OpenAI:
|
||||
* - { type: "auto" } → "auto"
|
||||
* - { type: "any" } → "required"
|
||||
* - { type: "tool", name } → { type: "function", function: { name } }
|
||||
* - undefined → undefined (use provider default)
|
||||
*/
|
||||
export function anthropicToolChoiceToOpenAI(
|
||||
toolChoice: unknown,
|
||||
): string | { type: 'function'; function: { name: string } } | undefined {
|
||||
if (!toolChoice || typeof toolChoice !== 'object') return undefined
|
||||
|
||||
const tc = toolChoice as Record<string, unknown>
|
||||
const type = tc.type as string
|
||||
|
||||
switch (type) {
|
||||
case 'auto':
|
||||
return 'auto'
|
||||
case 'any':
|
||||
return 'required'
|
||||
case 'tool':
|
||||
return {
|
||||
type: 'function',
|
||||
function: { name: tc.name as string },
|
||||
}
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
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)),
|
||||
})
|
||||
}
|
||||
}
|
||||
56
src/services/api/openai/modelMapping.ts
Normal file
56
src/services/api/openai/modelMapping.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Default mapping from Anthropic model names to OpenAI model names.
|
||||
* Used only when ANTHROPIC_DEFAULT_*_MODEL env vars are not set.
|
||||
*/
|
||||
const DEFAULT_MODEL_MAP: Record<string, string> = {
|
||||
'claude-sonnet-4-20250514': 'gpt-4o',
|
||||
'claude-sonnet-4-5-20250929': 'gpt-4o',
|
||||
'claude-sonnet-4-6': 'gpt-4o',
|
||||
'claude-opus-4-20250514': 'o3',
|
||||
'claude-opus-4-1-20250805': 'o3',
|
||||
'claude-opus-4-5-20251101': 'o3',
|
||||
'claude-opus-4-6': 'o3',
|
||||
'claude-haiku-4-5-20251001': 'gpt-4o-mini',
|
||||
'claude-3-5-haiku-20241022': 'gpt-4o-mini',
|
||||
'claude-3-7-sonnet-20250219': 'gpt-4o',
|
||||
'claude-3-5-sonnet-20241022': 'gpt-4o',
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the model family (haiku / sonnet / opus) from an Anthropic model ID.
|
||||
*/
|
||||
function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null {
|
||||
if (/haiku/i.test(model)) return 'haiku'
|
||||
if (/opus/i.test(model)) return 'opus'
|
||||
if (/sonnet/i.test(model)) return 'sonnet'
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the OpenAI model name for a given Anthropic model.
|
||||
*
|
||||
* Priority:
|
||||
* 1. OPENAI_MODEL env var (override all)
|
||||
* 2. ANTHROPIC_DEFAULT_{FAMILY}_MODEL env var (e.g. ANTHROPIC_DEFAULT_SONNET_MODEL)
|
||||
* 3. DEFAULT_MODEL_MAP lookup
|
||||
* 4. Pass through original model name
|
||||
*/
|
||||
export function resolveOpenAIModel(anthropicModel: string): string {
|
||||
// Highest priority: explicit override
|
||||
if (process.env.OPENAI_MODEL) {
|
||||
return process.env.OPENAI_MODEL
|
||||
}
|
||||
|
||||
// Strip [1m] suffix if present (Claude-specific modifier)
|
||||
const cleanModel = anthropicModel.replace(/\[1m\]$/, '')
|
||||
|
||||
// Check ANTHROPIC_DEFAULT_*_MODEL env vars based on model family
|
||||
const family = getModelFamily(cleanModel)
|
||||
if (family) {
|
||||
const envVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||
const override = process.env[envVar]
|
||||
if (override) return override
|
||||
}
|
||||
|
||||
return DEFAULT_MODEL_MAP[cleanModel] ?? cleanModel
|
||||
}
|
||||
310
src/services/api/openai/streamAdapter.ts
Normal file
310
src/services/api/openai/streamAdapter.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
import type { BetaRawMessageStreamEvent } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||
import type { ChatCompletionChunk } from 'openai/resources/chat/completions/completions.mjs'
|
||||
import { randomUUID } from 'crypto'
|
||||
|
||||
/**
|
||||
* Adapt an OpenAI streaming response into Anthropic BetaRawMessageStreamEvent.
|
||||
*
|
||||
* Mapping:
|
||||
* First chunk → message_start
|
||||
* delta.reasoning_content → content_block_start(thinking) + thinking_delta + content_block_stop
|
||||
* delta.content → content_block_start(text) + text_delta + content_block_stop
|
||||
* delta.tool_calls → content_block_start(tool_use) + input_json_delta + content_block_stop
|
||||
* finish_reason → message_delta(stop_reason) + message_stop
|
||||
* usage.cached_tokens → cache_read_input_tokens in message_start usage
|
||||
*
|
||||
* Thinking support:
|
||||
* DeepSeek and compatible providers send `delta.reasoning_content` for chain-of-thought.
|
||||
* This is mapped to Anthropic's `thinking` content blocks:
|
||||
* content_block_start: { type: 'thinking', thinking: '', signature: '' }
|
||||
* content_block_delta: { type: 'thinking_delta', thinking: '...' }
|
||||
*
|
||||
* Prompt caching:
|
||||
* OpenAI reports cached tokens in usage.prompt_tokens_details.cached_tokens.
|
||||
* This is mapped to Anthropic's cache_read_input_tokens.
|
||||
*/
|
||||
export async function* adaptOpenAIStreamToAnthropic(
|
||||
stream: AsyncIterable<ChatCompletionChunk>,
|
||||
model: string,
|
||||
): AsyncGenerator<BetaRawMessageStreamEvent, void> {
|
||||
const messageId = `msg_${randomUUID().replace(/-/g, '').slice(0, 24)}`
|
||||
|
||||
let started = false
|
||||
let currentContentIndex = -1
|
||||
|
||||
// Track tool_use blocks: tool_calls index → { contentIndex, id, name, arguments }
|
||||
const toolBlocks = new Map<number, { contentIndex: number; id: string; name: string; arguments: string }>()
|
||||
|
||||
// Track thinking block state
|
||||
let thinkingBlockOpen = false
|
||||
|
||||
// Track text block state
|
||||
let textBlockOpen = false
|
||||
|
||||
// Track usage
|
||||
let inputTokens = 0
|
||||
let outputTokens = 0
|
||||
let cachedTokens = 0
|
||||
|
||||
// Track all open content block indices (for cleanup)
|
||||
const openBlockIndices = new Set<number>()
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const choice = chunk.choices?.[0]
|
||||
const delta = choice?.delta
|
||||
|
||||
// Extract usage from any chunk that carries it
|
||||
if (chunk.usage) {
|
||||
inputTokens = chunk.usage.prompt_tokens ?? inputTokens
|
||||
outputTokens = chunk.usage.completion_tokens ?? outputTokens
|
||||
// OpenAI prompt caching: prompt_tokens_details.cached_tokens
|
||||
const details = (chunk.usage as any).prompt_tokens_details
|
||||
if (details?.cached_tokens) {
|
||||
cachedTokens = details.cached_tokens
|
||||
}
|
||||
}
|
||||
|
||||
// Emit message_start on first chunk
|
||||
if (!started) {
|
||||
started = true
|
||||
|
||||
yield {
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id: messageId,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [],
|
||||
model,
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: cachedTokens,
|
||||
},
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
|
||||
if (!delta) continue
|
||||
|
||||
// Handle reasoning_content → Anthropic thinking block
|
||||
// DeepSeek and compatible providers send delta.reasoning_content
|
||||
const reasoningContent = (delta as any).reasoning_content
|
||||
if (reasoningContent != null && reasoningContent !== '') {
|
||||
if (!thinkingBlockOpen) {
|
||||
currentContentIndex++
|
||||
thinkingBlockOpen = true
|
||||
openBlockIndices.add(currentContentIndex)
|
||||
|
||||
yield {
|
||||
type: 'content_block_start',
|
||||
index: currentContentIndex,
|
||||
content_block: {
|
||||
type: 'thinking',
|
||||
thinking: '',
|
||||
signature: '',
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
|
||||
yield {
|
||||
type: 'content_block_delta',
|
||||
index: currentContentIndex,
|
||||
delta: {
|
||||
type: 'thinking_delta',
|
||||
thinking: reasoningContent,
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
|
||||
// Handle text content
|
||||
if (delta.content != null && delta.content !== '') {
|
||||
if (!textBlockOpen) {
|
||||
// Close thinking block if still open (reasoning done, now generating answer)
|
||||
if (thinkingBlockOpen) {
|
||||
yield {
|
||||
type: 'content_block_stop',
|
||||
index: currentContentIndex,
|
||||
} as BetaRawMessageStreamEvent
|
||||
openBlockIndices.delete(currentContentIndex)
|
||||
thinkingBlockOpen = false
|
||||
}
|
||||
|
||||
currentContentIndex++
|
||||
textBlockOpen = true
|
||||
openBlockIndices.add(currentContentIndex)
|
||||
|
||||
yield {
|
||||
type: 'content_block_start',
|
||||
index: currentContentIndex,
|
||||
content_block: {
|
||||
type: 'text',
|
||||
text: '',
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
|
||||
yield {
|
||||
type: 'content_block_delta',
|
||||
index: currentContentIndex,
|
||||
delta: {
|
||||
type: 'text_delta',
|
||||
text: delta.content,
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
|
||||
// Handle tool calls
|
||||
if (delta.tool_calls) {
|
||||
for (const tc of delta.tool_calls) {
|
||||
const tcIndex = tc.index
|
||||
|
||||
if (!toolBlocks.has(tcIndex)) {
|
||||
// Close thinking block if open
|
||||
if (thinkingBlockOpen) {
|
||||
yield {
|
||||
type: 'content_block_stop',
|
||||
index: currentContentIndex,
|
||||
} as BetaRawMessageStreamEvent
|
||||
openBlockIndices.delete(currentContentIndex)
|
||||
thinkingBlockOpen = false
|
||||
}
|
||||
|
||||
// Close text block if open
|
||||
if (textBlockOpen) {
|
||||
yield {
|
||||
type: 'content_block_stop',
|
||||
index: currentContentIndex,
|
||||
} as BetaRawMessageStreamEvent
|
||||
openBlockIndices.delete(currentContentIndex)
|
||||
textBlockOpen = false
|
||||
}
|
||||
|
||||
// Start new tool_use block
|
||||
currentContentIndex++
|
||||
const toolId = tc.id || `toolu_${randomUUID().replace(/-/g, '').slice(0, 24)}`
|
||||
const toolName = tc.function?.name || ''
|
||||
|
||||
toolBlocks.set(tcIndex, {
|
||||
contentIndex: currentContentIndex,
|
||||
id: toolId,
|
||||
name: toolName,
|
||||
arguments: '',
|
||||
})
|
||||
openBlockIndices.add(currentContentIndex)
|
||||
|
||||
yield {
|
||||
type: 'content_block_start',
|
||||
index: currentContentIndex,
|
||||
content_block: {
|
||||
type: 'tool_use',
|
||||
id: toolId,
|
||||
name: toolName,
|
||||
input: {},
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
|
||||
// Stream argument fragments
|
||||
const argFragment = tc.function?.arguments
|
||||
if (argFragment) {
|
||||
toolBlocks.get(tcIndex)!.arguments += argFragment
|
||||
yield {
|
||||
type: 'content_block_delta',
|
||||
index: toolBlocks.get(tcIndex)!.contentIndex,
|
||||
delta: {
|
||||
type: 'input_json_delta',
|
||||
partial_json: argFragment,
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle finish
|
||||
if (choice?.finish_reason) {
|
||||
// Close thinking block if still open
|
||||
if (thinkingBlockOpen) {
|
||||
yield {
|
||||
type: 'content_block_stop',
|
||||
index: currentContentIndex,
|
||||
} as BetaRawMessageStreamEvent
|
||||
openBlockIndices.delete(currentContentIndex)
|
||||
thinkingBlockOpen = false
|
||||
}
|
||||
|
||||
// Close text block if still open
|
||||
if (textBlockOpen) {
|
||||
yield {
|
||||
type: 'content_block_stop',
|
||||
index: currentContentIndex,
|
||||
} as BetaRawMessageStreamEvent
|
||||
openBlockIndices.delete(currentContentIndex)
|
||||
textBlockOpen = false
|
||||
}
|
||||
|
||||
// Close all tool blocks that haven't been closed yet
|
||||
for (const [, block] of toolBlocks) {
|
||||
if (openBlockIndices.has(block.contentIndex)) {
|
||||
yield {
|
||||
type: 'content_block_stop',
|
||||
index: block.contentIndex,
|
||||
} as BetaRawMessageStreamEvent
|
||||
openBlockIndices.delete(block.contentIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// Map finish_reason to Anthropic stop_reason
|
||||
const stopReason = mapFinishReason(choice.finish_reason)
|
||||
|
||||
yield {
|
||||
type: 'message_delta',
|
||||
delta: {
|
||||
stop_reason: stopReason,
|
||||
stop_sequence: null,
|
||||
},
|
||||
usage: {
|
||||
output_tokens: outputTokens,
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
|
||||
yield {
|
||||
type: 'message_stop',
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
}
|
||||
|
||||
// Safety: close any remaining open blocks if stream ended without finish_reason
|
||||
for (const idx of openBlockIndices) {
|
||||
yield {
|
||||
type: 'content_block_stop',
|
||||
index: idx,
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map OpenAI finish_reason to Anthropic stop_reason.
|
||||
*
|
||||
* stop → end_turn
|
||||
* tool_calls → tool_use
|
||||
* length → max_tokens
|
||||
* content_filter → end_turn
|
||||
*/
|
||||
function mapFinishReason(reason: string): string {
|
||||
switch (reason) {
|
||||
case 'stop':
|
||||
return 'end_turn'
|
||||
case 'tool_calls':
|
||||
return 'tool_use'
|
||||
case 'length':
|
||||
return 'max_tokens'
|
||||
case 'content_filter':
|
||||
return 'end_turn'
|
||||
default:
|
||||
return 'end_turn'
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user