feat: 重构供应商层次 (#286)

* refactor: 创建 @anthropic-ai/model-provider 包骨架与类型定义

- 新建 workspace 包 packages/@anthropic-ai/model-provider
- 定义 ModelProviderHooks 接口(依赖注入:分析、成本、日志等)
- 定义 ClientFactories 接口(Anthropic/OpenAI/Gemini/Grok 客户端工厂)
- 搬入核心类型:Message 体系、NonNullableUsage、EMPTY_USAGE、SystemPrompt、错误常量
- 主项目 src/types/message.ts 等改为 re-export,保持向后兼容

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 提升 OpenAI 转换器和模型映射到 model-provider 包

- 搬入 OpenAI 消息转换(convertMessages)、工具转换(convertTools)、流适配(streamAdapter)
- 搬入 OpenAI 和 Grok 模型映射(resolveOpenAIModel、resolveGrokModel)
- 主项目文件改为 thin re-export proxy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 搬入 Gemini 兼容层到 model-provider 包

- 搬入 Gemini 类型定义、消息转换、工具转换、流适配、模型映射
- 主项目 gemini/ 目录下文件改为 thin re-export proxy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: 搬入 errorUtils 并迁移消费者导入到 model-provider

- 搬入 formatAPIError、extractConnectionErrorDetails 等 errorUtils
- 迁移 10 个消费者文件直接从 @anthropic-ai/model-provider 导入
- 更新 emptyUsage、sdkUtilityTypes、systemPromptType 为 re-export proxy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: compact 模型降级为 -1 模式(Opus→Sonnet, Sonnet→Haiku)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: 添加 agent-loop 绘图

* Revert "feat: compact 模型降级为 -1 模式(Opus→Sonnet, Sonnet→Haiku)"

This reverts commit e458d6391d.

* docs: 添加简化版 agent loop

* fix: 修复 n 快捷键导致关闭的问题

* fix: 修复 node 下 ws 没打包问题

* docs: 修复链接

* test: 添加测试支持

* fix: 修复类型问题(#267) (#271)

* fix: 修复 Bun 的 polyfill 问题

* fix: 类型修复完成

* feat: 统一所有包的类型文件

* fix: 修复构建问题

* test: 修复类型校验 (#279)

* fix: 修复 Bun 的 polyfill 问题

* fix: 类型修复完成

* feat: 统一所有包的类型文件

* fix: 修复构建问题

* fix(remote-control): harden self-hosted session flows (#278)

Co-authored-by: chengzifeng <chengzifeng@meituan.com>

* docs: update contributors

* build: 新增 vite 构建流程

* feat: 添加环境变量支持以覆盖 max_tokens 设置

* feat(langfuse): LLM generation 记录工具定义

将 Anthropic 格式的工具定义转换为 Langfuse 兼容的 OpenAI 格式,
并在 generation 的 input 中以 { messages, tools } 结构传入,
以便在 Langfuse UI 中查看完整的工具定义信息。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: 添加对 ACP 协议的支持 (#284)

* feat: 适配 zed acp 协议

* docs: 完善 acp 文档

* chore: 1.4.0

* conflict: 解决冲突

* feat: 添加测试覆盖率上报

* style: 改名加移动文件夹位置

* refactor: 移动测试用例及实现

* test: 修复测试用例完成

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Cheng Zi Feng <1154238323@qq.com>
Co-authored-by: chengzifeng <chengzifeng@meituan.com>
Co-authored-by: claude-code-best <272536312+claude-code-best@users.noreply.github.com>
This commit is contained in:
claude-code-best
2026-04-17 09:33:14 +08:00
committed by GitHub
parent c8d08d235b
commit bddd146f25
86 changed files with 1661 additions and 1766 deletions

View File

@@ -0,0 +1,501 @@
import { describe, expect, test } from 'bun:test'
import { anthropicMessagesToOpenAI } from '../openaiConvertMessages.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')
})
test('converts base64 image to image_url', () => {
const result = anthropicMessagesToOpenAI(
[makeUserMsg([
{ type: 'text', text: 'what is this?' },
{
type: 'image' as const,
source: {
type: 'base64',
media_type: 'image/png',
data: 'iVBORw0KGgo=',
},
},
])],
[] as any,
)
expect(result).toEqual([{
role: 'user',
content: [
{ type: 'text', text: 'what is this?' },
{
type: 'image_url',
image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' },
},
],
}])
})
test('converts url image to image_url', () => {
const result = anthropicMessagesToOpenAI(
[makeUserMsg([
{
type: 'image' as const,
source: {
type: 'url',
url: 'https://example.com/img.png',
},
},
])],
[] as any,
)
expect(result).toEqual([{
role: 'user',
content: [
{
type: 'image_url',
image_url: { url: 'https://example.com/img.png' },
},
],
}])
})
test('converts image-only message without text', () => {
const result = anthropicMessagesToOpenAI(
[makeUserMsg([
{
type: 'image' as const,
source: {
type: 'base64',
media_type: 'image/jpeg',
data: '/9j/4AAQ',
},
},
])],
[] as any,
)
expect(result).toEqual([{
role: 'user',
content: [
{
type: 'image_url',
image_url: { url: 'data:image/jpeg;base64,/9j/4AAQ' },
},
],
}])
})
test('defaults to image/png when media_type is missing', () => {
const result = anthropicMessagesToOpenAI(
[makeUserMsg([
{
type: 'image' as const,
source: {
type: 'base64',
data: 'ABC123',
},
},
])],
[] as any,
)
expect((result[0].content as any[])[0].image_url.url).toBe(
'data:image/png;base64,ABC123',
)
})
})
describe('DeepSeek thinking mode (enableThinking)', () => {
test('preserves thinking block as reasoning_content when enabled', () => {
const result = anthropicMessagesToOpenAI(
[makeUserMsg('question'), makeAssistantMsg([
{ type: 'thinking' as const, thinking: 'Let me reason about this...' },
{ type: 'text', text: 'The answer is 42.' },
])],
[] as any,
{ enableThinking: true },
)
// Should have: user, assistant with reasoning_content
expect(result).toHaveLength(2)
expect(result[0].role).toBe('user')
const assistant = result[1] as any
expect(assistant.role).toBe('assistant')
expect(assistant.content).toBe('The answer is 42.')
expect(assistant.reasoning_content).toBe('Let me reason about this...')
})
test('drops thinking block when enableThinking is false (default)', () => {
const result = anthropicMessagesToOpenAI(
[makeAssistantMsg([
{ type: 'thinking' as const, thinking: 'internal thoughts...' },
{ type: 'text', text: 'visible response' },
])],
[] as any,
)
const assistant = result[0] as any
expect(assistant.content).toBe('visible response')
expect(assistant.reasoning_content).toBeUndefined()
})
test('preserves reasoning_content with tool_calls in same turn', () => {
const result = anthropicMessagesToOpenAI(
[
makeUserMsg('what is the weather?'),
makeAssistantMsg([
{ type: 'thinking' as const, thinking: 'I need to call the weather tool.' },
{ type: 'text', text: '' },
{
type: 'tool_use' as const,
id: 'toolu_001',
name: 'get_weather',
input: { location: 'Hangzhou' },
},
]),
makeUserMsg([
{
type: 'tool_result' as const,
tool_use_id: 'toolu_001',
content: 'Cloudy 7~13°C',
},
]),
],
[] as any,
{ enableThinking: true },
)
// Find the assistant message
const assistants = result.filter(m => m.role === 'assistant')
expect(assistants.length).toBe(1)
const assistant = assistants[0] as any
expect(assistant.reasoning_content).toBe('I need to call the weather tool.')
expect(assistant.tool_calls).toBeDefined()
expect(assistant.tool_calls[0].function.name).toBe('get_weather')
})
test('strips reasoning_content from previous turns', () => {
const result = anthropicMessagesToOpenAI(
[
// Turn 1: user → assistant (with thinking)
makeUserMsg('question 1'),
makeAssistantMsg([
{ type: 'thinking' as const, thinking: 'Turn 1 reasoning...' },
{ type: 'text', text: 'Turn 1 answer' },
]),
// Turn 2: new user message → previous reasoning should be stripped
makeUserMsg('question 2'),
makeAssistantMsg([
{ type: 'thinking' as const, thinking: 'Turn 2 reasoning...' },
{ type: 'text', text: 'Turn 2 answer' },
]),
],
[] as any,
{ enableThinking: true },
)
const assistants = result.filter(m => m.role === 'assistant')
// Turn 1 assistant: reasoning should be stripped (previous turn)
expect((assistants[0] as any).reasoning_content).toBeUndefined()
expect((assistants[0] as any).content).toBe('Turn 1 answer')
// Turn 2 assistant: reasoning should be preserved (current turn)
expect((assistants[1] as any).reasoning_content).toBe('Turn 2 reasoning...')
expect((assistants[1] as any).content).toBe('Turn 2 answer')
})
test('preserves reasoning_content in multi-iteration tool call within same turn', () => {
// Simulates a full DeepSeek tool call iteration:
// user → assistant(thinking+tool_call) → tool_result → assistant(thinking+tool_call) → tool_result → assistant(thinking+text)
const result = anthropicMessagesToOpenAI(
[
makeUserMsg("tomorrow's weather in Hangzhou"),
// Iteration 1: thinking + tool call
makeAssistantMsg([
{ type: 'thinking' as const, thinking: 'I need the date first.' },
{
type: 'tool_use' as const,
id: 'toolu_001',
name: 'get_date',
input: {},
},
]),
makeUserMsg([
{
type: 'tool_result' as const,
tool_use_id: 'toolu_001',
content: '2026-04-08',
},
]),
// Iteration 2: thinking + tool call
makeAssistantMsg([
{ type: 'thinking' as const, thinking: 'Now I can get the weather.' },
{
type: 'tool_use' as const,
id: 'toolu_002',
name: 'get_weather',
input: { location: 'Hangzhou', date: '2026-04-08' },
},
]),
makeUserMsg([
{
type: 'tool_result' as const,
tool_use_id: 'toolu_002',
content: 'Cloudy 7~13°C',
},
]),
// Iteration 3: thinking + final answer
makeAssistantMsg([
{ type: 'thinking' as const, thinking: 'I have the info now.' },
{ type: 'text', text: 'Tomorrow will be cloudy, 7-13°C.' },
]),
],
[] as any,
{ enableThinking: true },
)
const assistants = result.filter(m => m.role === 'assistant')
expect(assistants.length).toBe(3)
// All iterations within the same turn preserve reasoning
expect((assistants[0] as any).reasoning_content).toBe('I need the date first.')
expect((assistants[1] as any).reasoning_content).toBe('Now I can get the weather.')
expect((assistants[2] as any).reasoning_content).toBe('I have the info now.')
})
test('handles multiple thinking blocks in single assistant message', () => {
const result = anthropicMessagesToOpenAI(
[makeUserMsg('question'), makeAssistantMsg([
{ type: 'thinking' as const, thinking: 'First thought.' },
{ type: 'thinking' as const, thinking: 'Second thought.' },
{ type: 'text', text: 'Final answer.' },
])],
[] as any,
{ enableThinking: true },
)
const assistant = result.filter(m => m.role === 'assistant')[0] as any
expect(assistant.reasoning_content).toBe('First thought.\nSecond thought.')
})
test('skips empty thinking blocks', () => {
const result = anthropicMessagesToOpenAI(
[makeUserMsg('question'), makeAssistantMsg([
{ type: 'thinking' as const, thinking: '' },
{ type: 'text', text: 'Answer.' },
])],
[] as any,
{ enableThinking: true },
)
const assistant = result.filter(m => m.role === 'assistant')[0] as any
expect(assistant.reasoning_content).toBeUndefined()
})
// ── fix: reorder tool and user messages for OpenAI API compatibility (#168) ──
test('tool messages come BEFORE user text when mixed in same turn', () => {
// OpenAI requires: assistant(tool_calls) → tool → user
// Bug: previously user text was emitted before tool messages
const result = anthropicMessagesToOpenAI(
[
makeUserMsg('run ls'),
makeAssistantMsg([
{ type: 'tool_use' as const, id: 'toolu_1', name: 'bash', input: { command: 'ls' } },
]),
makeUserMsg([
{ type: 'tool_result' as const, tool_use_id: 'toolu_1', content: 'file.txt' },
{ type: 'text' as const, text: 'looks good' },
]),
],
[] as any,
)
// Find the tool message and the user text message
const toolIdx = result.findIndex(m => m.role === 'tool')
const userTextIdx = result.findIndex(
m => m.role === 'user' && typeof m.content === 'string' && m.content.includes('looks good'),
)
expect(toolIdx).toBeGreaterThanOrEqual(0)
expect(userTextIdx).toBeGreaterThanOrEqual(0)
// Tool MUST come before user text
expect(toolIdx).toBeLessThan(userTextIdx)
})
test('tool message immediately follows assistant tool_calls (no user message in between)', () => {
const result = anthropicMessagesToOpenAI(
[
makeUserMsg('do something'),
makeAssistantMsg([
{ type: 'tool_use' as const, id: 'toolu_2', name: 'bash', input: { command: 'pwd' } },
]),
makeUserMsg([
{ type: 'tool_result' as const, tool_use_id: 'toolu_2', content: '/home/user' },
]),
],
[] as any,
)
const assistantIdx = result.findIndex(m => m.role === 'assistant' && (m as any).tool_calls)
const toolIdx = result.findIndex(m => m.role === 'tool')
expect(assistantIdx).toBeGreaterThanOrEqual(0)
expect(toolIdx).toBe(assistantIdx + 1)
})
test('sets content to null when only thinking and tool_calls present', () => {
const result = anthropicMessagesToOpenAI(
[makeUserMsg('question'), makeAssistantMsg([
{ type: 'thinking' as const, thinking: 'Reasoning only.' },
{
type: 'tool_use' as const,
id: 'toolu_001',
name: 'bash',
input: { command: 'ls' },
},
])],
[] as any,
{ enableThinking: true },
)
const assistant = result.filter(m => m.role === 'assistant')[0] as any
expect(assistant.content).toBeNull()
expect(assistant.reasoning_content).toBe('Reasoning only.')
expect(assistant.tool_calls).toHaveLength(1)
})
})

View File

@@ -0,0 +1,167 @@
import { describe, expect, test } from 'bun:test'
import { anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI } from '../openaiConvertTools.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] as { function: { parameters: unknown } }).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([])
})
test('sanitizes const to enum in tool schema', () => {
const tools = [
{
type: 'custom',
name: 'test',
description: 'test tool',
input_schema: {
type: 'object',
properties: {
mode: { const: 'read' },
name: { type: 'string' },
},
},
},
]
const result = anthropicToolsToOpenAI(tools as any)
const props = (result[0] as { function: { parameters: any } }).function.parameters as any
expect(props.properties.mode).toEqual({ enum: ['read'] })
expect(props.properties.mode.const).toBeUndefined()
expect(props.properties.name).toEqual({ type: 'string' })
})
test('sanitizes const in deeply nested schemas', () => {
const tools = [
{
type: 'custom',
name: 'deep',
description: 'nested const',
input_schema: {
type: 'object',
properties: {
outer: {
type: 'object',
properties: {
inner: { const: 'fixed' },
},
},
},
definitions: {
MyType: {
type: 'object',
properties: {
field: { const: 42 },
},
},
},
},
},
]
const result = anthropicToolsToOpenAI(tools as any)
const params = (result[0] as { function: { parameters: any } }).function.parameters as any
expect(params.properties.outer.properties.inner).toEqual({ enum: ['fixed'] })
expect(params.definitions.MyType.properties.field).toEqual({ enum: [42] })
})
test('sanitizes const in anyOf/oneOf/allOf', () => {
const tools = [
{
type: 'custom',
name: 'union',
description: 'union test',
input_schema: {
type: 'object',
properties: {
val: {
anyOf: [
{ const: 'a' },
{ const: 'b' },
{ type: 'string' },
],
},
},
},
},
]
const result = anthropicToolsToOpenAI(tools as any)
const anyOf = ((result[0] as { function: { parameters: any } }).function.parameters as any).properties.val.anyOf
expect(anyOf[0]).toEqual({ enum: ['a'] })
expect(anyOf[1]).toEqual({ enum: ['b'] })
expect(anyOf[2]).toEqual({ type: 'string' })
})
})
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()
})
})

View File

@@ -0,0 +1,659 @@
import { describe, expect, test } from 'bun:test'
import type { ChatCompletionChunk } from 'openai/resources/chat/completions/completions.mjs'
import { adaptOpenAIStreamToAnthropic } from '../openaiStreamAdapter.js'
/** 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
}
/** Collect all emitted Anthropic events from the stream adapter for assertion */
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('forces tool_use stop_reason when tool_calls present but finish_reason is stop', async () => {
// Some backends (e.g., certain OpenAI-compatible endpoints) incorrectly
// return finish_reason "stop" when they actually made tool calls.
const events = await collectEvents([
makeChunk({
choices: [{
index: 0,
delta: {
tool_calls: [{ index: 0, id: 'call_1', function: { name: 'bash', arguments: '{"cmd":"ls"}' } }],
},
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('tool_use')
})
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)
})
test('captures output_tokens and input_tokens from trailing chunk sent after finish_reason', async () => {
// Many OpenAI-compatible endpoints (e.g. DeepSeek) send usage in a separate
// final chunk AFTER the finish_reason chunk, with choices: [].
// message_delta must carry both input_tokens and output_tokens so that
// queryModelOpenAI's spread can override the zeros from message_start — which is
// emitted before the trailing chunk and always has input_tokens=0.
const events = await collectEvents([
makeChunk({
choices: [{ index: 0, delta: { content: 'hello' }, finish_reason: null }],
}),
// finish_reason chunk — usage not yet available
makeChunk({
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
}),
// trailing usage-only chunk (choices: [])
makeChunk({
choices: [],
usage: { prompt_tokens: 123, completion_tokens: 45, total_tokens: 168 },
}),
])
// message_start emits on the first chunk before trailing usage arrives
const msgStart = events.find(e => e.type === 'message_start') as any
expect(msgStart.message.usage.input_tokens).toBe(0)
// message_delta is emitted after stream loop ends with final real values
const msgDelta = events.find(e => e.type === 'message_delta') as any
expect(msgDelta.usage.input_tokens).toBe(123)
expect(msgDelta.usage.output_tokens).toBe(45)
expect(msgDelta.delta.stop_reason).toBe('end_turn')
})
test('captures input_tokens from trailing chunk (used by tokenCountWithEstimation for autocompact)', async () => {
// input_tokens is the dominant term in tokenCountWithEstimation. Without it,
// getTokenCountFromUsage returns only output_tokens (~100-700), which is far below
// the autocompact threshold (~33k), so compaction never fires.
const events = await collectEvents([
makeChunk({
choices: [{ index: 0, delta: { content: 'answer' }, finish_reason: null }],
}),
makeChunk({
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
}),
makeChunk({
choices: [],
usage: { prompt_tokens: 800, completion_tokens: 200, total_tokens: 1000 },
}),
])
const msgDelta = events.find(e => e.type === 'message_delta') as any
expect(msgDelta.usage.input_tokens).toBe(800)
expect(msgDelta.usage.output_tokens).toBe(200)
})
test('trailing usage chunk with tool_calls: stop_reason stays tool_use', async () => {
// Verifies that deferring message_delta does not break stop_reason mapping
// when the model made tool calls and usage arrives in a trailing chunk.
const events = await collectEvents([
makeChunk({
choices: [{
index: 0,
delta: {
tool_calls: [{ index: 0, id: 'call_x', function: { name: 'bash', arguments: '{"cmd":"ls"}' } }],
},
finish_reason: null,
}],
}),
makeChunk({
choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }],
}),
// trailing usage-only chunk
makeChunk({
choices: [],
usage: { prompt_tokens: 500, completion_tokens: 30, total_tokens: 530 },
}),
])
const msgDelta = events.find(e => e.type === 'message_delta') as any
expect(msgDelta.delta.stop_reason).toBe('tool_use')
expect(msgDelta.usage.output_tokens).toBe(30)
})
test('message_delta always comes before message_stop', async () => {
// Verifies event ordering is preserved after deferring to post-loop emission.
const events = await collectEvents([
makeChunk({ choices: [{ index: 0, delta: { content: 'x' }, finish_reason: null }] }),
makeChunk({ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] }),
makeChunk({ choices: [], usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 } }),
])
const types = events.map(e => e.type)
const deltaIdx = types.lastIndexOf('message_delta')
const stopIdx = types.lastIndexOf('message_stop')
expect(deltaIdx).toBeGreaterThanOrEqual(0)
expect(stopIdx).toBeGreaterThan(deltaIdx)
})
// ── cache_read_input_tokens in message_delta (the core bug fix) ──────────
test('message_delta carries cache_read_input_tokens from trailing usage chunk', async () => {
// Real-world case: DeepSeek-V3 returns cached_tokens=19904
// in a trailing chunk with choices:[]. Previously message_delta only carried
// input_tokens and output_tokens, so cache_read_input_tokens stayed 0 after
// queryModelOpenAI's spread — even though cachedTokens was captured internally.
const events = await collectEvents([
makeChunk({
choices: [{ index: 0, delta: { content: 'answer' }, finish_reason: null }],
}),
makeChunk({
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
}),
// trailing usage chunk matching the observed server response format
makeChunk({
choices: [],
usage: {
prompt_tokens: 30011,
completion_tokens: 190,
total_tokens: 30201,
prompt_tokens_details: { audio_tokens: 0, cached_tokens: 19904 },
} as any,
}),
])
// message_start is emitted before trailing chunk — cache fields are 0
const msgStart = events.find(e => e.type === 'message_start') as any
expect(msgStart.message.usage.cache_read_input_tokens).toBe(0)
// message_delta carries the real values from the trailing chunk
const msgDelta = events.find(e => e.type === 'message_delta') as any
expect(msgDelta.usage.input_tokens).toBe(30011)
expect(msgDelta.usage.output_tokens).toBe(190)
expect(msgDelta.usage.cache_read_input_tokens).toBe(19904)
expect(msgDelta.usage.cache_creation_input_tokens).toBe(0)
})
test('cache_read_input_tokens=0 in message_delta when cached_tokens is absent', async () => {
// Non-caching requests should still have the field present and zero.
const events = await collectEvents([
makeChunk({
choices: [{ index: 0, delta: { content: 'hi' }, finish_reason: null }],
}),
makeChunk({
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
}),
makeChunk({
choices: [],
usage: { prompt_tokens: 100, completion_tokens: 20, total_tokens: 120 },
}),
])
const msgDelta = events.find(e => e.type === 'message_delta') as any
expect(msgDelta.usage.cache_read_input_tokens).toBe(0)
expect(msgDelta.usage.cache_creation_input_tokens).toBe(0)
})
test('cache_read_input_tokens=0 in message_delta when cached_tokens is 0', async () => {
// Explicit cached_tokens:0 should not be treated differently from absent.
const events = await collectEvents([
makeChunk({
choices: [{ index: 0, delta: { content: 'hi' }, finish_reason: null }],
}),
makeChunk({
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
}),
makeChunk({
choices: [],
usage: {
prompt_tokens: 500,
completion_tokens: 50,
total_tokens: 550,
prompt_tokens_details: { cached_tokens: 0 },
} as any,
}),
])
const msgDelta = events.find(e => e.type === 'message_delta') as any
expect(msgDelta.usage.cache_read_input_tokens).toBe(0)
})
test('cache_read_input_tokens updated when cached_tokens arrives in same chunk as finish_reason', async () => {
// Some endpoints send usage in the finish_reason chunk instead of a trailing chunk.
const events = await collectEvents([
makeChunk({
choices: [{ index: 0, delta: { content: 'result' }, finish_reason: null }],
}),
makeChunk({
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
usage: {
prompt_tokens: 2000,
completion_tokens: 100,
total_tokens: 2100,
prompt_tokens_details: { cached_tokens: 1500 },
} as any,
}),
])
const msgDelta = events.find(e => e.type === 'message_delta') as any
expect(msgDelta.usage.cache_read_input_tokens).toBe(1500)
expect(msgDelta.usage.input_tokens).toBe(2000)
expect(msgDelta.usage.output_tokens).toBe(100)
})
})