mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-24 09:05:50 +00:00
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:
@@ -0,0 +1,267 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import type {
|
||||
AssistantMessage,
|
||||
UserMessage,
|
||||
} from '../../../types/message.js'
|
||||
import { anthropicMessagesToGemini } from '../convertMessages.js'
|
||||
|
||||
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('anthropicMessagesToGemini', () => {
|
||||
test('converts system prompt to systemInstruction', () => {
|
||||
const result = anthropicMessagesToGemini(
|
||||
[makeUserMsg('hello')],
|
||||
['You are helpful.'] as any,
|
||||
)
|
||||
|
||||
expect(result.systemInstruction).toEqual({
|
||||
parts: [{ text: 'You are helpful.' }],
|
||||
})
|
||||
})
|
||||
|
||||
test('converts assistant tool_use to functionCall', () => {
|
||||
const result = anthropicMessagesToGemini(
|
||||
[
|
||||
makeAssistantMsg([
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'toolu_123',
|
||||
name: 'bash',
|
||||
input: { command: 'ls' },
|
||||
_geminiThoughtSignature: 'sig-tool',
|
||||
},
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
|
||||
expect(result.contents).toEqual([
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: 'bash',
|
||||
args: { command: 'ls' },
|
||||
},
|
||||
thoughtSignature: 'sig-tool',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('converts tool_result to functionResponse using prior tool name', () => {
|
||||
const result = anthropicMessagesToGemini(
|
||||
[
|
||||
makeAssistantMsg([
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'toolu_123',
|
||||
name: 'bash',
|
||||
input: { command: 'ls' },
|
||||
},
|
||||
]),
|
||||
makeUserMsg([
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'toolu_123',
|
||||
content: 'file.txt',
|
||||
},
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
|
||||
expect(result.contents[1]).toEqual({
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'bash',
|
||||
response: {
|
||||
result: 'file.txt',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test('converts thinking blocks with signatures', () => {
|
||||
const result = anthropicMessagesToGemini(
|
||||
[
|
||||
makeAssistantMsg([
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: 'internal reasoning',
|
||||
signature: 'sig-thinking',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'visible answer',
|
||||
},
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
|
||||
expect(result.contents[0]).toEqual({
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
text: 'internal reasoning',
|
||||
thought: true,
|
||||
thoughtSignature: 'sig-thinking',
|
||||
},
|
||||
{
|
||||
text: 'visible answer',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test('filters empty assistant text and signature-only thinking parts', () => {
|
||||
const result = anthropicMessagesToGemini(
|
||||
[
|
||||
makeAssistantMsg([
|
||||
{
|
||||
type: 'text',
|
||||
text: '',
|
||||
_geminiThoughtSignature: 'sig-empty-text',
|
||||
},
|
||||
{
|
||||
type: 'thinking',
|
||||
thinking: '',
|
||||
signature: 'sig-empty-thinking',
|
||||
},
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'toolu_123',
|
||||
name: 'bash',
|
||||
input: { command: 'pwd' },
|
||||
},
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
|
||||
expect(result.contents).toEqual([
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: 'bash',
|
||||
args: { command: 'pwd' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('filters empty user text blocks', () => {
|
||||
const result = anthropicMessagesToGemini(
|
||||
[
|
||||
makeUserMsg([
|
||||
{
|
||||
type: 'text',
|
||||
text: '',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: 'hello',
|
||||
},
|
||||
]),
|
||||
],
|
||||
[] as any,
|
||||
)
|
||||
|
||||
expect(result.contents).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'hello' }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('converts base64 image to inlineData', () => {
|
||||
const result = anthropicMessagesToGemini(
|
||||
[makeUserMsg([
|
||||
{ type: 'text', text: 'describe this' },
|
||||
{
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'image/png',
|
||||
data: 'iVBORw0KGgo=',
|
||||
},
|
||||
},
|
||||
])],
|
||||
[] as any,
|
||||
)
|
||||
expect(result.contents).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{ text: 'describe this' },
|
||||
{ inlineData: { mimeType: 'image/png', data: 'iVBORw0KGgo=' } },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('converts url image to text fallback', () => {
|
||||
const result = anthropicMessagesToGemini(
|
||||
[makeUserMsg([
|
||||
{
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'url',
|
||||
url: 'https://example.com/img.png',
|
||||
},
|
||||
},
|
||||
])],
|
||||
[] as any,
|
||||
)
|
||||
expect(result.contents).toEqual([
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: '[image: https://example.com/img.png]' }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('defaults to image/png when media_type is missing', () => {
|
||||
const result = anthropicMessagesToGemini(
|
||||
[makeUserMsg([
|
||||
{
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
data: 'ABC123',
|
||||
},
|
||||
},
|
||||
])],
|
||||
[] as any,
|
||||
)
|
||||
expect(result.contents[0].parts[0]).toEqual({
|
||||
inlineData: { mimeType: 'image/png', data: 'ABC123' },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
anthropicToolChoiceToGemini,
|
||||
anthropicToolsToGemini,
|
||||
} from '../convertTools.js'
|
||||
|
||||
describe('anthropicToolsToGemini', () => {
|
||||
test('converts basic tool to parametersJsonSchema', () => {
|
||||
const tools = [
|
||||
{
|
||||
type: 'custom',
|
||||
name: 'bash',
|
||||
description: 'Run a bash command',
|
||||
input_schema: {
|
||||
type: 'object',
|
||||
properties: { command: { type: 'string' } },
|
||||
required: ['command'],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
expect(anthropicToolsToGemini(tools as any)).toEqual([
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: 'bash',
|
||||
description: 'Run a bash command',
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
properties: { command: { type: 'string' } },
|
||||
propertyOrdering: ['command'],
|
||||
required: ['command'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('sanitizes unsupported JSON Schema fields for Gemini', () => {
|
||||
const tools = [
|
||||
{
|
||||
type: 'custom',
|
||||
name: 'complex',
|
||||
description: 'Complex schema',
|
||||
input_schema: {
|
||||
$schema: 'http://json-schema.org/draft-07/schema#',
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
propertyNames: { pattern: '^[a-z]+$' },
|
||||
properties: {
|
||||
mode: { const: 'strict' },
|
||||
retries: {
|
||||
type: 'integer',
|
||||
exclusiveMinimum: 0,
|
||||
},
|
||||
metadata: {
|
||||
type: 'object',
|
||||
additionalProperties: {
|
||||
type: 'string',
|
||||
propertyNames: { pattern: '^[a-z]+$' },
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['mode'],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
expect(anthropicToolsToGemini(tools as any)).toEqual([
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: 'complex',
|
||||
description: 'Complex schema',
|
||||
parametersJsonSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
mode: {
|
||||
type: 'string',
|
||||
enum: ['strict'],
|
||||
},
|
||||
retries: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
},
|
||||
metadata: {
|
||||
type: 'object',
|
||||
additionalProperties: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
propertyOrdering: ['mode', 'retries', 'metadata'],
|
||||
required: ['mode'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('returns empty array when no tools are provided', () => {
|
||||
expect(anthropicToolsToGemini([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('anthropicToolChoiceToGemini', () => {
|
||||
test('maps auto', () => {
|
||||
expect(anthropicToolChoiceToGemini({ type: 'auto' })).toEqual({
|
||||
mode: 'AUTO',
|
||||
})
|
||||
})
|
||||
|
||||
test('maps any', () => {
|
||||
expect(anthropicToolChoiceToGemini({ type: 'any' })).toEqual({
|
||||
mode: 'ANY',
|
||||
})
|
||||
})
|
||||
|
||||
test('maps explicit tool choice', () => {
|
||||
expect(
|
||||
anthropicToolChoiceToGemini({ type: 'tool', name: 'bash' }),
|
||||
).toEqual({
|
||||
mode: 'ANY',
|
||||
allowedFunctionNames: ['bash'],
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { resolveGeminiModel } from '../modelMapping.js'
|
||||
|
||||
describe('resolveGeminiModel', () => {
|
||||
const originalEnv = {
|
||||
GEMINI_MODEL: process.env.GEMINI_MODEL,
|
||||
GEMINI_DEFAULT_HAIKU_MODEL: process.env.GEMINI_DEFAULT_HAIKU_MODEL,
|
||||
GEMINI_DEFAULT_SONNET_MODEL: process.env.GEMINI_DEFAULT_SONNET_MODEL,
|
||||
GEMINI_DEFAULT_OPUS_MODEL: process.env.GEMINI_DEFAULT_OPUS_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.GEMINI_MODEL
|
||||
delete process.env.GEMINI_DEFAULT_HAIKU_MODEL
|
||||
delete process.env.GEMINI_DEFAULT_SONNET_MODEL
|
||||
delete process.env.GEMINI_DEFAULT_OPUS_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('GEMINI_MODEL env var overrides family mappings', () => {
|
||||
process.env.GEMINI_MODEL = 'gemini-2.5-pro'
|
||||
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'gemini-2.5-flash'
|
||||
|
||||
expect(resolveGeminiModel('claude-sonnet-4-6')).toBe('gemini-2.5-pro')
|
||||
})
|
||||
|
||||
test('GEMINI_DEFAULT_*_MODEL takes precedence over ANTHROPIC_DEFAULT_*', () => {
|
||||
process.env.GEMINI_DEFAULT_SONNET_MODEL = 'gemini-2.5-flash-priority'
|
||||
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'gemini-2.5-flash-fallback'
|
||||
|
||||
expect(resolveGeminiModel('claude-sonnet-4-6')).toBe(
|
||||
'gemini-2.5-flash-priority',
|
||||
)
|
||||
})
|
||||
|
||||
test('resolves sonnet model from GEMINI_DEFAULT_SONNET_MODEL', () => {
|
||||
process.env.GEMINI_DEFAULT_SONNET_MODEL = 'gemini-2.5-flash'
|
||||
expect(resolveGeminiModel('claude-sonnet-4-6')).toBe('gemini-2.5-flash')
|
||||
})
|
||||
|
||||
test('resolves haiku model from GEMINI_DEFAULT_HAIKU_MODEL', () => {
|
||||
process.env.GEMINI_DEFAULT_HAIKU_MODEL = 'gemini-2.5-flash-lite'
|
||||
expect(resolveGeminiModel('claude-haiku-4-5-20251001')).toBe(
|
||||
'gemini-2.5-flash-lite',
|
||||
)
|
||||
})
|
||||
|
||||
test('resolves opus model from GEMINI_DEFAULT_OPUS_MODEL', () => {
|
||||
process.env.GEMINI_DEFAULT_OPUS_MODEL = 'gemini-2.5-pro'
|
||||
expect(resolveGeminiModel('claude-opus-4-6')).toBe('gemini-2.5-pro')
|
||||
})
|
||||
|
||||
test('falls back to ANTHROPIC_DEFAULT_* when GEMINI_DEFAULT_* not set', () => {
|
||||
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'gemini-2.5-flash'
|
||||
expect(resolveGeminiModel('claude-sonnet-4-6')).toBe('gemini-2.5-flash')
|
||||
})
|
||||
|
||||
test('resolves haiku from ANTHROPIC_DEFAULT_HAIKU_MODEL as fallback', () => {
|
||||
process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = 'gemini-2.5-flash-lite'
|
||||
expect(resolveGeminiModel('claude-haiku-4-5-20251001')).toBe(
|
||||
'gemini-2.5-flash-lite',
|
||||
)
|
||||
})
|
||||
|
||||
test('resolves opus from ANTHROPIC_DEFAULT_OPUS_MODEL as fallback', () => {
|
||||
process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = 'gemini-2.5-pro'
|
||||
expect(resolveGeminiModel('claude-opus-4-6')).toBe('gemini-2.5-pro')
|
||||
})
|
||||
|
||||
test('uses backward compatible family override', () => {
|
||||
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'legacy-gemini-sonnet'
|
||||
expect(resolveGeminiModel('claude-sonnet-4-6')).toBe('legacy-gemini-sonnet')
|
||||
})
|
||||
|
||||
test('strips [1m] suffix before resolving', () => {
|
||||
process.env.GEMINI_DEFAULT_SONNET_MODEL = 'gemini-2.5-flash'
|
||||
expect(resolveGeminiModel('claude-sonnet-4-6[1m]')).toBe('gemini-2.5-flash')
|
||||
})
|
||||
|
||||
test('passes through explicit Gemini model names', () => {
|
||||
expect(resolveGeminiModel('gemini-3.1-flash-lite-preview')).toBe(
|
||||
'gemini-3.1-flash-lite-preview',
|
||||
)
|
||||
})
|
||||
|
||||
test('throws when no Gemini model configuration is available', () => {
|
||||
expect(() => resolveGeminiModel('claude-sonnet-4-6')).toThrow(
|
||||
'Gemini provider requires GEMINI_MODEL or GEMINI_DEFAULT_SONNET_MODEL (or ANTHROPIC_DEFAULT_SONNET_MODEL for backward compatibility) to be configured.',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { adaptGeminiStreamToAnthropic } from '../streamAdapter.js'
|
||||
import type { GeminiStreamChunk } from '../types.js'
|
||||
|
||||
function mockStream(
|
||||
chunks: GeminiStreamChunk[],
|
||||
): AsyncIterable<GeminiStreamChunk> {
|
||||
return {
|
||||
[Symbol.asyncIterator]() {
|
||||
let index = 0
|
||||
return {
|
||||
async next() {
|
||||
if (index >= chunks.length) {
|
||||
return { done: true, value: undefined }
|
||||
}
|
||||
return { done: false, value: chunks[index++] }
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function collectEvents(chunks: GeminiStreamChunk[]) {
|
||||
const events: any[] = []
|
||||
for await (const event of adaptGeminiStreamToAnthropic(
|
||||
mockStream(chunks),
|
||||
'gemini-2.5-flash',
|
||||
)) {
|
||||
events.push(event)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
describe('adaptGeminiStreamToAnthropic', () => {
|
||||
test('converts text chunks', async () => {
|
||||
const events = await collectEvents([
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'Hello' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: ' world' }],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const textDeltas = events.filter(
|
||||
event =>
|
||||
event.type === 'content_block_delta' && event.delta.type === 'text_delta',
|
||||
)
|
||||
|
||||
expect(events[0].type).toBe('message_start')
|
||||
expect(textDeltas).toHaveLength(2)
|
||||
expect(textDeltas[0].delta.text).toBe('Hello')
|
||||
expect(textDeltas[1].delta.text).toBe(' world')
|
||||
|
||||
const messageDelta = events.find(event => event.type === 'message_delta')
|
||||
expect(messageDelta.delta.stop_reason).toBe('end_turn')
|
||||
})
|
||||
|
||||
test('converts thinking chunks and signatures', async () => {
|
||||
const events = await collectEvents([
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'Think', thought: true }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ thought: true, thoughtSignature: 'sig-123' }],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const blockStart = events.find(event => event.type === 'content_block_start')
|
||||
expect(blockStart.content_block.type).toBe('thinking')
|
||||
|
||||
const signatureDelta = events.find(
|
||||
event =>
|
||||
event.type === 'content_block_delta' &&
|
||||
event.delta.type === 'signature_delta',
|
||||
)
|
||||
expect(signatureDelta.delta.signature).toBe('sig-123')
|
||||
})
|
||||
|
||||
test('converts function calls to tool_use blocks', async () => {
|
||||
const events = await collectEvents([
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: 'bash',
|
||||
args: { command: 'ls' },
|
||||
},
|
||||
thoughtSignature: 'sig-tool',
|
||||
},
|
||||
],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
const blockStart = events.find(event => event.type === 'content_block_start')
|
||||
expect(blockStart.content_block.type).toBe('tool_use')
|
||||
expect(blockStart.content_block.name).toBe('bash')
|
||||
|
||||
const signatureDelta = events.find(
|
||||
event =>
|
||||
event.type === 'content_block_delta' &&
|
||||
event.delta.type === 'signature_delta',
|
||||
)
|
||||
expect(signatureDelta.delta.signature).toBe('sig-tool')
|
||||
|
||||
const inputDelta = events.find(
|
||||
event =>
|
||||
event.type === 'content_block_delta' &&
|
||||
event.delta.type === 'input_json_delta',
|
||||
)
|
||||
expect(inputDelta.delta.partial_json).toBe('{"command":"ls"}')
|
||||
|
||||
const messageDelta = events.find(event => event.type === 'message_delta')
|
||||
expect(messageDelta.delta.stop_reason).toBe('tool_use')
|
||||
})
|
||||
|
||||
test('maps usage metadata into output tokens', async () => {
|
||||
const events = await collectEvents([
|
||||
{
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'Hello' }],
|
||||
},
|
||||
finishReason: 'STOP',
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 10,
|
||||
candidatesTokenCount: 5,
|
||||
thoughtsTokenCount: 2,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const messageStart = events.find(event => event.type === 'message_start')
|
||||
expect(messageStart.message.usage.input_tokens).toBe(10)
|
||||
|
||||
const messageDelta = events.find(event => event.type === 'message_delta')
|
||||
expect(messageDelta.usage.output_tokens).toBe(7)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,307 @@
|
||||
import type {
|
||||
BetaToolResultBlockParam,
|
||||
BetaToolUseBlock,
|
||||
} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||
import type { AssistantMessage, UserMessage } from '../../types/message.js'
|
||||
import type { SystemPrompt } from '../../types/systemPrompt.js'
|
||||
import {
|
||||
GEMINI_THOUGHT_SIGNATURE_FIELD,
|
||||
type GeminiContent,
|
||||
type GeminiGenerateContentRequest,
|
||||
type GeminiPart,
|
||||
} from './types.js'
|
||||
|
||||
// Simple JSON parse utility (replaces safeParseJSON from main project)
|
||||
function safeParseJSON(json: string | null | undefined): unknown {
|
||||
if (!json) return null
|
||||
try {
|
||||
return JSON.parse(json)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function anthropicMessagesToGemini(
|
||||
messages: (UserMessage | AssistantMessage)[],
|
||||
systemPrompt: SystemPrompt,
|
||||
): Pick<GeminiGenerateContentRequest, 'contents' | 'systemInstruction'> {
|
||||
const contents: GeminiContent[] = []
|
||||
const toolNamesById = new Map<string, string>()
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.type === 'assistant') {
|
||||
const content = convertInternalAssistantMessage(msg)
|
||||
if (content.parts.length > 0) {
|
||||
contents.push(content)
|
||||
}
|
||||
|
||||
const assistantContent = msg.message.content
|
||||
if (Array.isArray(assistantContent)) {
|
||||
for (const block of assistantContent) {
|
||||
if (typeof block !== 'string' && block.type === 'tool_use') {
|
||||
toolNamesById.set(block.id, block.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (msg.type === 'user') {
|
||||
const content = convertInternalUserMessage(msg, toolNamesById)
|
||||
if (content.parts.length > 0) {
|
||||
contents.push(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const systemText = systemPromptToText(systemPrompt)
|
||||
|
||||
return {
|
||||
contents,
|
||||
...(systemText
|
||||
? {
|
||||
systemInstruction: {
|
||||
parts: [{ text: systemText }],
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
function systemPromptToText(systemPrompt: SystemPrompt): string {
|
||||
if (!systemPrompt || systemPrompt.length === 0) return ''
|
||||
return systemPrompt.filter(Boolean).join('\n\n')
|
||||
}
|
||||
|
||||
function convertInternalUserMessage(
|
||||
msg: UserMessage,
|
||||
toolNamesById: ReadonlyMap<string, string>,
|
||||
): GeminiContent {
|
||||
const content = msg.message.content
|
||||
|
||||
if (typeof content === 'string') {
|
||||
return {
|
||||
role: 'user',
|
||||
parts: createTextGeminiParts(content),
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return { role: 'user', parts: [] }
|
||||
}
|
||||
|
||||
return {
|
||||
role: 'user',
|
||||
parts: content.flatMap(block =>
|
||||
convertUserContentBlockToGeminiParts(block as unknown as string | Record<string, unknown>, toolNamesById),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function convertUserContentBlockToGeminiParts(
|
||||
block: string | Record<string, unknown>,
|
||||
toolNamesById: ReadonlyMap<string, string>,
|
||||
): GeminiPart[] {
|
||||
if (typeof block === 'string') {
|
||||
return createTextGeminiParts(block)
|
||||
}
|
||||
|
||||
if (block.type === 'text') {
|
||||
return createTextGeminiParts(block.text)
|
||||
}
|
||||
|
||||
if (block.type === 'tool_result') {
|
||||
const toolResult = block as unknown as BetaToolResultBlockParam
|
||||
return [
|
||||
{
|
||||
functionResponse: {
|
||||
name: toolNamesById.get(toolResult.tool_use_id) ?? toolResult.tool_use_id,
|
||||
response: toolResultToResponseObject(toolResult),
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// Convert Anthropic image blocks to Gemini inlineData
|
||||
if (block.type === 'image') {
|
||||
const source = block.source as Record<string, unknown> | undefined
|
||||
if (source?.type === 'base64' && typeof source.data === 'string') {
|
||||
const mediaType = (source.media_type as string) || 'image/png'
|
||||
return [
|
||||
{
|
||||
inlineData: {
|
||||
mimeType: mediaType,
|
||||
data: source.data,
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
// URL images not directly supported by Gemini, convert to text description
|
||||
if (source?.type === 'url' && typeof source.url === 'string') {
|
||||
return createTextGeminiParts(`[image: ${source.url}]`)
|
||||
}
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function convertInternalAssistantMessage(msg: AssistantMessage): GeminiContent {
|
||||
const content = msg.message.content
|
||||
|
||||
if (typeof content === 'string') {
|
||||
return {
|
||||
role: 'model',
|
||||
parts: createTextGeminiParts(content),
|
||||
}
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return { role: 'model', parts: [] }
|
||||
}
|
||||
|
||||
const parts: GeminiPart[] = []
|
||||
for (const block of content) {
|
||||
if (typeof block === 'string') {
|
||||
parts.push(...createTextGeminiParts(block))
|
||||
continue
|
||||
}
|
||||
|
||||
if (block.type === 'text') {
|
||||
parts.push(
|
||||
...createTextGeminiParts(
|
||||
block.text,
|
||||
getGeminiThoughtSignature(block as unknown as Record<string, unknown>),
|
||||
),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if (block.type === 'thinking') {
|
||||
const thinkingPart = createThinkingGeminiPart(
|
||||
block.thinking,
|
||||
block.signature,
|
||||
)
|
||||
if (thinkingPart) {
|
||||
parts.push(thinkingPart)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (block.type === 'tool_use') {
|
||||
const toolUse = block as unknown as BetaToolUseBlock
|
||||
parts.push({
|
||||
functionCall: {
|
||||
name: toolUse.name,
|
||||
args: normalizeToolUseInput(toolUse.input),
|
||||
},
|
||||
...(getGeminiThoughtSignature(block as unknown as Record<string, unknown>) && {
|
||||
thoughtSignature: getGeminiThoughtSignature(block as unknown as Record<string, unknown>),
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { role: 'model', parts }
|
||||
}
|
||||
|
||||
function createTextGeminiParts(
|
||||
value: unknown,
|
||||
thoughtSignature?: string,
|
||||
): GeminiPart[] {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
text: value,
|
||||
...(thoughtSignature && { thoughtSignature }),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function createThinkingGeminiPart(
|
||||
value: unknown,
|
||||
thoughtSignature?: string,
|
||||
): GeminiPart | undefined {
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
text: value,
|
||||
thought: true,
|
||||
...(thoughtSignature && { thoughtSignature }),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeToolUseInput(input: unknown): Record<string, unknown> {
|
||||
if (typeof input === 'string') {
|
||||
const parsed = safeParseJSON(input)
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
return parsed === null ? {} : { value: parsed }
|
||||
}
|
||||
|
||||
if (input && typeof input === 'object' && !Array.isArray(input)) {
|
||||
return input as Record<string, unknown>
|
||||
}
|
||||
|
||||
return input === undefined ? {} : { value: input }
|
||||
}
|
||||
|
||||
function toolResultToResponseObject(
|
||||
block: BetaToolResultBlockParam,
|
||||
): Record<string, unknown> {
|
||||
const result = normalizeToolResultContent(block.content)
|
||||
if (
|
||||
result &&
|
||||
typeof result === 'object' &&
|
||||
!Array.isArray(result)
|
||||
) {
|
||||
return block.is_error ? { ...(result as Record<string, unknown>), is_error: true } : result as Record<string, unknown>
|
||||
}
|
||||
|
||||
return {
|
||||
result,
|
||||
...(block.is_error ? { is_error: true } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeToolResultContent(content: unknown): unknown {
|
||||
if (typeof content === 'string') {
|
||||
const parsed = safeParseJSON(content)
|
||||
return parsed ?? content
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
const text = content
|
||||
.map(part => {
|
||||
if (typeof part === 'string') return part
|
||||
if (
|
||||
part &&
|
||||
typeof part === 'object' &&
|
||||
'text' in part &&
|
||||
typeof part.text === 'string'
|
||||
) {
|
||||
return part.text
|
||||
}
|
||||
return ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
const parsed = safeParseJSON(text)
|
||||
return parsed ?? text
|
||||
}
|
||||
|
||||
return content ?? ''
|
||||
}
|
||||
|
||||
function getGeminiThoughtSignature(block: Record<string, unknown>): string | undefined {
|
||||
const signature = block[GEMINI_THOUGHT_SIGNATURE_FIELD]
|
||||
return typeof signature === 'string' && signature.length > 0
|
||||
? signature
|
||||
: undefined
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||
import type {
|
||||
GeminiFunctionCallingConfig,
|
||||
GeminiTool,
|
||||
} from './types.js'
|
||||
|
||||
const GEMINI_JSON_SCHEMA_TYPES = new Set([
|
||||
'string',
|
||||
'number',
|
||||
'integer',
|
||||
'boolean',
|
||||
'object',
|
||||
'array',
|
||||
'null',
|
||||
])
|
||||
|
||||
function normalizeGeminiJsonSchemaType(
|
||||
value: unknown,
|
||||
): string | string[] | undefined {
|
||||
if (typeof value === 'string') {
|
||||
return GEMINI_JSON_SCHEMA_TYPES.has(value) ? value : undefined
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const normalized = value.filter(
|
||||
(item): item is string =>
|
||||
typeof item === 'string' && GEMINI_JSON_SCHEMA_TYPES.has(item),
|
||||
)
|
||||
const unique = Array.from(new Set(normalized))
|
||||
if (unique.length === 0) return undefined
|
||||
return unique.length === 1 ? unique[0] : unique
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function inferGeminiJsonSchemaTypeFromValue(value: unknown): string | undefined {
|
||||
if (value === null) return 'null'
|
||||
if (Array.isArray(value)) return 'array'
|
||||
if (typeof value === 'string') return 'string'
|
||||
if (typeof value === 'boolean') return 'boolean'
|
||||
if (typeof value === 'number') {
|
||||
return Number.isInteger(value) ? 'integer' : 'number'
|
||||
}
|
||||
if (typeof value === 'object') return 'object'
|
||||
return undefined
|
||||
}
|
||||
|
||||
function inferGeminiJsonSchemaTypeFromEnum(
|
||||
values: unknown[],
|
||||
): string | string[] | undefined {
|
||||
const inferred = values
|
||||
.map(inferGeminiJsonSchemaTypeFromValue)
|
||||
.filter((value): value is string => value !== undefined)
|
||||
const unique = Array.from(new Set(inferred))
|
||||
if (unique.length === 0) return undefined
|
||||
return unique.length === 1 ? unique[0] : unique
|
||||
}
|
||||
|
||||
function addNullToGeminiJsonSchemaType(
|
||||
value: string | string[] | undefined,
|
||||
): string | string[] | undefined {
|
||||
if (value === undefined) return ['null']
|
||||
if (Array.isArray(value)) {
|
||||
return value.includes('null') ? value : [...value, 'null']
|
||||
}
|
||||
return value === 'null' ? value : [value, 'null']
|
||||
}
|
||||
|
||||
function sanitizeGeminiJsonSchemaProperties(
|
||||
value: unknown,
|
||||
): Record<string, Record<string, unknown>> | undefined {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const sanitizedEntries = Object.entries(value as Record<string, unknown>)
|
||||
.map(([key, schema]) => [key, sanitizeGeminiJsonSchema(schema)] as const)
|
||||
.filter(([, schema]) => Object.keys(schema).length > 0)
|
||||
|
||||
if (sanitizedEntries.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return Object.fromEntries(sanitizedEntries)
|
||||
}
|
||||
|
||||
function sanitizeGeminiJsonSchemaArray(
|
||||
value: unknown,
|
||||
): Record<string, unknown>[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined
|
||||
|
||||
const sanitized = value
|
||||
.map(item => sanitizeGeminiJsonSchema(item))
|
||||
.filter(item => Object.keys(item).length > 0)
|
||||
|
||||
return sanitized.length > 0 ? sanitized : undefined
|
||||
}
|
||||
|
||||
function sanitizeGeminiJsonSchema(
|
||||
schema: unknown,
|
||||
): Record<string, unknown> {
|
||||
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const source = schema as Record<string, unknown>
|
||||
const result: Record<string, unknown> = {}
|
||||
|
||||
let type = normalizeGeminiJsonSchemaType(source.type)
|
||||
|
||||
if (source.const !== undefined) {
|
||||
result.enum = [source.const]
|
||||
type = type ?? inferGeminiJsonSchemaTypeFromValue(source.const)
|
||||
} else if (Array.isArray(source.enum) && source.enum.length > 0) {
|
||||
result.enum = source.enum
|
||||
type = type ?? inferGeminiJsonSchemaTypeFromEnum(source.enum)
|
||||
}
|
||||
|
||||
if (!type) {
|
||||
if (source.properties && typeof source.properties === 'object') {
|
||||
type = 'object'
|
||||
} else if (source.items !== undefined || source.prefixItems !== undefined) {
|
||||
type = 'array'
|
||||
}
|
||||
}
|
||||
|
||||
if (source.nullable === true) {
|
||||
type = addNullToGeminiJsonSchemaType(type)
|
||||
}
|
||||
|
||||
if (type) {
|
||||
result.type = type
|
||||
}
|
||||
|
||||
if (typeof source.title === 'string') {
|
||||
result.title = source.title
|
||||
}
|
||||
if (typeof source.description === 'string') {
|
||||
result.description = source.description
|
||||
}
|
||||
if (typeof source.format === 'string') {
|
||||
result.format = source.format
|
||||
}
|
||||
if (typeof source.pattern === 'string') {
|
||||
result.pattern = source.pattern
|
||||
}
|
||||
if (typeof source.minimum === 'number') {
|
||||
result.minimum = source.minimum
|
||||
} else if (typeof source.exclusiveMinimum === 'number') {
|
||||
result.minimum = source.exclusiveMinimum
|
||||
}
|
||||
if (typeof source.maximum === 'number') {
|
||||
result.maximum = source.maximum
|
||||
} else if (typeof source.exclusiveMaximum === 'number') {
|
||||
result.maximum = source.exclusiveMaximum
|
||||
}
|
||||
if (typeof source.minItems === 'number') {
|
||||
result.minItems = source.minItems
|
||||
}
|
||||
if (typeof source.maxItems === 'number') {
|
||||
result.maxItems = source.maxItems
|
||||
}
|
||||
if (typeof source.minLength === 'number') {
|
||||
result.minLength = source.minLength
|
||||
}
|
||||
if (typeof source.maxLength === 'number') {
|
||||
result.maxLength = source.maxLength
|
||||
}
|
||||
if (typeof source.minProperties === 'number') {
|
||||
result.minProperties = source.minProperties
|
||||
}
|
||||
if (typeof source.maxProperties === 'number') {
|
||||
result.maxProperties = source.maxProperties
|
||||
}
|
||||
|
||||
const properties = sanitizeGeminiJsonSchemaProperties(source.properties)
|
||||
if (properties) {
|
||||
result.properties = properties
|
||||
result.propertyOrdering = Object.keys(properties)
|
||||
}
|
||||
|
||||
if (Array.isArray(source.required)) {
|
||||
const required = source.required.filter(
|
||||
(item): item is string => typeof item === 'string',
|
||||
)
|
||||
if (required.length > 0) {
|
||||
result.required = required
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof source.additionalProperties === 'boolean') {
|
||||
result.additionalProperties = source.additionalProperties
|
||||
} else {
|
||||
const additionalProperties = sanitizeGeminiJsonSchema(
|
||||
source.additionalProperties,
|
||||
)
|
||||
if (Object.keys(additionalProperties).length > 0) {
|
||||
result.additionalProperties = additionalProperties
|
||||
}
|
||||
}
|
||||
|
||||
const items = sanitizeGeminiJsonSchema(source.items)
|
||||
if (Object.keys(items).length > 0) {
|
||||
result.items = items
|
||||
}
|
||||
|
||||
const prefixItems = sanitizeGeminiJsonSchemaArray(source.prefixItems)
|
||||
if (prefixItems) {
|
||||
result.prefixItems = prefixItems
|
||||
}
|
||||
|
||||
const anyOf = sanitizeGeminiJsonSchemaArray(source.anyOf ?? source.oneOf)
|
||||
if (anyOf) {
|
||||
result.anyOf = anyOf
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function sanitizeGeminiFunctionParameters(
|
||||
schema: unknown,
|
||||
): Record<string, unknown> {
|
||||
const sanitized = sanitizeGeminiJsonSchema(schema)
|
||||
if (Object.keys(sanitized).length > 0) {
|
||||
return sanitized
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
}
|
||||
}
|
||||
|
||||
export function anthropicToolsToGemini(tools: BetaToolUnion[]): GeminiTool[] {
|
||||
const functionDeclarations = tools
|
||||
.filter(tool => {
|
||||
const toolType = (tool as unknown as { type?: string }).type
|
||||
return tool.type === 'custom' || !('type' in tool) || toolType !== 'server'
|
||||
})
|
||||
.map(tool => {
|
||||
const anyTool = tool as unknown 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) ?? {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
parametersJsonSchema: sanitizeGeminiFunctionParameters(inputSchema),
|
||||
}
|
||||
})
|
||||
|
||||
return functionDeclarations.length > 0
|
||||
? [{ functionDeclarations }]
|
||||
: []
|
||||
}
|
||||
|
||||
export function anthropicToolChoiceToGemini(
|
||||
toolChoice: unknown,
|
||||
): GeminiFunctionCallingConfig | 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 { mode: 'AUTO' }
|
||||
case 'any':
|
||||
return { mode: 'ANY' }
|
||||
case 'tool':
|
||||
return {
|
||||
mode: 'ANY',
|
||||
allowedFunctionNames:
|
||||
typeof tc.name === 'string' ? [tc.name] : undefined,
|
||||
}
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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
|
||||
}
|
||||
|
||||
export function resolveGeminiModel(anthropicModel: string): string {
|
||||
if (process.env.GEMINI_MODEL) {
|
||||
return process.env.GEMINI_MODEL
|
||||
}
|
||||
|
||||
const cleanModel = anthropicModel.replace(/\[1m\]$/i, '')
|
||||
const family = getModelFamily(cleanModel)
|
||||
|
||||
if (!family) {
|
||||
return cleanModel
|
||||
}
|
||||
|
||||
const geminiEnvVar = `GEMINI_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||
const geminiModel = process.env[geminiEnvVar]
|
||||
if (geminiModel) {
|
||||
return geminiModel
|
||||
}
|
||||
|
||||
const sharedEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||
const resolvedModel = process.env[sharedEnvVar]
|
||||
if (resolvedModel) {
|
||||
return resolvedModel
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Gemini provider requires GEMINI_MODEL or ${geminiEnvVar} (or ${sharedEnvVar} for backward compatibility) to be configured.`,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import type { BetaRawMessageStreamEvent } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||
import { randomUUID } from 'crypto'
|
||||
import type { GeminiPart, GeminiStreamChunk } from './types.js'
|
||||
|
||||
export async function* adaptGeminiStreamToAnthropic(
|
||||
stream: AsyncIterable<GeminiStreamChunk>,
|
||||
model: string,
|
||||
): AsyncGenerator<BetaRawMessageStreamEvent, void> {
|
||||
const messageId = `msg_${randomUUID().replace(/-/g, '').slice(0, 24)}`
|
||||
let started = false
|
||||
let stopped = false
|
||||
let nextContentIndex = 0
|
||||
let openTextLikeBlock:
|
||||
| { index: number; type: 'text' | 'thinking' }
|
||||
| null = null
|
||||
let sawToolUse = false
|
||||
let finishReason: string | undefined
|
||||
let inputTokens = 0
|
||||
let outputTokens = 0
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const usage = chunk.usageMetadata
|
||||
if (usage) {
|
||||
inputTokens = usage.promptTokenCount ?? inputTokens
|
||||
outputTokens =
|
||||
(usage.candidatesTokenCount ?? 0) + (usage.thoughtsTokenCount ?? 0)
|
||||
}
|
||||
|
||||
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: 0,
|
||||
},
|
||||
},
|
||||
} as unknown as BetaRawMessageStreamEvent
|
||||
}
|
||||
const candidate = chunk.candidates?.[0]
|
||||
const parts = candidate?.content?.parts ?? []
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.functionCall) {
|
||||
if (openTextLikeBlock) {
|
||||
yield {
|
||||
type: 'content_block_stop',
|
||||
index: openTextLikeBlock.index,
|
||||
} as BetaRawMessageStreamEvent
|
||||
openTextLikeBlock = null
|
||||
}
|
||||
|
||||
sawToolUse = true
|
||||
const toolIndex = nextContentIndex++
|
||||
const toolId = `toolu_${randomUUID().replace(/-/g, '').slice(0, 24)}`
|
||||
yield {
|
||||
type: 'content_block_start',
|
||||
index: toolIndex,
|
||||
content_block: {
|
||||
type: 'tool_use',
|
||||
id: toolId,
|
||||
name: part.functionCall.name || '',
|
||||
input: {},
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
|
||||
if (part.thoughtSignature) {
|
||||
yield {
|
||||
type: 'content_block_delta',
|
||||
index: toolIndex,
|
||||
delta: {
|
||||
type: 'signature_delta',
|
||||
signature: part.thoughtSignature,
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
|
||||
if (part.functionCall.args && Object.keys(part.functionCall.args).length > 0) {
|
||||
yield {
|
||||
type: 'content_block_delta',
|
||||
index: toolIndex,
|
||||
delta: {
|
||||
type: 'input_json_delta',
|
||||
partial_json: JSON.stringify(part.functionCall.args),
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
|
||||
yield {
|
||||
type: 'content_block_stop',
|
||||
index: toolIndex,
|
||||
} as BetaRawMessageStreamEvent
|
||||
continue
|
||||
}
|
||||
|
||||
const textLikeType = getTextLikeBlockType(part)
|
||||
if (textLikeType) {
|
||||
if (!openTextLikeBlock || openTextLikeBlock.type !== textLikeType) {
|
||||
if (openTextLikeBlock) {
|
||||
yield {
|
||||
type: 'content_block_stop',
|
||||
index: openTextLikeBlock.index,
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
|
||||
openTextLikeBlock = {
|
||||
index: nextContentIndex++,
|
||||
type: textLikeType,
|
||||
}
|
||||
|
||||
yield {
|
||||
type: 'content_block_start',
|
||||
index: openTextLikeBlock.index,
|
||||
content_block:
|
||||
textLikeType === 'thinking'
|
||||
? {
|
||||
type: 'thinking',
|
||||
thinking: '',
|
||||
signature: '',
|
||||
}
|
||||
: {
|
||||
type: 'text',
|
||||
text: '',
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
|
||||
if (part.text) {
|
||||
yield {
|
||||
type: 'content_block_delta',
|
||||
index: openTextLikeBlock.index,
|
||||
delta:
|
||||
textLikeType === 'thinking'
|
||||
? {
|
||||
type: 'thinking_delta',
|
||||
thinking: part.text,
|
||||
}
|
||||
: {
|
||||
type: 'text_delta',
|
||||
text: part.text,
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
|
||||
if (part.thoughtSignature) {
|
||||
yield {
|
||||
type: 'content_block_delta',
|
||||
index: openTextLikeBlock.index,
|
||||
delta: {
|
||||
type: 'signature_delta',
|
||||
signature: part.thoughtSignature,
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.thoughtSignature && openTextLikeBlock) {
|
||||
yield {
|
||||
type: 'content_block_delta',
|
||||
index: openTextLikeBlock.index,
|
||||
delta: {
|
||||
type: 'signature_delta',
|
||||
signature: part.thoughtSignature,
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
}
|
||||
|
||||
if (candidate?.finishReason) {
|
||||
finishReason = candidate.finishReason
|
||||
}
|
||||
}
|
||||
|
||||
if (!started) {
|
||||
return
|
||||
}
|
||||
|
||||
if (openTextLikeBlock) {
|
||||
yield {
|
||||
type: 'content_block_stop',
|
||||
index: openTextLikeBlock.index,
|
||||
} as BetaRawMessageStreamEvent
|
||||
}
|
||||
|
||||
if (!stopped) {
|
||||
yield {
|
||||
type: 'message_delta',
|
||||
delta: {
|
||||
stop_reason: mapGeminiFinishReason(finishReason, sawToolUse),
|
||||
stop_sequence: null,
|
||||
},
|
||||
usage: {
|
||||
output_tokens: outputTokens,
|
||||
},
|
||||
} as BetaRawMessageStreamEvent
|
||||
|
||||
yield {
|
||||
type: 'message_stop',
|
||||
} as BetaRawMessageStreamEvent
|
||||
stopped = true
|
||||
}
|
||||
}
|
||||
|
||||
function getTextLikeBlockType(
|
||||
part: GeminiPart,
|
||||
): 'text' | 'thinking' | null {
|
||||
if (typeof part.text !== 'string') {
|
||||
return null
|
||||
}
|
||||
return part.thought ? 'thinking' : 'text'
|
||||
}
|
||||
|
||||
function mapGeminiFinishReason(
|
||||
reason: string | undefined,
|
||||
sawToolUse: boolean,
|
||||
): string {
|
||||
switch (reason) {
|
||||
case 'MAX_TOKENS':
|
||||
return 'max_tokens'
|
||||
case 'STOP':
|
||||
case 'FINISH_REASON_UNSPECIFIED':
|
||||
case 'SAFETY':
|
||||
case 'RECITATION':
|
||||
case 'BLOCKLIST':
|
||||
case 'PROHIBITED_CONTENT':
|
||||
case 'SPII':
|
||||
case 'MALFORMED_FUNCTION_CALL':
|
||||
default:
|
||||
return sawToolUse ? 'tool_use' : 'end_turn'
|
||||
}
|
||||
}
|
||||
86
packages/@ant/model-provider/src/providers/gemini/types.ts
Normal file
86
packages/@ant/model-provider/src/providers/gemini/types.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
export const GEMINI_THOUGHT_SIGNATURE_FIELD = '_geminiThoughtSignature'
|
||||
|
||||
export type GeminiFunctionCall = {
|
||||
name?: string
|
||||
args?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type GeminiFunctionResponse = {
|
||||
name?: string
|
||||
response?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type GeminiInlineData = {
|
||||
mimeType: string
|
||||
data: string
|
||||
}
|
||||
|
||||
export type GeminiPart = {
|
||||
text?: string
|
||||
thought?: boolean
|
||||
thoughtSignature?: string
|
||||
functionCall?: GeminiFunctionCall
|
||||
functionResponse?: GeminiFunctionResponse
|
||||
inlineData?: GeminiInlineData
|
||||
}
|
||||
|
||||
export type GeminiContent = {
|
||||
role: 'user' | 'model'
|
||||
parts: GeminiPart[]
|
||||
}
|
||||
|
||||
export type GeminiFunctionDeclaration = {
|
||||
name: string
|
||||
description?: string
|
||||
parameters?: Record<string, unknown>
|
||||
parametersJsonSchema?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type GeminiTool = {
|
||||
functionDeclarations: GeminiFunctionDeclaration[]
|
||||
}
|
||||
|
||||
export type GeminiFunctionCallingConfig = {
|
||||
mode: 'AUTO' | 'ANY' | 'NONE'
|
||||
allowedFunctionNames?: string[]
|
||||
}
|
||||
|
||||
export type GeminiGenerateContentRequest = {
|
||||
contents: GeminiContent[]
|
||||
systemInstruction?: {
|
||||
parts: Array<{ text: string }>
|
||||
}
|
||||
tools?: GeminiTool[]
|
||||
toolConfig?: {
|
||||
functionCallingConfig: GeminiFunctionCallingConfig
|
||||
}
|
||||
generationConfig?: {
|
||||
temperature?: number
|
||||
thinkingConfig?: {
|
||||
includeThoughts?: boolean
|
||||
thinkingBudget?: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type GeminiUsageMetadata = {
|
||||
promptTokenCount?: number
|
||||
candidatesTokenCount?: number
|
||||
thoughtsTokenCount?: number
|
||||
totalTokenCount?: number
|
||||
}
|
||||
|
||||
export type GeminiCandidate = {
|
||||
content?: {
|
||||
role?: string
|
||||
parts?: GeminiPart[]
|
||||
}
|
||||
finishReason?: string
|
||||
index?: number
|
||||
}
|
||||
|
||||
export type GeminiStreamChunk = {
|
||||
candidates?: GeminiCandidate[]
|
||||
usageMetadata?: GeminiUsageMetadata
|
||||
modelVersion?: string
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
|
||||
import { resolveGrokModel } from '../modelMapping.js'
|
||||
|
||||
describe('resolveGrokModel', () => {
|
||||
const originalEnv = { ...process.env }
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.GROK_MODEL
|
||||
delete process.env.GROK_MODEL_MAP
|
||||
delete process.env.GROK_DEFAULT_SONNET_MODEL
|
||||
delete process.env.GROK_DEFAULT_OPUS_MODEL
|
||||
delete process.env.GROK_DEFAULT_HAIKU_MODEL
|
||||
delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL
|
||||
delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL
|
||||
delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv }
|
||||
})
|
||||
|
||||
test('GROK_MODEL env var takes highest priority', () => {
|
||||
process.env.GROK_MODEL = 'grok-custom'
|
||||
expect(resolveGrokModel('claude-sonnet-4-6')).toBe('grok-custom')
|
||||
})
|
||||
|
||||
test('maps opus models to grok-4.20-reasoning', () => {
|
||||
expect(resolveGrokModel('claude-opus-4-6')).toBe('grok-4.20-reasoning')
|
||||
})
|
||||
|
||||
test('maps sonnet models to grok-3-mini-fast', () => {
|
||||
expect(resolveGrokModel('claude-sonnet-4-6')).toBe('grok-3-mini-fast')
|
||||
})
|
||||
|
||||
test('maps haiku models to grok-3-mini-fast', () => {
|
||||
expect(resolveGrokModel('claude-haiku-4-5-20251001')).toBe('grok-3-mini-fast')
|
||||
})
|
||||
|
||||
test('GROK_MODEL_MAP overrides family mapping', () => {
|
||||
process.env.GROK_MODEL_MAP = '{"opus":"grok-4","sonnet":"grok-3","haiku":"grok-mini"}'
|
||||
expect(resolveGrokModel('claude-opus-4-6')).toBe('grok-4')
|
||||
expect(resolveGrokModel('claude-sonnet-4-6')).toBe('grok-3')
|
||||
expect(resolveGrokModel('claude-haiku-4-5-20251001')).toBe('grok-mini')
|
||||
})
|
||||
|
||||
test('GROK_MODEL_MAP ignores invalid JSON', () => {
|
||||
process.env.GROK_MODEL_MAP = 'not-json'
|
||||
expect(resolveGrokModel('claude-opus-4-6')).toBe('grok-4.20-reasoning')
|
||||
})
|
||||
|
||||
test('GROK_DEFAULT_{FAMILY}_MODEL overrides default map', () => {
|
||||
process.env.GROK_DEFAULT_OPUS_MODEL = 'grok-2-latest'
|
||||
expect(resolveGrokModel('claude-opus-4-6')).toBe('grok-2-latest')
|
||||
})
|
||||
|
||||
test('passes through unknown model names', () => {
|
||||
expect(resolveGrokModel('some-unknown-model')).toBe('some-unknown-model')
|
||||
})
|
||||
|
||||
test('strips [1m] suffix before lookup', () => {
|
||||
expect(resolveGrokModel('claude-sonnet-4-6[1m]')).toBe('grok-3-mini-fast')
|
||||
})
|
||||
|
||||
test('falls back to family default for unlisted model', () => {
|
||||
expect(resolveGrokModel('claude-opus-99-20300101')).toBe('grok-4.20-reasoning')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Default mapping from Anthropic model names to Grok model names.
|
||||
*
|
||||
* Users can override per-family via GROK_DEFAULT_{FAMILY}_MODEL env vars,
|
||||
* or override the entire mapping via GROK_MODEL_MAP env var (JSON string).
|
||||
*/
|
||||
const DEFAULT_MODEL_MAP: Record<string, string> = {
|
||||
'claude-sonnet-4-20250514': 'grok-3-mini-fast',
|
||||
'claude-sonnet-4-5-20250929': 'grok-3-mini-fast',
|
||||
'claude-sonnet-4-6': 'grok-3-mini-fast',
|
||||
'claude-opus-4-20250514': 'grok-4.20-reasoning',
|
||||
'claude-opus-4-1-20250805': 'grok-4.20-reasoning',
|
||||
'claude-opus-4-5-20251101': 'grok-4.20-reasoning',
|
||||
'claude-opus-4-6': 'grok-4.20-reasoning',
|
||||
'claude-haiku-4-5-20251001': 'grok-3-mini-fast',
|
||||
'claude-3-5-haiku-20241022': 'grok-3-mini-fast',
|
||||
'claude-3-7-sonnet-20250219': 'grok-3-mini-fast',
|
||||
'claude-3-5-sonnet-20241022': 'grok-3-mini-fast',
|
||||
}
|
||||
|
||||
const DEFAULT_FAMILY_MAP: Record<string, string> = {
|
||||
opus: 'grok-4.20-reasoning',
|
||||
sonnet: 'grok-3-mini-fast',
|
||||
haiku: 'grok-3-mini-fast',
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function getUserModelMap(): Record<string, string> | null {
|
||||
const raw = process.env.GROK_MODEL_MAP
|
||||
if (!raw) return null
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, string>
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid JSON
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Grok model name for a given Anthropic model.
|
||||
*/
|
||||
export function resolveGrokModel(anthropicModel: string): string {
|
||||
if (process.env.GROK_MODEL) {
|
||||
return process.env.GROK_MODEL
|
||||
}
|
||||
|
||||
const cleanModel = anthropicModel.replace(/\[1m\]$/, '')
|
||||
const family = getModelFamily(cleanModel)
|
||||
|
||||
const userMap = getUserModelMap()
|
||||
if (userMap && family && userMap[family]) {
|
||||
return userMap[family]
|
||||
}
|
||||
|
||||
if (family) {
|
||||
const grokEnvVar = `GROK_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||
const grokOverride = process.env[grokEnvVar]
|
||||
if (grokOverride) return grokOverride
|
||||
|
||||
const anthropicEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||
const anthropicOverride = process.env[anthropicEnvVar]
|
||||
if (anthropicOverride) return anthropicOverride
|
||||
}
|
||||
|
||||
if (DEFAULT_MODEL_MAP[cleanModel]) {
|
||||
return DEFAULT_MODEL_MAP[cleanModel]
|
||||
}
|
||||
|
||||
if (family && DEFAULT_FAMILY_MAP[family]) {
|
||||
return DEFAULT_FAMILY_MAP[family]
|
||||
}
|
||||
|
||||
return cleanModel
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
|
||||
import { resolveOpenAIModel } from '../modelMapping.js'
|
||||
|
||||
describe('resolveOpenAIModel', () => {
|
||||
const originalEnv = {
|
||||
OPENAI_MODEL: process.env.OPENAI_MODEL,
|
||||
OPENAI_DEFAULT_HAIKU_MODEL: process.env.OPENAI_DEFAULT_HAIKU_MODEL,
|
||||
OPENAI_DEFAULT_SONNET_MODEL: process.env.OPENAI_DEFAULT_SONNET_MODEL,
|
||||
OPENAI_DEFAULT_OPUS_MODEL: process.env.OPENAI_DEFAULT_OPUS_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.OPENAI_DEFAULT_HAIKU_MODEL
|
||||
delete process.env.OPENAI_DEFAULT_SONNET_MODEL
|
||||
delete process.env.OPENAI_DEFAULT_OPUS_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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 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',
|
||||
}
|
||||
|
||||
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. OPENAI_DEFAULT_{FAMILY}_MODEL env var (e.g. OPENAI_DEFAULT_SONNET_MODEL)
|
||||
* 3. ANTHROPIC_DEFAULT_{FAMILY}_MODEL env var (backward compatibility)
|
||||
* 4. DEFAULT_MODEL_MAP lookup
|
||||
* 5. Pass through original model name
|
||||
*/
|
||||
export function resolveOpenAIModel(anthropicModel: string): string {
|
||||
if (process.env.OPENAI_MODEL) {
|
||||
return process.env.OPENAI_MODEL
|
||||
}
|
||||
|
||||
const cleanModel = anthropicModel.replace(/\[1m\]$/, '')
|
||||
|
||||
const family = getModelFamily(cleanModel)
|
||||
if (family) {
|
||||
const openaiEnvVar = `OPENAI_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||
const openaiOverride = process.env[openaiEnvVar]
|
||||
if (openaiOverride) return openaiOverride
|
||||
|
||||
const anthropicEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
|
||||
const anthropicOverride = process.env[anthropicEnvVar]
|
||||
if (anthropicOverride) return anthropicOverride
|
||||
}
|
||||
|
||||
return DEFAULT_MODEL_MAP[cleanModel] ?? cleanModel
|
||||
}
|
||||
Reference in New Issue
Block a user