Skip to content

Commit 130b417

Browse files
feat: 完成 openai 接口兼容
1 parent 465e9f0 commit 130b417

16 files changed

Lines changed: 1981 additions & 14 deletions

bun.lock

Lines changed: 27 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/plans/openai-compatibility.md

Lines changed: 421 additions & 0 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
},
5454
"dependencies": {},
5555
"devDependencies": {
56+
"openai": "^4.73.0",
5657
"@alcalzone/ansi-tokenize": "^0.3.0",
5758
"@ant/claude-for-chrome-mcp": "workspace:*",
5859
"@ant/computer-use-input": "workspace:*",

src/services/api/claude.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1301,6 +1301,15 @@ async function* queryModel(
13011301
API_MAX_MEDIA_PER_REQUEST,
13021302
)
13031303

1304+
// OpenAI-compatible provider: delegate to the OpenAI adapter layer
1305+
// after shared preprocessing (message normalization, tool filtering,
1306+
// media stripping) but before Anthropic-specific logic (betas, thinking, caching).
1307+
if (getAPIProvider() === 'openai') {
1308+
const { queryModelOpenAI } = await import('./openai/index.js')
1309+
yield* queryModelOpenAI(messagesForAPI, systemPrompt, filteredTools, signal, options)
1310+
return
1311+
}
1312+
13041313
// Instrumentation: Track message count after normalization
13051314
logEvent('tengu_api_after_normalize', {
13061315
postNormalizedMessageCount: messagesForAPI.length,
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { anthropicMessagesToOpenAI } from '../convertMessages.js'
3+
4+
// SystemPrompt is `readonly string[]` — pass string arrays
5+
describe('anthropicMessagesToOpenAI', () => {
6+
test('converts system prompt to system message', () => {
7+
const result = anthropicMessagesToOpenAI(
8+
[{ role: 'user', content: 'hello' }],
9+
['You are helpful.'] as any,
10+
)
11+
expect(result[0]).toEqual({ role: 'system', content: 'You are helpful.' })
12+
})
13+
14+
test('joins multiple system prompt strings', () => {
15+
const result = anthropicMessagesToOpenAI(
16+
[{ role: 'user', content: 'hi' }],
17+
['Part 1', 'Part 2'] as any,
18+
)
19+
expect(result[0]).toEqual({ role: 'system', content: 'Part 1\n\nPart 2' })
20+
})
21+
22+
test('skips empty system prompt', () => {
23+
const result = anthropicMessagesToOpenAI(
24+
[{ role: 'user', content: 'hi' }],
25+
[] as any,
26+
)
27+
expect(result[0].role).toBe('user')
28+
})
29+
30+
test('converts simple user text message', () => {
31+
const result = anthropicMessagesToOpenAI(
32+
[{ role: 'user', content: 'hello world' }],
33+
[] as any,
34+
)
35+
expect(result).toEqual([{ role: 'user', content: 'hello world' }])
36+
})
37+
38+
test('converts user message with content array', () => {
39+
const result = anthropicMessagesToOpenAI(
40+
[{
41+
role: 'user',
42+
content: [
43+
{ type: 'text', text: 'line 1' },
44+
{ type: 'text', text: 'line 2' },
45+
],
46+
}],
47+
[] as any,
48+
)
49+
expect(result).toEqual([{ role: 'user', content: 'line 1\nline 2' }])
50+
})
51+
52+
test('converts assistant message with text', () => {
53+
const result = anthropicMessagesToOpenAI(
54+
[{ role: 'assistant', content: 'response text' }],
55+
[] as any,
56+
)
57+
expect(result).toEqual([{ role: 'assistant', content: 'response text' }])
58+
})
59+
60+
test('converts assistant message with tool_use', () => {
61+
const result = anthropicMessagesToOpenAI(
62+
[{
63+
role: 'assistant',
64+
content: [
65+
{ type: 'text', text: 'Let me help.' },
66+
{
67+
type: 'tool_use' as const,
68+
id: 'toolu_123',
69+
name: 'bash',
70+
input: { command: 'ls' },
71+
},
72+
],
73+
}],
74+
[] as any,
75+
)
76+
expect(result).toEqual([{
77+
role: 'assistant',
78+
content: 'Let me help.',
79+
tool_calls: [{
80+
id: 'toolu_123',
81+
type: 'function',
82+
function: { name: 'bash', arguments: '{"command":"ls"}' },
83+
}],
84+
}])
85+
})
86+
87+
test('converts tool_result to tool message', () => {
88+
const result = anthropicMessagesToOpenAI(
89+
[{
90+
role: 'user',
91+
content: [
92+
{
93+
type: 'tool_result' as const,
94+
tool_use_id: 'toolu_123',
95+
content: 'file1.txt\nfile2.txt',
96+
},
97+
],
98+
}],
99+
[] as any,
100+
)
101+
expect(result).toEqual([{
102+
role: 'tool',
103+
tool_call_id: 'toolu_123',
104+
content: 'file1.txt\nfile2.txt',
105+
}])
106+
})
107+
108+
test('strips thinking blocks', () => {
109+
const result = anthropicMessagesToOpenAI(
110+
[{
111+
role: 'assistant',
112+
content: [
113+
{ type: 'thinking' as const, thinking: 'internal thoughts...' },
114+
{ type: 'text', text: 'visible response' },
115+
],
116+
}],
117+
[] as any,
118+
)
119+
expect(result).toEqual([{ role: 'assistant', content: 'visible response' }])
120+
})
121+
122+
test('handles full conversation with tools', () => {
123+
const result = anthropicMessagesToOpenAI(
124+
[
125+
{ role: 'user', content: 'list files' },
126+
{
127+
role: 'assistant',
128+
content: [
129+
{
130+
type: 'tool_use' as const,
131+
id: 'toolu_abc',
132+
name: 'bash',
133+
input: { command: 'ls' },
134+
},
135+
],
136+
},
137+
{
138+
role: 'user',
139+
content: [
140+
{
141+
type: 'tool_result' as const,
142+
tool_use_id: 'toolu_abc',
143+
content: 'file.txt',
144+
},
145+
],
146+
},
147+
],
148+
['You are helpful.'] as any,
149+
)
150+
151+
expect(result).toHaveLength(4)
152+
expect(result[0].role).toBe('system')
153+
expect(result[1].role).toBe('user')
154+
expect(result[2].role).toBe('assistant')
155+
expect((result[2] as any).tool_calls).toBeDefined()
156+
expect(result[3].role).toBe('tool')
157+
})
158+
})
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { describe, expect, test } from 'bun:test'
2+
import { anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI } from '../convertTools.js'
3+
4+
describe('anthropicToolsToOpenAI', () => {
5+
test('converts basic tool', () => {
6+
const tools = [
7+
{
8+
type: 'custom',
9+
name: 'bash',
10+
description: 'Run a bash command',
11+
input_schema: {
12+
type: 'object',
13+
properties: { command: { type: 'string' } },
14+
required: ['command'],
15+
},
16+
},
17+
]
18+
19+
const result = anthropicToolsToOpenAI(tools as any)
20+
21+
expect(result).toEqual([{
22+
type: 'function',
23+
function: {
24+
name: 'bash',
25+
description: 'Run a bash command',
26+
parameters: {
27+
type: 'object',
28+
properties: { command: { type: 'string' } },
29+
required: ['command'],
30+
},
31+
},
32+
}])
33+
})
34+
35+
test('uses empty schema when input_schema missing', () => {
36+
const tools = [{ type: 'custom', name: 'noop', description: 'no-op' }]
37+
const result = anthropicToolsToOpenAI(tools as any)
38+
39+
expect(result[0].function.parameters).toEqual({ type: 'object', properties: {} })
40+
})
41+
42+
test('strips Anthropic-specific fields', () => {
43+
const tools = [
44+
{
45+
type: 'custom',
46+
name: 'bash',
47+
description: 'Run bash',
48+
input_schema: { type: 'object', properties: {} },
49+
cache_control: { type: 'ephemeral' },
50+
defer_loading: true,
51+
},
52+
]
53+
const result = anthropicToolsToOpenAI(tools as any)
54+
55+
expect((result[0] as any).cache_control).toBeUndefined()
56+
expect((result[0] as any).defer_loading).toBeUndefined()
57+
})
58+
59+
test('handles empty tools array', () => {
60+
expect(anthropicToolsToOpenAI([])).toEqual([])
61+
})
62+
})
63+
64+
describe('anthropicToolChoiceToOpenAI', () => {
65+
test('maps auto', () => {
66+
expect(anthropicToolChoiceToOpenAI({ type: 'auto' })).toBe('auto')
67+
})
68+
69+
test('maps any to required', () => {
70+
expect(anthropicToolChoiceToOpenAI({ type: 'any' })).toBe('required')
71+
})
72+
73+
test('maps tool to function', () => {
74+
const result = anthropicToolChoiceToOpenAI({ type: 'tool', name: 'bash' })
75+
expect(result).toEqual({ type: 'function', function: { name: 'bash' } })
76+
})
77+
78+
test('returns undefined for undefined input', () => {
79+
expect(anthropicToolChoiceToOpenAI(undefined)).toBeUndefined()
80+
})
81+
82+
test('returns undefined for unknown type', () => {
83+
expect(anthropicToolChoiceToOpenAI({ type: 'unknown' })).toBeUndefined()
84+
})
85+
})
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
2+
import { resolveOpenAIModel } from '../modelMapping.js'
3+
4+
// Cache is module-level, so we need to invalidate it by changing env vars
5+
describe('resolveOpenAIModel', () => {
6+
const originalEnv = {
7+
OPENAI_MODEL: process.env.OPENAI_MODEL,
8+
OPENAI_MODEL_MAP: process.env.OPENAI_MODEL_MAP,
9+
}
10+
11+
beforeEach(() => {
12+
// Reset env and clear module cache between tests
13+
delete process.env.OPENAI_MODEL
14+
delete process.env.OPENAI_MODEL_MAP
15+
})
16+
17+
afterEach(() => {
18+
process.env.OPENAI_MODEL = originalEnv.OPENAI_MODEL
19+
process.env.OPENAI_MODEL_MAP = originalEnv.OPENAI_MODEL_MAP
20+
})
21+
22+
test('OPENAI_MODEL env var overrides all', () => {
23+
process.env.OPENAI_MODEL = 'my-custom-model'
24+
// Need to reimport to bust cache — but since resolveOpenAIModel reads env at call time
25+
// for OPENAI_MODEL, this should work
26+
expect(resolveOpenAIModel('claude-sonnet-4-6')).toBe('my-custom-model')
27+
})
28+
29+
test('maps known Anthropic model via DEFAULT_MODEL_MAP', () => {
30+
// claude-sonnet-4-6 → gpt-4o per default map
31+
expect(resolveOpenAIModel('claude-sonnet-4-6')).toBe('gpt-4o')
32+
})
33+
34+
test('maps haiku model', () => {
35+
expect(resolveOpenAIModel('claude-haiku-4-5-20251001')).toBe('gpt-4o-mini')
36+
})
37+
38+
test('maps opus model', () => {
39+
expect(resolveOpenAIModel('claude-opus-4-6')).toBe('o3')
40+
})
41+
42+
test('passes through unknown model name', () => {
43+
expect(resolveOpenAIModel('some-random-model')).toBe('some-random-model')
44+
})
45+
46+
test('strips [1m] suffix', () => {
47+
// claude-sonnet-4-6[1m] → gpt-4o (same as without suffix)
48+
expect(resolveOpenAIModel('claude-sonnet-4-6[1m]')).toBe('gpt-4o')
49+
})
50+
})

0 commit comments

Comments
 (0)