feat(proxy): add multi-protocol proxy for OpenAI-compatible providers

Add a protocol-translating reverse proxy that allows using OpenAI-compatible
API providers (DeepSeek, OpenRouter, Groq, etc.) with Claude Code.

The proxy intercepts Anthropic Messages API requests from the CLI, transforms
them to OpenAI Chat Completions or Responses API format, forwards to the
upstream provider, and transforms streaming/non-streaming responses back.

Key features:
- Request transform: Anthropic Messages → OpenAI Chat/Responses
- Response transform: OpenAI → Anthropic (streaming SSE + non-streaming)
- Provider-agnostic reasoning support (reasoning_content, thinking_blocks,
  reasoning fields from DeepSeek, OpenAI o-series, GLM-5, Groq, etc.)
- Event queue pattern for correct Anthropic SSE event ordering
- Two-step test: ① connectivity check ② full proxy pipeline validation
- Desktop UI: API format selector, two-step test results display
- License attribution for cc-switch (MIT, Jason Young)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-10 00:33:26 +08:00
parent 85969318b9
commit 98a24f99b4
23 changed files with 3018 additions and 59 deletions

View File

@ -44,8 +44,8 @@ export const providersApi = {
return api.post<{ ok: true }>('/api/providers/official')
},
test(id: string) {
return api.post<TestResultResponse>(`/api/providers/${id}/test`)
test(id: string, overrides?: { baseUrl?: string; modelId?: string; apiFormat?: string }) {
return api.post<TestResultResponse>(`/api/providers/${id}/test`, overrides)
},
testConfig(input: TestProviderConfigInput) {

View File

@ -1,6 +1,8 @@
// Provider presets inspired by cc-switch (https://github.com/farion1231/cc-switch)
// Original work by Jason Young, MIT License
import type { ApiFormat } from '../types/provider'
export type ModelMapping = {
main: string
haiku: string
@ -12,6 +14,7 @@ export type ProviderPreset = {
id: string
name: string
baseUrl: string
apiFormat: ApiFormat
defaultModels: ModelMapping
needsApiKey: boolean
websiteUrl: string
@ -22,6 +25,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
id: 'official',
name: 'Claude Official',
baseUrl: '',
apiFormat: 'anthropic',
defaultModels: { main: '', haiku: '', sonnet: '', opus: '' },
needsApiKey: false,
websiteUrl: 'https://www.anthropic.com/claude-code',
@ -30,6 +34,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
id: 'deepseek',
name: 'DeepSeek',
baseUrl: 'https://api.deepseek.com/anthropic',
apiFormat: 'anthropic',
defaultModels: { main: 'DeepSeek-V3.2', haiku: 'DeepSeek-V3.2', sonnet: 'DeepSeek-V3.2', opus: 'DeepSeek-V3.2' },
needsApiKey: true,
websiteUrl: 'https://platform.deepseek.com',
@ -38,6 +43,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
id: 'zhipuglm',
name: 'Zhipu GLM',
baseUrl: 'https://open.bigmodel.cn/api/anthropic',
apiFormat: 'anthropic',
defaultModels: { main: 'glm-5', haiku: 'glm-5', sonnet: 'glm-5', opus: 'glm-5' },
needsApiKey: true,
websiteUrl: 'https://open.bigmodel.cn',
@ -46,6 +52,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
id: 'kimi',
name: 'Kimi',
baseUrl: 'https://api.moonshot.cn/anthropic',
apiFormat: 'anthropic',
defaultModels: { main: 'kimi-k2.5', haiku: 'kimi-k2.5', sonnet: 'kimi-k2.5', opus: 'kimi-k2.5' },
needsApiKey: true,
websiteUrl: 'https://platform.moonshot.cn',
@ -54,6 +61,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
id: 'minimax',
name: 'MiniMax',
baseUrl: 'https://api.minimaxi.com/anthropic',
apiFormat: 'anthropic',
defaultModels: { main: 'MiniMax-M2.7', haiku: 'MiniMax-M2.7', sonnet: 'MiniMax-M2.7', opus: 'MiniMax-M2.7' },
needsApiKey: true,
websiteUrl: 'https://platform.minimaxi.com',
@ -62,6 +70,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
id: 'custom',
name: 'Custom',
baseUrl: '',
apiFormat: 'anthropic',
defaultModels: { main: '', haiku: '', sonnet: '', opus: '' },
needsApiKey: true,
websiteUrl: '',

View File

@ -57,6 +57,10 @@ export const en = {
'settings.providers.officialDesc': 'Anthropic native — no API key required',
'settings.providers.connected': 'Connected ({latency}ms)',
'settings.providers.failed': 'Failed: {error}',
'settings.providers.connectivityOk': '① Connectivity ({latency}ms)',
'settings.providers.connectivityFailed': '① Connectivity failed: {error}',
'settings.providers.proxyOk': '② Proxy pipeline ({latency}ms)',
'settings.providers.proxyFailed': '② Proxy failed: {error}',
'settings.providers.confirmDelete': 'Delete provider "{name}"? This cannot be undone.',
'settings.providers.activate': 'Activate',
'settings.providers.test': 'Test',
@ -83,6 +87,11 @@ export const en = {
'settings.providers.settingsJson': 'Settings JSON',
'settings.providers.settingsJsonDesc': '~/.claude/settings.json — edit directly, will be written on save.',
'settings.providers.jsonError': 'JSON syntax error: {error}',
'settings.providers.apiFormat': 'API Format',
'settings.providers.apiFormatAnthropic': 'Anthropic Messages (native)',
'settings.providers.apiFormatOpenaiChat': 'OpenAI Chat Completions (proxy)',
'settings.providers.apiFormatOpenaiResponses': 'OpenAI Responses API (proxy)',
'settings.providers.proxyHint': 'Requests will be translated via the local proxy',
// Settings > Permissions
'settings.permissions.title': 'Permission Mode',

View File

@ -59,6 +59,10 @@ export const zh: Record<TranslationKey, string> = {
'settings.providers.officialDesc': 'Anthropic 原生接入 — 无需 API 密钥',
'settings.providers.connected': '已连接 ({latency}ms)',
'settings.providers.failed': '失败: {error}',
'settings.providers.connectivityOk': '① 连通 ({latency}ms)',
'settings.providers.connectivityFailed': '① 连通失败: {error}',
'settings.providers.proxyOk': '② 代理转换 ({latency}ms)',
'settings.providers.proxyFailed': '② 代理转换失败: {error}',
'settings.providers.confirmDelete': '删除服务商 "{name}"?此操作不可撤销。',
'settings.providers.activate': '激活',
'settings.providers.test': '测试',
@ -85,6 +89,11 @@ export const zh: Record<TranslationKey, string> = {
'settings.providers.settingsJson': '设置 JSON',
'settings.providers.settingsJsonDesc': '~/.claude/settings.json — 直接编辑,保存时写入。',
'settings.providers.jsonError': 'JSON 语法错误: {error}',
'settings.providers.apiFormat': 'API 格式',
'settings.providers.apiFormatAnthropic': 'Anthropic Messages (原生)',
'settings.providers.apiFormatOpenaiChat': 'OpenAI Chat Completions (代理转换)',
'settings.providers.apiFormatOpenaiResponses': 'OpenAI Responses API (代理转换)',
'settings.providers.proxyHint': '请求将通过本地代理转换协议格式',
// Settings > Permissions
'settings.permissions.title': '权限模式',

View File

@ -9,7 +9,7 @@ import type { PermissionMode, EffortLevel } from '../types/settings'
import type { Locale } from '../i18n'
import { PROVIDER_PRESETS } from '../config/providerPresets'
import type { ProviderPreset } from '../config/providerPresets'
import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping } from '../types/provider'
import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat } from '../types/provider'
import { AdapterSettings } from './AdapterSettings'
import { useAgentStore } from '../stores/agentStore'
import { useSessionStore } from '../stores/sessionStore'
@ -92,7 +92,7 @@ function ProviderSettings() {
const result = await testProvider(provider.id)
setTestResults((r) => ({ ...r, [provider.id]: { loading: false, result } }))
} catch {
setTestResults((r) => ({ ...r, [provider.id]: { loading: false, result: { success: false, latencyMs: 0, error: t('settings.providers.requestFailed') } } }))
setTestResults((r) => ({ ...r, [provider.id]: { loading: false, result: { connectivity: { success: false, latencyMs: 0, error: t('settings.providers.requestFailed') } } } }))
}
}
@ -169,6 +169,11 @@ function ProviderSettings() {
{preset && preset.id !== 'custom' && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)] leading-none">{preset.name}</span>
)}
{provider.apiFormat && provider.apiFormat !== 'anthropic' && (
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-[var(--color-surface-container-high)] text-[var(--color-warning)] leading-none">
{provider.apiFormat === 'openai_chat' ? 'OpenAI Chat' : 'OpenAI Responses'}
</span>
)}
{isActive && (
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-[var(--color-brand)] text-white leading-none">{t('common.active')}</span>
)}
@ -177,8 +182,19 @@ function ProviderSettings() {
{provider.baseUrl} &middot; {provider.models.main}
</div>
{test && !test.loading && test.result && (
<div className={`text-xs mt-1 ${test.result.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
{test.result.success ? t('settings.providers.connected', { latency: String(test.result.latencyMs) }) : t('settings.providers.failed', { error: test.result.error || '' })}
<div className="text-xs mt-1 flex flex-col gap-0.5">
<span className={test.result.connectivity.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}>
{test.result.connectivity.success
? t('settings.providers.connectivityOk', { latency: String(test.result.connectivity.latencyMs) })
: t('settings.providers.connectivityFailed', { error: test.result.connectivity.error || '' })}
</span>
{test.result.proxy && (
<span className={test.result.proxy.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}>
{test.result.proxy.success
? t('settings.providers.proxyOk', { latency: String(test.result.proxy.latencyMs) })
: t('settings.providers.proxyFailed', { error: test.result.proxy.error || '' })}
</span>
)}
</div>
)}
</div>
@ -245,6 +261,7 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
const [selectedPreset, setSelectedPreset] = useState<ProviderPreset>(initialPreset)
const [name, setName] = useState(provider?.name ?? initialPreset.name)
const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? initialPreset.baseUrl)
const [apiFormat, setApiFormat] = useState<ApiFormat>(provider?.apiFormat ?? initialPreset.apiFormat ?? 'anthropic')
const [apiKey, setApiKey] = useState('')
const [notes, setNotes] = useState(provider?.notes ?? '')
const [models, setModels] = useState<ModelMapping>(provider?.models ?? { ...initialPreset.defaultModels })
@ -264,12 +281,13 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
}
import('../api/settings').then(({ settingsApi }) => {
settingsApi.getUser().then((settings) => {
const needsProxy = apiFormat !== 'anthropic'
const merged = {
...settings,
env: {
...((settings.env as Record<string, string>) || {}),
ANTHROPIC_BASE_URL: baseUrl,
ANTHROPIC_AUTH_TOKEN: apiKey || '(your API key)',
ANTHROPIC_BASE_URL: needsProxy ? 'http://127.0.0.1:3456/proxy' : baseUrl,
ANTHROPIC_AUTH_TOKEN: needsProxy ? 'proxy-managed' : (apiKey || '(your API key)'),
ANTHROPIC_MODEL: models.main,
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
@ -288,6 +306,7 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
setSelectedPreset(preset)
setName(preset.name)
setBaseUrl(preset.baseUrl)
setApiFormat(preset.apiFormat ?? 'anthropic')
setModels({ ...preset.defaultModels })
setTestResult(null)
}
@ -316,6 +335,7 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
name: name.trim(),
apiKey: apiKey.trim(),
baseUrl: baseUrl.trim(),
apiFormat,
models,
notes: notes.trim() || undefined,
})
@ -323,6 +343,7 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
const input: UpdateProviderInput = {
name: name.trim(),
baseUrl: baseUrl.trim(),
apiFormat,
models,
notes: notes.trim() || undefined,
}
@ -345,14 +366,18 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
try {
let result: ProviderTestResult
if (mode === 'edit' && provider && !apiKey.trim()) {
result = await useProviderStore.getState().testProvider(provider.id)
result = await useProviderStore.getState().testProvider(provider.id, {
baseUrl: baseUrl.trim(),
modelId: models.main.trim(),
apiFormat,
})
} else {
if (!apiKey.trim()) return
result = await testConfig({ baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), modelId: models.main.trim() })
result = await testConfig({ baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), modelId: models.main.trim(), apiFormat })
}
setTestResult(result)
} catch {
setTestResult({ success: false, latencyMs: 0, error: t('settings.providers.requestFailed') })
setTestResult({ connectivity: { success: false, latencyMs: 0, error: t('settings.providers.requestFailed') } })
} finally {
setIsTesting(false)
}
@ -412,6 +437,32 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
</div>
)}
{/* API Format */}
{(isCustom || mode === 'edit') ? (
<div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-1 block">{t('settings.providers.apiFormat')}</label>
<select
value={apiFormat}
onChange={(e) => setApiFormat(e.target.value as ApiFormat)}
className="w-full text-sm px-3 py-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container-low)] border border-[var(--color-border)] text-[var(--color-text-primary)] outline-none focus:border-[var(--color-border-focus)]"
>
<option value="anthropic">{t('settings.providers.apiFormatAnthropic')}</option>
<option value="openai_chat">{t('settings.providers.apiFormatOpenaiChat')}</option>
<option value="openai_responses">{t('settings.providers.apiFormatOpenaiResponses')}</option>
</select>
{apiFormat !== 'anthropic' && (
<p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">{t('settings.providers.proxyHint')}</p>
)}
</div>
) : apiFormat !== 'anthropic' ? (
<div>
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-1 block">{t('settings.providers.apiFormat')}</label>
<div className="text-xs text-[var(--color-text-tertiary)] px-3 py-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container-low)] border border-[var(--color-border)]">
{apiFormat === 'openai_chat' ? t('settings.providers.apiFormatOpenaiChat') : t('settings.providers.apiFormatOpenaiResponses')}
</div>
</div>
) : null}
<Input
label={mode === 'edit' ? t('settings.providers.apiKeyKeep') : t('settings.providers.apiKey')}
required={mode === 'create'}
@ -438,9 +489,20 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
{t('settings.providers.testConnection')}
</Button>
{testResult && (
<span className={`text-xs ${testResult.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
{testResult.success ? t('settings.providers.connected', { latency: String(testResult.latencyMs) }) : t('settings.providers.failed', { error: testResult.error || '' })}
</span>
<div className="flex flex-col gap-0.5">
<span className={`text-xs ${testResult.connectivity.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
{testResult.connectivity.success
? t('settings.providers.connectivityOk', { latency: String(testResult.connectivity.latencyMs) })
: t('settings.providers.connectivityFailed', { error: testResult.connectivity.error || '' })}
</span>
{testResult.proxy && (
<span className={`text-xs ${testResult.proxy.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
{testResult.proxy.success
? t('settings.providers.proxyOk', { latency: String(testResult.proxy.latencyMs) })
: t('settings.providers.proxyFailed', { error: testResult.proxy.error || '' })}
</span>
)}
</div>
)}
</div>

View File

@ -21,7 +21,7 @@ type ProviderStore = {
deleteProvider: (id: string) => Promise<void>
activateProvider: (id: string) => Promise<void>
activateOfficial: () => Promise<void>
testProvider: (id: string) => Promise<ProviderTestResult>
testProvider: (id: string, overrides?: { baseUrl?: string; modelId?: string; apiFormat?: string }) => Promise<ProviderTestResult>
testConfig: (input: TestProviderConfigInput) => Promise<ProviderTestResult>
}
@ -67,8 +67,8 @@ export const useProviderStore = create<ProviderStore>((set, get) => ({
await get().fetchProviders()
},
testProvider: async (id) => {
const { result } = await providersApi.test(id)
testProvider: async (id, overrides?) => {
const { result } = await providersApi.test(id, overrides)
return result
},

View File

@ -1,5 +1,7 @@
// desktop/src/types/provider.ts
export type ApiFormat = 'anthropic' | 'openai_chat' | 'openai_responses'
export type ModelMapping = {
main: string
haiku: string
@ -13,6 +15,7 @@ export type SavedProvider = {
name: string
apiKey: string // masked from server
baseUrl: string
apiFormat: ApiFormat
models: ModelMapping
notes?: string
}
@ -22,6 +25,7 @@ export type CreateProviderInput = {
name: string
apiKey: string
baseUrl: string
apiFormat?: ApiFormat
models: ModelMapping
notes?: string
}
@ -30,6 +34,7 @@ export type UpdateProviderInput = {
name?: string
apiKey?: string
baseUrl?: string
apiFormat?: ApiFormat
models?: ModelMapping
notes?: string
}
@ -38,12 +43,20 @@ export type TestProviderConfigInput = {
baseUrl: string
apiKey: string
modelId: string
apiFormat?: ApiFormat
}
export type ProviderTestResult = {
export type ProviderTestStepResult = {
success: boolean
latencyMs: number
error?: string
modelUsed?: string
httpStatus?: number
}
export type ProviderTestResult = {
/** Step 1: Basic connectivity */
connectivity: ProviderTestStepResult
/** Step 2: Proxy pipeline (only for openai_* formats) */
proxy?: ProviderTestStepResult
}

View File

@ -0,0 +1,317 @@
/**
* Unit tests for proxy streaming SSE transformation
*/
import { describe, test, expect } from 'bun:test'
import { openaiChatStreamToAnthropic } from '../proxy/streaming/openaiChatStreamToAnthropic.js'
import { openaiResponsesStreamToAnthropic } from '../proxy/streaming/openaiResponsesStreamToAnthropic.js'
// ─── Helpers ────────────────────────────────────────────────────
function makeStream(chunks: string[]): ReadableStream<Uint8Array> {
const encoder = new TextEncoder()
return new ReadableStream({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk))
}
controller.close()
},
})
}
async function collectSse(stream: ReadableStream<Uint8Array>): Promise<Array<{ event: string; data: Record<string, unknown> }>> {
const decoder = new TextDecoder()
const reader = stream.getReader()
let text = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
text += decoder.decode(value, { stream: true })
}
const events: Array<{ event: string; data: Record<string, unknown> }> = []
const blocks = text.split('\n\n').filter(Boolean)
for (const block of blocks) {
const lines = block.split('\n')
let event = ''
let data = ''
for (const line of lines) {
if (line.startsWith('event: ')) event = line.slice(7)
if (line.startsWith('data: ')) data = line.slice(6)
}
if (event && data) {
try {
events.push({ event, data: JSON.parse(data) })
} catch {
// skip unparseable
}
}
}
return events
}
// ─── OpenAI Chat Completions SSE → Anthropic SSE ───────────────
describe('openaiChatStreamToAnthropic', () => {
test('basic text streaming', async () => {
const sseChunks = [
'data: {"id":"c1","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}\n\n',
'data: {"id":"c1","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}\n\n',
'data: {"id":"c1","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"content":" world"},"finish_reason":null}]}\n\n',
'data: {"id":"c1","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n',
'data: [DONE]\n\n',
]
const upstream = makeStream(sseChunks)
const anthropicStream = openaiChatStreamToAnthropic(upstream, 'gpt-4')
const events = await collectSse(anthropicStream)
// Should have: message_start, content_block_start, content_block_delta x2, message_delta, content_block_stop, message_stop
const eventTypes = events.map((e) => e.event)
expect(eventTypes[0]).toBe('message_start')
expect(eventTypes).toContain('content_block_start')
expect(eventTypes).toContain('content_block_delta')
expect(eventTypes).toContain('message_delta')
expect(eventTypes).toContain('message_stop')
// Check message_start
const msgStart = events.find((e) => e.event === 'message_start')!
expect((msgStart.data.message as Record<string, unknown>).model).toBe('gpt-4')
expect((msgStart.data.message as Record<string, unknown>).role).toBe('assistant')
// Check text deltas
const textDeltas = events.filter((e) => e.event === 'content_block_delta')
const texts = textDeltas.map((e) => (e.data.delta as Record<string, unknown>).text)
expect(texts).toContain('Hello')
expect(texts).toContain(' world')
// Check stop reason
const msgDelta = events.find((e) => e.event === 'message_delta')!
expect((msgDelta.data.delta as Record<string, unknown>).stop_reason).toBe('end_turn')
})
test('tool call streaming', async () => {
const sseChunks = [
'data: {"id":"c2","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"role":"assistant","content":null},"finish_reason":null}]}\n\n',
'data: {"id":"c2","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":null}]}\n\n',
'data: {"id":"c2","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"city\\""}}]},"finish_reason":null}]}\n\n',
'data: {"id":"c2","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":":\\"NYC\\"}"}}]},"finish_reason":null}]}\n\n',
'data: {"id":"c2","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}\n\n',
'data: [DONE]\n\n',
]
const upstream = makeStream(sseChunks)
const anthropicStream = openaiChatStreamToAnthropic(upstream, 'gpt-4')
const events = await collectSse(anthropicStream)
// Should have content_block_start with type tool_use
const toolStart = events.find(
(e) => e.event === 'content_block_start' && (e.data.content_block as Record<string, unknown>)?.type === 'tool_use',
)
expect(toolStart).toBeDefined()
expect((toolStart!.data.content_block as Record<string, unknown>).name).toBe('get_weather')
expect((toolStart!.data.content_block as Record<string, unknown>).id).toBe('call_1')
// Should have input_json_delta
const jsonDeltas = events.filter(
(e) => e.event === 'content_block_delta' && (e.data.delta as Record<string, unknown>)?.type === 'input_json_delta',
)
expect(jsonDeltas.length).toBeGreaterThan(0)
// Stop reason should be tool_use
const msgDelta = events.find((e) => e.event === 'message_delta')!
expect((msgDelta.data.delta as Record<string, unknown>).stop_reason).toBe('tool_use')
})
test('empty stream (just DONE)', async () => {
const upstream = makeStream(['data: [DONE]\n\n'])
const anthropicStream = openaiChatStreamToAnthropic(upstream, 'gpt-4')
const events = await collectSse(anthropicStream)
// Should at least have message_stop
expect(events.some((e) => e.event === 'message_stop')).toBe(true)
})
test('event ordering: content_block_stop before message_delta', async () => {
const sseChunks = [
'data: {"id":"c3","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}\n\n',
'data: {"id":"c3","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":null}]}\n\n',
'data: {"id":"c3","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n',
'data: [DONE]\n\n',
]
const upstream = makeStream(sseChunks)
const events = await collectSse(openaiChatStreamToAnthropic(upstream, 'gpt-4'))
const types = events.map((e) => e.event)
// content_block_stop MUST appear before message_delta
const stopIdx = types.indexOf('content_block_stop')
const deltaIdx = types.indexOf('message_delta')
expect(stopIdx).toBeGreaterThan(-1)
expect(deltaIdx).toBeGreaterThan(-1)
expect(stopIdx).toBeLessThan(deltaIdx)
// message_delta before message_stop
const msgStopIdx = types.indexOf('message_stop')
expect(deltaIdx).toBeLessThan(msgStopIdx)
})
test('reasoning_content (DeepSeek, OpenRouter, XAI)', async () => {
const sseChunks = [
'data: {"id":"c4","object":"chat.completion.chunk","created":0,"model":"deepseek-chat","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning_content":"Let me think"},"finish_reason":null}]}\n\n',
'data: {"id":"c4","object":"chat.completion.chunk","created":0,"model":"deepseek-chat","choices":[{"index":0,"delta":{"reasoning_content":" about this..."},"finish_reason":null}]}\n\n',
'data: {"id":"c4","object":"chat.completion.chunk","created":0,"model":"deepseek-chat","choices":[{"index":0,"delta":{"content":"Hello!"},"finish_reason":null}]}\n\n',
'data: {"id":"c4","object":"chat.completion.chunk","created":0,"model":"deepseek-chat","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n',
'data: [DONE]\n\n',
]
const upstream = makeStream(sseChunks)
const events = await collectSse(openaiChatStreamToAnthropic(upstream, 'deepseek-chat'))
// Should have thinking block
const thinkingStart = events.find(
(e) => e.event === 'content_block_start' && (e.data.content_block as Record<string, unknown>)?.type === 'thinking',
)
expect(thinkingStart).toBeDefined()
// Should have thinking deltas
const thinkingDeltas = events.filter(
(e) => e.event === 'content_block_delta' && (e.data.delta as Record<string, unknown>)?.type === 'thinking_delta',
)
expect(thinkingDeltas.length).toBeGreaterThan(0)
// Should have text block after thinking
const textStart = events.find(
(e) => e.event === 'content_block_start' && (e.data.content_block as Record<string, unknown>)?.type === 'text',
)
expect(textStart).toBeDefined()
// Text should come after thinking in index order
expect((textStart!.data as Record<string, unknown>).index).toBeGreaterThan(
(thinkingStart!.data as Record<string, unknown>).index as number,
)
})
test('reasoning field (GLM-5, Cerebras, Groq)', async () => {
const sseChunks = [
'data: {"id":"c5","object":"chat.completion.chunk","created":0,"model":"glm-5","choices":[{"index":0,"delta":{"role":"assistant","reasoning":"Thinking here"},"finish_reason":null}]}\n\n',
'data: {"id":"c5","object":"chat.completion.chunk","created":0,"model":"glm-5","choices":[{"index":0,"delta":{"content":"Result"},"finish_reason":null}]}\n\n',
'data: {"id":"c5","object":"chat.completion.chunk","created":0,"model":"glm-5","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n',
'data: [DONE]\n\n',
]
const upstream = makeStream(sseChunks)
const events = await collectSse(openaiChatStreamToAnthropic(upstream, 'glm-5'))
// Should produce thinking delta from "reasoning" field
const thinkingDeltas = events.filter(
(e) => e.event === 'content_block_delta' && (e.data.delta as Record<string, unknown>)?.type === 'thinking_delta',
)
expect(thinkingDeltas.length).toBe(1)
expect((thinkingDeltas[0].data.delta as Record<string, unknown>).thinking).toBe('Thinking here')
})
test('thinking_blocks (OpenAI o-series)', async () => {
const sseChunks = [
'data: {"id":"c6","object":"chat.completion.chunk","created":0,"model":"o3","choices":[{"index":0,"delta":{"role":"assistant","thinking_blocks":[{"type":"thinking","thinking":"Deep thought"}]},"finish_reason":null}]}\n\n',
'data: {"id":"c6","object":"chat.completion.chunk","created":0,"model":"o3","choices":[{"index":0,"delta":{"content":"Answer"},"finish_reason":null}]}\n\n',
'data: {"id":"c6","object":"chat.completion.chunk","created":0,"model":"o3","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n',
'data: [DONE]\n\n',
]
const upstream = makeStream(sseChunks)
const events = await collectSse(openaiChatStreamToAnthropic(upstream, 'o3'))
const thinkingDeltas = events.filter(
(e) => e.event === 'content_block_delta' && (e.data.delta as Record<string, unknown>)?.type === 'thinking_delta',
)
expect(thinkingDeltas.length).toBe(1)
expect((thinkingDeltas[0].data.delta as Record<string, unknown>).thinking).toBe('Deep thought')
})
test('text + tool transition closes text block first', async () => {
const sseChunks = [
'data: {"id":"c7","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"content":"Let me search"},"finish_reason":null}]}\n\n',
'data: {"id":"c7","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_x","type":"function","function":{"name":"search","arguments":"{\\"q\\":\\"test\\"}"}}]},"finish_reason":null}]}\n\n',
'data: {"id":"c7","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}\n\n',
'data: [DONE]\n\n',
]
const upstream = makeStream(sseChunks)
const events = await collectSse(openaiChatStreamToAnthropic(upstream, 'gpt-4'))
const types = events.map((e) => e.event)
// Should see: text block start, text delta, text block stop, tool block start, ...
const firstBlockStop = types.indexOf('content_block_stop')
const toolBlockStart = types.findIndex(
(_, i) => events[i].event === 'content_block_start' && (events[i].data.content_block as Record<string, unknown>)?.type === 'tool_use',
)
expect(firstBlockStop).toBeLessThan(toolBlockStart)
})
})
// ─── OpenAI Responses SSE → Anthropic SSE ──────────────────────
describe('openaiResponsesStreamToAnthropic', () => {
test('basic text streaming', async () => {
const sseChunks = [
'event: response.created\ndata: {"id":"r1","model":"gpt-4o","status":"in_progress"}\n\n',
'event: response.output_item.added\ndata: {"output_index":0,"item":{"type":"message","role":"assistant"}}\n\n',
'event: response.content_part.added\ndata: {"output_index":0,"content_index":0,"part":{"type":"output_text","text":""}}\n\n',
'event: response.output_text.delta\ndata: {"output_index":0,"content_index":0,"delta":"Hello"}\n\n',
'event: response.output_text.delta\ndata: {"output_index":0,"content_index":0,"delta":" world"}\n\n',
'event: response.output_text.done\ndata: {"output_index":0,"content_index":0,"text":"Hello world"}\n\n',
'event: response.completed\ndata: {"response":{"id":"r1","model":"gpt-4o","status":"completed","usage":{"input_tokens":10,"output_tokens":5}}}\n\n',
]
const upstream = makeStream(sseChunks)
const anthropicStream = openaiResponsesStreamToAnthropic(upstream, 'gpt-4o')
const events = await collectSse(anthropicStream)
const eventTypes = events.map((e) => e.event)
expect(eventTypes[0]).toBe('message_start')
expect(eventTypes).toContain('content_block_start')
expect(eventTypes).toContain('content_block_delta')
expect(eventTypes).toContain('content_block_stop')
expect(eventTypes).toContain('message_delta')
expect(eventTypes).toContain('message_stop')
// Check text deltas
const textDeltas = events.filter((e) => e.event === 'content_block_delta')
const texts = textDeltas.map((e) => (e.data.delta as Record<string, unknown>).text)
expect(texts).toContain('Hello')
expect(texts).toContain(' world')
})
test('function call streaming', async () => {
const sseChunks = [
'event: response.created\ndata: {"id":"r2","model":"gpt-4o","status":"in_progress"}\n\n',
'event: response.output_item.added\ndata: {"output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"search"}}\n\n',
'event: response.function_call_arguments.delta\ndata: {"item_id":"fc_1","delta":"{\\"q\\":"}\n\n',
'event: response.function_call_arguments.delta\ndata: {"item_id":"fc_1","delta":"\\"test\\"}"}\n\n',
'event: response.function_call_arguments.done\ndata: {"item_id":"fc_1","arguments":"{\\"q\\":\\"test\\"}"}\n\n',
'event: response.completed\ndata: {"response":{"id":"r2","model":"gpt-4o","status":"completed","usage":{"input_tokens":10,"output_tokens":5}}}\n\n',
]
const upstream = makeStream(sseChunks)
const anthropicStream = openaiResponsesStreamToAnthropic(upstream, 'gpt-4o')
const events = await collectSse(anthropicStream)
// Should have tool_use content_block_start
const toolStart = events.find(
(e) => e.event === 'content_block_start' && (e.data.content_block as Record<string, unknown>)?.type === 'tool_use',
)
expect(toolStart).toBeDefined()
expect((toolStart!.data.content_block as Record<string, unknown>).name).toBe('search')
// Should have input_json_delta
const jsonDeltas = events.filter(
(e) => e.event === 'content_block_delta' && (e.data.delta as Record<string, unknown>)?.type === 'input_json_delta',
)
expect(jsonDeltas.length).toBeGreaterThan(0)
// Stop reason should be tool_use
const msgDelta = events.find((e) => e.event === 'message_delta')!
expect((msgDelta.data.delta as Record<string, unknown>).stop_reason).toBe('tool_use')
})
})

View File

@ -0,0 +1,469 @@
/**
* Unit tests for proxy protocol transformation
*/
import { describe, test, expect } from 'bun:test'
import { anthropicToOpenaiChat } from '../proxy/transform/anthropicToOpenaiChat.js'
import { anthropicToOpenaiResponses } from '../proxy/transform/anthropicToOpenaiResponses.js'
import { openaiChatToAnthropic } from '../proxy/transform/openaiChatToAnthropic.js'
import { openaiResponsesToAnthropic } from '../proxy/transform/openaiResponsesToAnthropic.js'
import type { AnthropicRequest, OpenAIChatResponse, OpenAIResponsesResponse } from '../proxy/transform/types.js'
// ─── anthropicToOpenaiChat ──────────────────────────────────────
describe('anthropicToOpenaiChat', () => {
test('basic text message', () => {
const req: AnthropicRequest = {
model: 'gpt-4',
max_tokens: 1024,
messages: [{ role: 'user', content: 'Hello' }],
}
const result = anthropicToOpenaiChat(req)
expect(result.model).toBe('gpt-4')
expect(result.max_tokens).toBeUndefined()
expect(result.messages).toEqual([{ role: 'user', content: 'Hello' }])
})
test('system prompt string', () => {
const req: AnthropicRequest = {
model: 'gpt-4',
max_tokens: 100,
system: 'You are helpful',
messages: [{ role: 'user', content: 'Hi' }],
}
const result = anthropicToOpenaiChat(req)
expect(result.messages[0]).toEqual({ role: 'system', content: 'You are helpful' })
expect(result.messages[1]).toEqual({ role: 'user', content: 'Hi' })
})
test('system prompt array', () => {
const req: AnthropicRequest = {
model: 'gpt-4',
max_tokens: 100,
system: [{ type: 'text', text: 'Part 1' }, { type: 'text', text: 'Part 2' }],
messages: [{ role: 'user', content: 'Hi' }],
}
const result = anthropicToOpenaiChat(req)
expect(result.messages[0]).toEqual({ role: 'system', content: 'Part 1\nPart 2' })
})
test('stop_sequences → stop', () => {
const req: AnthropicRequest = {
model: 'gpt-4',
max_tokens: 100,
stop_sequences: ['END', 'STOP'],
messages: [{ role: 'user', content: 'Hi' }],
}
const result = anthropicToOpenaiChat(req)
expect(result.stop).toEqual(['END', 'STOP'])
})
test('tools conversion', () => {
const req: AnthropicRequest = {
model: 'gpt-4',
max_tokens: 100,
messages: [{ role: 'user', content: 'Hi' }],
tools: [{
name: 'get_weather',
description: 'Get weather',
input_schema: { type: 'object', properties: { city: { type: 'string' } } },
}],
}
const result = anthropicToOpenaiChat(req)
expect(result.tools).toHaveLength(1)
expect(result.tools![0].type).toBe('function')
expect(result.tools![0].function.name).toBe('get_weather')
expect(result.tools![0].function.parameters).toEqual({ type: 'object', properties: { city: { type: 'string' } } })
})
test('filters BatchTool', () => {
const req: AnthropicRequest = {
model: 'gpt-4',
max_tokens: 100,
messages: [{ role: 'user', content: 'Hi' }],
tools: [
{ name: 'BatchTool', input_schema: {} },
{ name: 'real_tool', input_schema: {} },
],
}
const result = anthropicToOpenaiChat(req)
expect(result.tools).toHaveLength(1)
expect(result.tools![0].function.name).toBe('real_tool')
})
test('tool_choice conversion', () => {
const req: AnthropicRequest = {
model: 'gpt-4',
max_tokens: 100,
messages: [{ role: 'user', content: 'Hi' }],
tool_choice: { type: 'any' },
}
const result = anthropicToOpenaiChat(req)
expect(result.tool_choice).toBe('required')
})
test('tool_choice type=tool', () => {
const req: AnthropicRequest = {
model: 'gpt-4',
max_tokens: 100,
messages: [{ role: 'user', content: 'Hi' }],
tool_choice: { type: 'tool', name: 'get_weather' },
}
const result = anthropicToOpenaiChat(req)
expect(result.tool_choice).toEqual({ type: 'function', function: { name: 'get_weather' } })
})
test('thinking budget → reasoning_effort', () => {
const lowReq: AnthropicRequest = {
model: 'gpt-4',
max_tokens: 100,
messages: [{ role: 'user', content: 'Hi' }],
thinking: { type: 'enabled', budget_tokens: 512 },
}
expect(anthropicToOpenaiChat(lowReq).reasoning_effort).toBe('low')
const medReq: AnthropicRequest = {
model: 'gpt-4',
max_tokens: 100,
messages: [{ role: 'user', content: 'Hi' }],
thinking: { type: 'enabled', budget_tokens: 4096 },
}
expect(anthropicToOpenaiChat(medReq).reasoning_effort).toBe('medium')
const highReq: AnthropicRequest = {
model: 'gpt-4',
max_tokens: 100,
messages: [{ role: 'user', content: 'Hi' }],
thinking: { type: 'enabled', budget_tokens: 16000 },
}
expect(anthropicToOpenaiChat(highReq).reasoning_effort).toBe('high')
})
test('assistant message with tool_use', () => {
const req: AnthropicRequest = {
model: 'gpt-4',
max_tokens: 100,
messages: [{
role: 'assistant',
content: [
{ type: 'text', text: 'Let me check' },
{ type: 'tool_use', id: 'tc_1', name: 'get_weather', input: { city: 'NYC' } },
],
}],
}
const result = anthropicToOpenaiChat(req)
const msg = result.messages[0]
expect(msg.role).toBe('assistant')
expect(msg.content).toBe('Let me check')
expect(msg.tool_calls).toHaveLength(1)
expect(msg.tool_calls![0].id).toBe('tc_1')
expect(msg.tool_calls![0].function.name).toBe('get_weather')
expect(msg.tool_calls![0].function.arguments).toBe('{"city":"NYC"}')
})
test('user message with tool_result', () => {
const req: AnthropicRequest = {
model: 'gpt-4',
max_tokens: 100,
messages: [{
role: 'user',
content: [
{ type: 'tool_result', tool_use_id: 'tc_1', content: 'Sunny, 72°F' },
],
}],
}
const result = anthropicToOpenaiChat(req)
expect(result.messages[0].role).toBe('tool')
expect(result.messages[0].tool_call_id).toBe('tc_1')
expect(result.messages[0].content).toBe('Sunny, 72°F')
})
test('image content conversion', () => {
const req: AnthropicRequest = {
model: 'gpt-4',
max_tokens: 100,
messages: [{
role: 'user',
content: [
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'abc123' } },
],
}],
}
const result = anthropicToOpenaiChat(req)
const content = result.messages[0].content as Array<{ type: string; image_url?: { url: string } }>
expect(content[0].type).toBe('image_url')
expect(content[0].image_url!.url).toBe('data:image/png;base64,abc123')
})
})
// ─── openaiChatToAnthropic ──────────────────────────────────────
describe('openaiChatToAnthropic', () => {
test('basic text response', () => {
const res: OpenAIChatResponse = {
id: 'chatcmpl-1',
object: 'chat.completion',
created: 1234567890,
model: 'gpt-4',
choices: [{
index: 0,
message: { role: 'assistant', content: 'Hello!' },
finish_reason: 'stop',
}],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
}
const result = openaiChatToAnthropic(res, 'gpt-4')
expect(result.type).toBe('message')
expect(result.role).toBe('assistant')
expect(result.content).toEqual([{ type: 'text', text: 'Hello!' }])
expect(result.stop_reason).toBe('end_turn')
expect(result.usage.input_tokens).toBe(10)
expect(result.usage.output_tokens).toBe(5)
})
test('tool_calls response', () => {
const res: OpenAIChatResponse = {
id: 'chatcmpl-2',
object: 'chat.completion',
created: 1234567890,
model: 'gpt-4',
choices: [{
index: 0,
message: {
role: 'assistant',
content: null,
tool_calls: [{
id: 'call_1',
type: 'function',
function: { name: 'get_weather', arguments: '{"city":"NYC"}' },
}],
},
finish_reason: 'tool_calls',
}],
}
const result = openaiChatToAnthropic(res, 'gpt-4')
expect(result.stop_reason).toBe('tool_use')
expect(result.content).toHaveLength(1)
expect(result.content[0].type).toBe('tool_use')
if (result.content[0].type === 'tool_use') {
expect(result.content[0].id).toBe('call_1')
expect(result.content[0].name).toBe('get_weather')
expect(result.content[0].input).toEqual({ city: 'NYC' })
}
})
test('finish_reason mapping', () => {
const make = (reason: string) => ({
id: 'x', object: 'chat.completion', created: 0, model: 'gpt-4',
choices: [{ index: 0, message: { role: 'assistant', content: 'hi' }, finish_reason: reason }],
} as OpenAIChatResponse)
expect(openaiChatToAnthropic(make('stop'), 'gpt-4').stop_reason).toBe('end_turn')
expect(openaiChatToAnthropic(make('length'), 'gpt-4').stop_reason).toBe('max_tokens')
expect(openaiChatToAnthropic(make('tool_calls'), 'gpt-4').stop_reason).toBe('tool_use')
expect(openaiChatToAnthropic(make('content_filter'), 'gpt-4').stop_reason).toBe('end_turn')
})
test('empty choices', () => {
const res: OpenAIChatResponse = {
id: 'x', object: 'chat.completion', created: 0, model: 'gpt-4',
choices: [],
}
const result = openaiChatToAnthropic(res, 'gpt-4')
expect(result.content).toEqual([{ type: 'text', text: '' }])
expect(result.stop_reason).toBe('end_turn')
})
test('cached tokens mapping', () => {
const res: OpenAIChatResponse = {
id: 'x', object: 'chat.completion', created: 0, model: 'gpt-4',
choices: [{ index: 0, message: { role: 'assistant', content: 'hi' }, finish_reason: 'stop' }],
usage: {
prompt_tokens: 100,
completion_tokens: 50,
total_tokens: 150,
prompt_tokens_details: { cached_tokens: 80 },
},
}
const result = openaiChatToAnthropic(res, 'gpt-4')
expect(result.usage.cache_read_input_tokens).toBe(80)
})
})
// ─── anthropicToOpenaiResponses ─────────────────────────────────
describe('anthropicToOpenaiResponses', () => {
test('basic message', () => {
const req: AnthropicRequest = {
model: 'gpt-4o',
max_tokens: 1024,
system: 'Be helpful',
messages: [{ role: 'user', content: 'Hello' }],
}
const result = anthropicToOpenaiResponses(req)
expect(result.model).toBe('gpt-4o')
expect(result.instructions).toBe('Be helpful')
expect(result.max_output_tokens).toBeUndefined()
expect(result.input).toEqual([{ type: 'message', role: 'user', content: 'Hello' }])
})
test('tool_use lifted to function_call', () => {
const req: AnthropicRequest = {
model: 'gpt-4o',
max_tokens: 100,
messages: [{
role: 'assistant',
content: [
{ type: 'tool_use', id: 'tc_1', name: 'search', input: { q: 'test' } },
],
}],
}
const result = anthropicToOpenaiResponses(req)
const fc = result.input.find((i) => i.type === 'function_call')
expect(fc).toBeDefined()
if (fc && fc.type === 'function_call') {
expect(fc.call_id).toBe('tc_1')
expect(fc.name).toBe('search')
expect(fc.arguments).toBe('{"q":"test"}')
}
})
test('tool_result lifted to function_call_output', () => {
const req: AnthropicRequest = {
model: 'gpt-4o',
max_tokens: 100,
messages: [{
role: 'user',
content: [
{ type: 'tool_result', tool_use_id: 'tc_1', content: 'found it' },
],
}],
}
const result = anthropicToOpenaiResponses(req)
const fco = result.input.find((i) => i.type === 'function_call_output')
expect(fco).toBeDefined()
if (fco && fco.type === 'function_call_output') {
expect(fco.call_id).toBe('tc_1')
expect(fco.output).toBe('found it')
}
})
test('thinking → reasoning', () => {
const req: AnthropicRequest = {
model: 'gpt-4o',
max_tokens: 100,
messages: [{ role: 'user', content: 'Hi' }],
thinking: { type: 'enabled', budget_tokens: 10000 },
}
const result = anthropicToOpenaiResponses(req)
expect(result.reasoning).toEqual({ effort: 'high' })
})
test('stop_sequences dropped', () => {
const req: AnthropicRequest = {
model: 'gpt-4o',
max_tokens: 100,
messages: [{ role: 'user', content: 'Hi' }],
stop_sequences: ['END'],
}
const result = anthropicToOpenaiResponses(req)
expect((result as Record<string, unknown>).stop).toBeUndefined()
expect((result as Record<string, unknown>).stop_sequences).toBeUndefined()
})
})
// ─── openaiResponsesToAnthropic ─────────────────────────────────
describe('openaiResponsesToAnthropic', () => {
test('basic text response', () => {
const res: OpenAIResponsesResponse = {
id: 'resp_1',
object: 'response',
created_at: 1234567890,
model: 'gpt-4o',
status: 'completed',
output: [{
type: 'message',
role: 'assistant',
content: [{ type: 'output_text', text: 'Hello!' }],
}],
usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 },
}
const result = openaiResponsesToAnthropic(res, 'gpt-4o')
expect(result.content).toEqual([{ type: 'text', text: 'Hello!' }])
expect(result.stop_reason).toBe('end_turn')
expect(result.usage.input_tokens).toBe(10)
expect(result.usage.output_tokens).toBe(5)
})
test('function_call → tool_use', () => {
const res: OpenAIResponsesResponse = {
id: 'resp_2',
object: 'response',
created_at: 0,
model: 'gpt-4o',
status: 'completed',
output: [{
type: 'function_call',
id: 'fc_1',
call_id: 'call_1',
name: 'search',
arguments: '{"q":"test"}',
}],
}
const result = openaiResponsesToAnthropic(res, 'gpt-4o')
expect(result.stop_reason).toBe('tool_use')
expect(result.content[0].type).toBe('tool_use')
if (result.content[0].type === 'tool_use') {
expect(result.content[0].id).toBe('call_1')
expect(result.content[0].input).toEqual({ q: 'test' })
}
})
test('reasoning → thinking', () => {
const res: OpenAIResponsesResponse = {
id: 'resp_3',
object: 'response',
created_at: 0,
model: 'gpt-4o',
status: 'completed',
output: [
{ type: 'reasoning', id: 'r_1', summary: [{ type: 'text', text: 'Thinking...' }] },
{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'Result' }] },
],
}
const result = openaiResponsesToAnthropic(res, 'gpt-4o')
expect(result.content).toHaveLength(2)
expect(result.content[0].type).toBe('thinking')
if (result.content[0].type === 'thinking') {
expect(result.content[0].thinking).toBe('Thinking...')
}
expect(result.content[1].type).toBe('text')
})
test('status incomplete → max_tokens', () => {
const res: OpenAIResponsesResponse = {
id: 'resp_4',
object: 'response',
created_at: 0,
model: 'gpt-4o',
status: 'incomplete',
output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'partial' }] }],
}
const result = openaiResponsesToAnthropic(res, 'gpt-4o')
expect(result.stop_reason).toBe('max_tokens')
})
test('empty output', () => {
const res: OpenAIResponsesResponse = {
id: 'resp_5',
object: 'response',
created_at: 0,
model: 'gpt-4o',
status: 'completed',
output: [],
}
const result = openaiResponsesToAnthropic(res, 'gpt-4o')
expect(result.content).toEqual([{ type: 'text', text: '' }])
})
})

View File

@ -83,7 +83,12 @@ export async function handleProvidersApi(
// /api/providers/:id/test
if (action === 'test') {
if (req.method !== 'POST') throw methodNotAllowed(req.method)
const result = await providerService.testProvider(id)
let overrides: { baseUrl?: string; modelId?: string; apiFormat?: string } | undefined
try {
const body = await req.json()
if (body && typeof body === 'object') overrides = body as typeof overrides
} catch { /* no body is fine — uses saved values */ }
const result = await providerService.testProvider(id, overrides)
return Response.json({ result })
}

View File

@ -1,6 +1,8 @@
// Provider presets inspired by cc-switch (https://github.com/farion1231/cc-switch)
// Original work by Jason Young, MIT License
import type { ApiFormat } from '../types/provider.js'
export type ModelMapping = {
main: string
haiku: string
@ -12,6 +14,7 @@ export type ProviderPreset = {
id: string
name: string
baseUrl: string
apiFormat: ApiFormat
defaultModels: ModelMapping
needsApiKey: boolean
websiteUrl: string
@ -22,6 +25,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
id: 'official',
name: 'Claude Official',
baseUrl: '',
apiFormat: 'anthropic',
defaultModels: { main: '', haiku: '', sonnet: '', opus: '' },
needsApiKey: false,
websiteUrl: 'https://www.anthropic.com/claude-code',
@ -30,6 +34,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
id: 'deepseek',
name: 'DeepSeek',
baseUrl: 'https://api.deepseek.com/anthropic',
apiFormat: 'anthropic',
defaultModels: { main: 'DeepSeek-V3.2', haiku: 'DeepSeek-V3.2', sonnet: 'DeepSeek-V3.2', opus: 'DeepSeek-V3.2' },
needsApiKey: true,
websiteUrl: 'https://platform.deepseek.com',
@ -38,6 +43,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
id: 'zhipuglm',
name: 'Zhipu GLM',
baseUrl: 'https://open.bigmodel.cn/api/anthropic',
apiFormat: 'anthropic',
defaultModels: { main: 'glm-5', haiku: 'glm-5', sonnet: 'glm-5', opus: 'glm-5' },
needsApiKey: true,
websiteUrl: 'https://open.bigmodel.cn',
@ -46,6 +52,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
id: 'kimi',
name: 'Kimi',
baseUrl: 'https://api.moonshot.cn/anthropic',
apiFormat: 'anthropic',
defaultModels: { main: 'kimi-k2.5', haiku: 'kimi-k2.5', sonnet: 'kimi-k2.5', opus: 'kimi-k2.5' },
needsApiKey: true,
websiteUrl: 'https://platform.moonshot.cn',
@ -54,6 +61,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
id: 'minimax',
name: 'MiniMax',
baseUrl: 'https://api.minimaxi.com/anthropic',
apiFormat: 'anthropic',
defaultModels: { main: 'MiniMax-M2.7', haiku: 'MiniMax-M2.7', sonnet: 'MiniMax-M2.7', opus: 'MiniMax-M2.7' },
needsApiKey: true,
websiteUrl: 'https://platform.minimaxi.com',
@ -62,6 +70,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
id: 'custom',
name: 'Custom',
baseUrl: '',
apiFormat: 'anthropic',
defaultModels: { main: '', haiku: '', sonnet: '', opus: '' },
needsApiKey: true,
websiteUrl: '',

View File

@ -11,6 +11,8 @@ import { corsHeaders } from './middleware/cors.js'
import { requireAuth } from './middleware/auth.js'
import { teamWatcher } from './services/teamWatcher.js'
import { cronScheduler } from './services/cronScheduler.js'
import { handleProxyRequest } from './proxy/handler.js'
import { ProviderService } from './services/providerService.js'
function readArgValue(flag: string): string | undefined {
const args = process.argv.slice(2)
@ -42,6 +44,7 @@ const PORT = SERVER_OPTIONS.port
const HOST = SERVER_OPTIONS.host
export function startServer(port = PORT, host = HOST) {
ProviderService.setServerPort(port)
const localConnectHost =
host === '0.0.0.0' || host === '127.0.0.1' || host === 'localhost'
? '127.0.0.1'
@ -159,6 +162,37 @@ export function startServer(port = PORT, host = HOST) {
}
}
// Proxy — protocol-translating reverse proxy for OpenAI-compatible APIs
if (url.pathname.startsWith('/proxy/')) {
if (authRequired) {
const authError = requireAuth(req)
if (authError) {
const headers = new Headers(authError.headers)
for (const [key, value] of Object.entries(corsHeaders(origin))) {
headers.set(key, value)
}
return new Response(authError.body, { status: authError.status, headers })
}
}
try {
const response = await handleProxyRequest(req, url)
const headers = new Headers(response.headers)
for (const [key, value] of Object.entries(corsHeaders(origin))) {
headers.set(key, value)
}
return new Response(response.body, {
status: response.status,
headers,
})
} catch (error) {
console.error('[Server] Proxy error:', error)
return Response.json(
{ type: 'error', error: { type: 'api_error', message: 'Internal proxy error' } },
{ status: 500, headers: corsHeaders() },
)
}
}
// Health check
if (url.pathname === '/health') {
return Response.json(

View File

@ -0,0 +1,28 @@
# CC Switch License Attribution
The protocol transformation logic in this directory is derived from
[cc-switch](https://github.com/farion1231/cc-switch) by Jason Young.
Original work licensed under the MIT License:
MIT License
Copyright (c) 2025 Jason Young
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

195
src/server/proxy/handler.ts Normal file
View File

@ -0,0 +1,195 @@
/**
* Proxy Handler protocol-translating reverse proxy for OpenAI-compatible APIs.
*
* Receives Anthropic Messages API requests from the CLI, transforms them to
* OpenAI Chat Completions or Responses API format, forwards to the upstream
* provider, and transforms the response back to Anthropic format.
*
* Derived from cc-switch (https://github.com/farion1231/cc-switch)
* Original work by Jason Young, MIT License
*/
import { ProviderService } from '../services/providerService.js'
import { anthropicToOpenaiChat } from './transform/anthropicToOpenaiChat.js'
import { anthropicToOpenaiResponses } from './transform/anthropicToOpenaiResponses.js'
import { openaiChatToAnthropic } from './transform/openaiChatToAnthropic.js'
import { openaiResponsesToAnthropic } from './transform/openaiResponsesToAnthropic.js'
import { openaiChatStreamToAnthropic } from './streaming/openaiChatStreamToAnthropic.js'
import { openaiResponsesStreamToAnthropic } from './streaming/openaiResponsesStreamToAnthropic.js'
import type { AnthropicRequest } from './transform/types.js'
const providerService = new ProviderService()
export async function handleProxyRequest(req: Request, url: URL): Promise<Response> {
// Only handle POST /proxy/v1/messages
if (req.method !== 'POST' || url.pathname !== '/proxy/v1/messages') {
return Response.json(
{ error: 'Not Found', message: 'Proxy only handles POST /proxy/v1/messages' },
{ status: 404 },
)
}
// Read active provider config
const config = await providerService.getActiveProviderForProxy()
if (!config) {
return Response.json(
{ type: 'error', error: { type: 'invalid_request_error', message: 'No active provider configured for proxy' } },
{ status: 400 },
)
}
if (config.apiFormat === 'anthropic') {
return Response.json(
{ type: 'error', error: { type: 'invalid_request_error', message: 'Active provider uses anthropic format — proxy not needed' } },
{ status: 400 },
)
}
// Parse request body
let body: AnthropicRequest
try {
body = (await req.json()) as AnthropicRequest
} catch {
return Response.json(
{ type: 'error', error: { type: 'invalid_request_error', message: 'Invalid JSON in request body' } },
{ status: 400 },
)
}
const isStream = body.stream === true
const baseUrl = config.baseUrl.replace(/\/+$/, '')
try {
if (config.apiFormat === 'openai_chat') {
return await handleOpenaiChat(body, baseUrl, config.apiKey, isStream)
} else {
return await handleOpenaiResponses(body, baseUrl, config.apiKey, isStream)
}
} catch (err) {
console.error('[Proxy] Upstream request failed:', err)
return Response.json(
{
type: 'error',
error: {
type: 'api_error',
message: err instanceof Error ? err.message : String(err),
},
},
{ status: 502 },
)
}
}
async function handleOpenaiChat(
body: AnthropicRequest,
baseUrl: string,
apiKey: string,
isStream: boolean,
): Promise<Response> {
const transformed = anthropicToOpenaiChat(body)
const url = `${baseUrl}/v1/chat/completions`
const upstream = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(transformed),
signal: isStream ? AbortSignal.timeout(30_000) : AbortSignal.timeout(300_000),
})
if (!upstream.ok) {
const errText = await upstream.text().catch(() => '')
return Response.json(
{
type: 'error',
error: {
type: 'api_error',
message: `Upstream returned HTTP ${upstream.status}: ${errText.slice(0, 500)}`,
},
},
{ status: upstream.status },
)
}
if (isStream) {
if (!upstream.body) {
return Response.json(
{ type: 'error', error: { type: 'api_error', message: 'Upstream returned no body for stream' } },
{ status: 502 },
)
}
const anthropicStream = openaiChatStreamToAnthropic(upstream.body, body.model)
return new Response(anthropicStream, {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
}
// Non-streaming
const responseBody = await upstream.json()
const anthropicResponse = openaiChatToAnthropic(responseBody, body.model)
return Response.json(anthropicResponse)
}
async function handleOpenaiResponses(
body: AnthropicRequest,
baseUrl: string,
apiKey: string,
isStream: boolean,
): Promise<Response> {
const transformed = anthropicToOpenaiResponses(body)
const url = `${baseUrl}/v1/responses`
const upstream = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(transformed),
signal: isStream ? AbortSignal.timeout(30_000) : AbortSignal.timeout(300_000),
})
if (!upstream.ok) {
const errText = await upstream.text().catch(() => '')
return Response.json(
{
type: 'error',
error: {
type: 'api_error',
message: `Upstream returned HTTP ${upstream.status}: ${errText.slice(0, 500)}`,
},
},
{ status: upstream.status },
)
}
if (isStream) {
if (!upstream.body) {
return Response.json(
{ type: 'error', error: { type: 'api_error', message: 'Upstream returned no body for stream' } },
{ status: 502 },
)
}
const anthropicStream = openaiResponsesStreamToAnthropic(upstream.body, body.model)
return new Response(anthropicStream, {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
}
// Non-streaming
const responseBody = await upstream.json()
const anthropicResponse = openaiResponsesToAnthropic(responseBody, body.model)
return Response.json(anthropicResponse)
}

View File

@ -0,0 +1,542 @@
/**
* Streaming SSE transformation: OpenAI Chat Completions Anthropic Messages
*
* Converts an OpenAI-compatible streaming response into Anthropic Messages
* streaming format. Follows the patterns established by LiteLLM's
* AnthropicStreamWrapper for correctness across many providers.
*
* Anthropic event order:
* message_start
* (content_block_start content_block_delta* content_block_stop)*
* message_delta
* message_stop
*
* Derived from cc-switch (https://github.com/farion1231/cc-switch)
* Original work by Jason Young, MIT License
*
* Provider-specific reasoning formats handled:
* - delta.reasoning_content (DeepSeek, OpenRouter, XAI, Perplexity, )
* - delta.thinking_blocks (OpenAI o-series)
* - delta.reasoning (GLM-5, Cerebras, Groq mapped to reasoning_content)
*/
import type { OpenAIChatStreamChunk } from '../transform/types.js'
// ─── Types ─────────────────────────────────────────────────
type ContentBlockType = 'text' | 'thinking' | 'tool_use'
type ToolBlockState = {
id: string
name: string
argsBuffer: string
started: boolean
anthropicIndex: number
}
type SseEvent = { event: string; data: unknown }
type StreamState = {
// Event queue — guarantees correct multi-event ordering
queue: SseEvent[]
// Content block tracking (mirrors LiteLLM's state machine)
currentBlockType: ContentBlockType
currentBlockIndex: number
nextContentIndex: number
blockStartSent: boolean // content_block_start emitted for current block?
blockStopSent: boolean // content_block_stop emitted for current block?
// Tool call tracking
toolBlocks: Map<number, ToolBlockState>
// Message lifecycle
model: string
messageStartSent: boolean
messageDeltaSent: boolean
messageStopSent: boolean
// Holding pattern: hold message_delta until usage arrives
// (some providers send finish_reason and usage in separate chunks)
heldMessageDelta: SseEvent | null
}
// ─── Helpers ───────────────────────────────────────────────
function formatSse(event: string, data: unknown): string {
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
}
function createState(model: string): StreamState {
return {
queue: [],
currentBlockType: 'text',
currentBlockIndex: -1,
nextContentIndex: 0,
blockStartSent: false,
blockStopSent: false,
toolBlocks: new Map(),
model,
messageStartSent: false,
messageDeltaSent: false,
messageStopSent: false,
heldMessageDelta: null,
}
}
// ─── Public entry point ────────────────────────────────────
/**
* Transform an OpenAI Chat Completions SSE stream into an Anthropic Messages SSE stream.
*/
export function openaiChatStreamToAnthropic(
upstream: ReadableStream<Uint8Array>,
model: string,
): ReadableStream<Uint8Array> {
const encoder = new TextEncoder()
const decoder = new TextDecoder()
let buffer = ''
const state = createState(model)
return new ReadableStream({
async start(controller) {
const reader = upstream.getReader()
let errored = false
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith(':')) continue
if (trimmed === 'data: [DONE]') {
finalizeStream(state)
flushQueue(state, controller, encoder)
continue
}
if (!trimmed.startsWith('data: ')) continue
const jsonStr = trimmed.slice(6)
let chunk: OpenAIChatStreamChunk
try {
chunk = JSON.parse(jsonStr)
} catch {
continue
}
processChunk(chunk, state)
flushQueue(state, controller, encoder)
}
}
} catch (err) {
errored = true
controller.error(err)
} finally {
if (!errored) {
finalizeStream(state)
flushQueue(state, controller, encoder)
controller.close()
}
}
},
})
}
// ─── Queue management ──────────────────────────────────────
function enqueue(state: StreamState, event: string, data: unknown): void {
state.queue.push({ event, data })
}
function flushQueue(
state: StreamState,
controller: ReadableStreamDefaultController,
encoder: TextEncoder,
): void {
for (const item of state.queue) {
controller.enqueue(encoder.encode(formatSse(item.event, item.data)))
}
state.queue.length = 0
}
// ─── Message lifecycle events ──────────────────────────────
function ensureMessageStart(state: StreamState, chunkId?: string): void {
if (state.messageStartSent) return
state.messageStartSent = true
enqueue(state, 'message_start', {
type: 'message_start',
message: {
id: chunkId || `msg_${Date.now()}`,
type: 'message',
role: 'assistant',
content: [],
model: state.model,
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 0, output_tokens: 0 },
},
})
}
// ─── Content block lifecycle ───────────────────────────────
function openBlock(state: StreamState, blockType: ContentBlockType, block: Record<string, unknown>): number {
const index = state.nextContentIndex++
state.currentBlockType = blockType
state.currentBlockIndex = index
state.blockStartSent = true
state.blockStopSent = false
enqueue(state, 'content_block_start', {
type: 'content_block_start',
index,
content_block: block,
})
return index
}
function emitDelta(state: StreamState, index: number, delta: Record<string, unknown>): void {
enqueue(state, 'content_block_delta', {
type: 'content_block_delta',
index,
delta,
})
}
function closeCurrentBlock(state: StreamState): void {
if (!state.blockStartSent || state.blockStopSent) return
state.blockStopSent = true
enqueue(state, 'content_block_stop', {
type: 'content_block_stop',
index: state.currentBlockIndex,
})
}
function closeAllToolBlocks(state: StreamState): void {
for (const [, block] of state.toolBlocks) {
if (block.started) {
enqueue(state, 'content_block_stop', {
type: 'content_block_stop',
index: block.anthropicIndex,
})
}
}
state.toolBlocks.clear()
}
function closeAllOpenBlocks(state: StreamState): void {
// Close current text/thinking block
closeCurrentBlock(state)
// Close all tool blocks
closeAllToolBlocks(state)
}
// ─── Block type detection (follows LiteLLM priority) ───────
type DeltaEx = Record<string, unknown> & {
content?: string | null
tool_calls?: Array<{
index: number
id?: string
type?: string
function?: { name?: string; arguments?: string }
}>
}
/**
* Extract reasoning/thinking content from delta regardless of provider format.
*
* Handles:
* delta.reasoning_content DeepSeek, OpenRouter, XAI, Perplexity
* delta.reasoning GLM-5, Cerebras, Groq
* delta.thinking_blocks OpenAI o-series
*/
function extractReasoning(delta: DeltaEx): { thinking: string; signature: string } | null {
// Format 1: reasoning_content (most common)
if (typeof delta.reasoning_content === 'string' && delta.reasoning_content) {
return { thinking: delta.reasoning_content, signature: '' }
}
// Format 2: reasoning (GLM-5, Cerebras, Groq)
if (typeof delta.reasoning === 'string' && delta.reasoning) {
return { thinking: delta.reasoning, signature: '' }
}
// Format 3: thinking_blocks (OpenAI o-series)
const thinkingBlocks = delta.thinking_blocks as Array<Record<string, unknown>> | undefined
if (Array.isArray(thinkingBlocks) && thinkingBlocks.length > 0) {
const block = thinkingBlocks[0]
if (block.type === 'thinking') {
const thinking = (block.thinking as string) || ''
const signature = (block.signature as string) || ''
if (thinking || signature) {
return { thinking, signature }
}
}
}
return null
}
/**
* Determine what block type this chunk carries and whether it's a new block.
* Priority (matches LiteLLM): tool_calls > text > reasoning > ignore
*/
function detectBlockTransition(
delta: DeltaEx,
state: StreamState,
): { type: ContentBlockType; isNew: boolean } | null {
// Priority 1: Tool calls
if (delta.tool_calls && delta.tool_calls.length > 0) {
const tc = delta.tool_calls[0]
// A tool call with function.name signals a NEW tool block
const isNew = state.currentBlockType !== 'tool_use' || !!(tc.function?.name)
return { type: 'tool_use', isNew }
}
// Priority 2: Text content
if (delta.content != null && delta.content !== '') {
const isNew = state.currentBlockType !== 'text' || !state.blockStartSent
return { type: 'text', isNew }
}
// Priority 3: Reasoning/thinking
const reasoning = extractReasoning(delta)
if (reasoning) {
const isNew = state.currentBlockType !== 'thinking' || !state.blockStartSent
return { type: 'thinking', isNew }
}
return null
}
// ─── Main chunk processing ─────────────────────────────────
function processChunk(chunk: OpenAIChatStreamChunk, state: StreamState): void {
const choice = chunk.choices?.[0]
// Handle chunks with empty/missing choices (some providers send these)
if (!choice) {
// Check if this is a usage-only chunk (no choices but has usage)
if (chunk.usage && state.heldMessageDelta) {
mergeUsageIntoHeldDelta(state, chunk.usage)
}
return
}
// Update model from first chunk
state.model = chunk.model || state.model
ensureMessageStart(state, chunk.id)
const delta = choice.delta as DeltaEx
// Detect what this chunk carries
const transition = detectBlockTransition(delta, state)
if (transition) {
// Handle block transition: close previous block if type changed
if (transition.isNew && state.blockStartSent && !state.blockStopSent) {
if (transition.type !== 'tool_use') {
// For text/thinking, close the current block
closeCurrentBlock(state)
} else if (state.currentBlockType !== 'tool_use') {
// Switching TO tool_use from text/thinking: close current
closeCurrentBlock(state)
}
}
switch (transition.type) {
case 'thinking':
handleThinking(delta, state)
break
case 'text':
handleText(delta, state)
break
case 'tool_use':
handleToolCalls(delta, state)
break
}
}
// Handle finish_reason
if (choice.finish_reason) {
handleFinishReason(choice.finish_reason, chunk, state)
}
}
// ─── Content handlers ──────────────────────────────────────
function handleThinking(delta: DeltaEx, state: StreamState): void {
const reasoning = extractReasoning(delta)
if (!reasoning) return
if (state.currentBlockType !== 'thinking' || !state.blockStartSent) {
openBlock(state, 'thinking', { type: 'thinking', thinking: '' })
}
if (reasoning.thinking) {
emitDelta(state, state.currentBlockIndex, {
type: 'thinking_delta', thinking: reasoning.thinking,
})
}
if (reasoning.signature) {
emitDelta(state, state.currentBlockIndex, {
type: 'signature_delta', signature: reasoning.signature,
})
}
}
function handleText(delta: DeltaEx, state: StreamState): void {
if (delta.content == null || delta.content === '') return
if (state.currentBlockType !== 'text' || !state.blockStartSent) {
openBlock(state, 'text', { type: 'text', text: '' })
}
emitDelta(state, state.currentBlockIndex, {
type: 'text_delta', text: delta.content,
})
}
function handleToolCalls(delta: DeltaEx, state: StreamState): void {
if (!delta.tool_calls) return
for (const tc of delta.tool_calls) {
const tcIndex = tc.index
if (!state.toolBlocks.has(tcIndex)) {
state.toolBlocks.set(tcIndex, {
id: '', name: '', argsBuffer: '', started: false, anthropicIndex: -1,
})
}
const block = state.toolBlocks.get(tcIndex)!
if (tc.id) block.id = tc.id
if (tc.function?.name) block.name += tc.function.name
if (tc.function?.arguments) block.argsBuffer += tc.function.arguments
// Start tool block once we have id + name
if (!block.started && block.id && block.name) {
block.started = true
block.anthropicIndex = state.nextContentIndex++
state.currentBlockType = 'tool_use'
state.blockStartSent = true
state.blockStopSent = false
enqueue(state, 'content_block_start', {
type: 'content_block_start',
index: block.anthropicIndex,
content_block: { type: 'tool_use', id: block.id, name: block.name, input: {} },
})
// Flush buffered arguments
if (block.argsBuffer) {
emitDelta(state, block.anthropicIndex, {
type: 'input_json_delta', partial_json: block.argsBuffer,
})
}
} else if (block.started && tc.function?.arguments) {
emitDelta(state, block.anthropicIndex, {
type: 'input_json_delta', partial_json: tc.function.arguments,
})
}
}
}
// ─── Finish & usage handling ───────────────────────────────
function handleFinishReason(
finishReason: string,
chunk: OpenAIChatStreamChunk,
state: StreamState,
): void {
if (state.messageDeltaSent) return
// CRITICAL: close ALL content blocks BEFORE message_delta
closeAllOpenBlocks(state)
const stopReason = mapFinishReason(finishReason)
const usage = chunk.usage
? { output_tokens: chunk.usage.completion_tokens || 0 }
: { output_tokens: 0 }
const messageDelta: SseEvent = {
event: 'message_delta',
data: {
type: 'message_delta',
delta: { stop_reason: stopReason, stop_sequence: null },
usage,
},
}
// If usage is available in the same chunk, emit immediately
if (chunk.usage) {
state.messageDeltaSent = true
state.queue.push(messageDelta)
} else {
// Hold message_delta, wait for usage chunk
state.heldMessageDelta = messageDelta
}
}
function mergeUsageIntoHeldDelta(
state: StreamState,
usage: NonNullable<OpenAIChatStreamChunk['usage']>,
): void {
if (!state.heldMessageDelta) return
const data = state.heldMessageDelta.data as Record<string, unknown>
data.usage = { output_tokens: usage.completion_tokens || 0 }
state.messageDeltaSent = true
state.queue.push(state.heldMessageDelta)
state.heldMessageDelta = null
}
function finalizeStream(state: StreamState): void {
if (state.messageStopSent) return
state.messageStopSent = true
ensureMessageStart(state)
// Close any remaining open blocks
closeAllOpenBlocks(state)
// Flush held message_delta if still waiting for usage
if (state.heldMessageDelta && !state.messageDeltaSent) {
state.messageDeltaSent = true
state.queue.push(state.heldMessageDelta)
state.heldMessageDelta = null
}
// Emit message_delta if never sent (e.g., stream ended without finish_reason)
if (!state.messageDeltaSent) {
state.messageDeltaSent = true
enqueue(state, 'message_delta', {
type: 'message_delta',
delta: { stop_reason: 'end_turn', stop_sequence: null },
usage: { output_tokens: 0 },
})
}
enqueue(state, 'message_stop', { type: 'message_stop' })
}
// ─── Utilities ─────────────────────────────────────────────
function mapFinishReason(reason: string): string {
switch (reason) {
case 'stop': return 'end_turn'
case 'tool_calls': return 'tool_use'
case 'length': return 'max_tokens'
case 'content_filter': return 'end_turn'
default: return 'end_turn'
}
}

View File

@ -0,0 +1,277 @@
/**
* Streaming SSE transformation: OpenAI Responses API Anthropic Messages
* Derived from cc-switch (https://github.com/farion1231/cc-switch)
* Original work by Jason Young, MIT License
*/
type StreamState = {
nextContentIndex: number
indexByKey: Map<string, number> // content part key → Anthropic index
toolIndexByItemId: Map<string, number> // tool item ID → Anthropic index
model: string
messageStarted: boolean
messageStopped: boolean
}
function formatSse(event: string, data: unknown): string {
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
}
/**
* Transform an OpenAI Responses API SSE stream into an Anthropic Messages SSE stream.
*/
export function openaiResponsesStreamToAnthropic(
upstream: ReadableStream<Uint8Array>,
model: string,
): ReadableStream<Uint8Array> {
const encoder = new TextEncoder()
const decoder = new TextDecoder()
let buffer = ''
const state: StreamState = {
nextContentIndex: 0,
indexByKey: new Map(),
toolIndexByItemId: new Map(),
model,
messageStarted: false,
messageStopped: false,
}
return new ReadableStream({
async start(controller) {
const reader = upstream.getReader()
let currentEvent = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
const trimmed = line.trim()
if (trimmed.startsWith('event: ')) {
currentEvent = trimmed.slice(7).trim()
continue
}
if (trimmed.startsWith('data: ')) {
const jsonStr = trimmed.slice(6)
if (jsonStr === '[DONE]') {
if (!state.messageStopped) {
state.messageStopped = true
if (!state.messageStarted) {
emitMessageStart(state, controller, encoder, model)
}
controller.enqueue(encoder.encode(formatSse('message_stop', { type: 'message_stop' })))
}
continue
}
let data: Record<string, unknown>
try {
data = JSON.parse(jsonStr)
} catch {
continue
}
processEvent(currentEvent, data, state, controller, encoder)
currentEvent = ''
continue
}
if (trimmed === '') {
currentEvent = ''
}
}
}
} catch (err) {
controller.error(err)
return // don't call close() after error()
}
controller.close()
},
})
}
function emitMessageStart(
state: StreamState,
controller: ReadableStreamDefaultController,
encoder: TextEncoder,
model: string,
): void {
state.messageStarted = true
controller.enqueue(encoder.encode(formatSse('message_start', {
type: 'message_start',
message: {
id: `msg_${Date.now()}`,
type: 'message',
role: 'assistant',
content: [],
model,
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 0, output_tokens: 0 },
},
})))
}
function processEvent(
event: string,
data: Record<string, unknown>,
state: StreamState,
controller: ReadableStreamDefaultController,
encoder: TextEncoder,
): void {
switch (event) {
case 'response.created': {
const response = data as Record<string, unknown>
state.model = (response.model as string) || state.model
emitMessageStart(state, controller, encoder, state.model)
break
}
case 'response.output_item.added': {
if (!state.messageStarted) emitMessageStart(state, controller, encoder, state.model)
const item = data.item as Record<string, unknown> | undefined
if (!item) break
if (item.type === 'function_call') {
const index = state.nextContentIndex++
const callId = (item.call_id as string) || (item.id as string) || ''
const name = (item.name as string) || ''
state.toolIndexByItemId.set(item.id as string || callId, index)
controller.enqueue(encoder.encode(formatSse('content_block_start', {
type: 'content_block_start',
index,
content_block: {
type: 'tool_use',
id: callId,
name,
input: {},
},
})))
}
break
}
case 'response.content_part.added': {
if (!state.messageStarted) emitMessageStart(state, controller, encoder, state.model)
const part = data.part as Record<string, unknown> | undefined
if (!part) break
const contentIndex = (data.content_index as number) ?? 0
const outputIndex = (data.output_index as number) ?? 0
const key = `${outputIndex}:${contentIndex}`
const index = state.nextContentIndex++
state.indexByKey.set(key, index)
controller.enqueue(encoder.encode(formatSse('content_block_start', {
type: 'content_block_start',
index,
content_block: { type: 'text', text: '' },
})))
break
}
case 'response.output_text.delta': {
const contentIndex = (data.content_index as number) ?? 0
const outputIndex = (data.output_index as number) ?? 0
const key = `${outputIndex}:${contentIndex}`
const index = state.indexByKey.get(key)
if (index === undefined) break
const delta = (data.delta as string) || ''
controller.enqueue(encoder.encode(formatSse('content_block_delta', {
type: 'content_block_delta',
index,
delta: { type: 'text_delta', text: delta },
})))
break
}
case 'response.refusal.delta': {
const contentIndex = (data.content_index as number) ?? 0
const outputIndex = (data.output_index as number) ?? 0
const key = `${outputIndex}:${contentIndex}`
const index = state.indexByKey.get(key)
if (index === undefined) break
const delta = (data.delta as string) || ''
controller.enqueue(encoder.encode(formatSse('content_block_delta', {
type: 'content_block_delta',
index,
delta: { type: 'text_delta', text: delta },
})))
break
}
case 'response.function_call_arguments.delta': {
const itemId = (data.item_id as string) || ''
const index = state.toolIndexByItemId.get(itemId)
if (index === undefined) break
const delta = (data.delta as string) || ''
controller.enqueue(encoder.encode(formatSse('content_block_delta', {
type: 'content_block_delta',
index,
delta: { type: 'input_json_delta', partial_json: delta },
})))
break
}
case 'response.output_text.done':
case 'response.refusal.done': {
const contentIndex = (data.content_index as number) ?? 0
const outputIndex = (data.output_index as number) ?? 0
const key = `${outputIndex}:${contentIndex}`
const index = state.indexByKey.get(key)
if (index === undefined) break
controller.enqueue(encoder.encode(formatSse('content_block_stop', {
type: 'content_block_stop',
index,
})))
break
}
case 'response.function_call_arguments.done': {
const itemId = (data.item_id as string) || ''
const index = state.toolIndexByItemId.get(itemId)
if (index === undefined) break
controller.enqueue(encoder.encode(formatSse('content_block_stop', {
type: 'content_block_stop',
index,
})))
break
}
case 'response.completed': {
const response = data.response as Record<string, unknown> | undefined
const status = (response?.status as string) || 'completed'
const usage = response?.usage as Record<string, number> | undefined
const hasToolUse = state.toolIndexByItemId.size > 0
const stopReason = status === 'completed'
? (hasToolUse ? 'tool_use' : 'end_turn')
: status === 'incomplete' ? 'max_tokens' : 'end_turn'
controller.enqueue(encoder.encode(formatSse('message_delta', {
type: 'message_delta',
delta: { stop_reason: stopReason, stop_sequence: null },
usage: { output_tokens: usage?.output_tokens ?? 0 },
})))
if (!state.messageStopped) {
state.messageStopped = true
controller.enqueue(encoder.encode(formatSse('message_stop', { type: 'message_stop' })))
}
break
}
}
}

View File

@ -0,0 +1,194 @@
/**
* Request transformation: Anthropic Messages OpenAI Chat Completions
* Derived from cc-switch (https://github.com/farion1231/cc-switch)
* Original work by Jason Young, MIT License
*/
import type {
AnthropicRequest,
AnthropicContentBlock,
AnthropicMessage,
OpenAIChatRequest,
OpenAIChatMessage,
OpenAIChatContentPart,
OpenAIToolCall,
OpenAITool,
} from './types.js'
/**
* Convert Anthropic Messages request to OpenAI Chat Completions request.
*/
export function anthropicToOpenaiChat(body: AnthropicRequest): OpenAIChatRequest {
const messages: OpenAIChatMessage[] = []
// Convert system prompt
if (body.system) {
if (typeof body.system === 'string') {
messages.push({ role: 'system', content: body.system })
} else if (Array.isArray(body.system)) {
const text = body.system.map((b) => b.text).join('\n')
messages.push({ role: 'system', content: text })
}
}
// Convert messages
for (const msg of body.messages) {
convertMessage(msg, messages)
}
// Build request
const result: OpenAIChatRequest = {
model: body.model,
messages,
stream: body.stream,
}
// max_tokens — omit to let upstream provider use its own default/max.
// Claude Code sends very large values (e.g. 128K) that exceed many
// providers' limits (DeepSeek: 8192, etc.).
// temperature & top_p
if (body.temperature !== undefined) result.temperature = body.temperature
if (body.top_p !== undefined) result.top_p = body.top_p
// stop_sequences → stop
if (body.stop_sequences && body.stop_sequences.length > 0) {
result.stop = body.stop_sequences
}
// tools
if (body.tools && body.tools.length > 0) {
result.tools = body.tools
.filter((t) => t.name !== 'BatchTool')
.map((t): OpenAITool => ({
type: 'function',
function: {
name: t.name,
description: t.description,
parameters: t.input_schema,
},
}))
}
// tool_choice
if (body.tool_choice !== undefined) {
result.tool_choice = convertToolChoice(body.tool_choice)
}
// thinking → reasoning_effort
if (body.thinking) {
const budget = body.thinking.budget_tokens
if (budget !== undefined) {
if (budget <= 1024) result.reasoning_effort = 'low'
else if (budget <= 8192) result.reasoning_effort = 'medium'
else result.reasoning_effort = 'high'
} else if (body.thinking.type === 'enabled') {
result.reasoning_effort = 'high'
}
}
return result
}
function convertMessage(msg: AnthropicMessage, output: OpenAIChatMessage[]): void {
const content = msg.content
// Simple string content
if (typeof content === 'string') {
output.push({ role: msg.role, content })
return
}
// Array content blocks
if (!Array.isArray(content) || content.length === 0) {
output.push({ role: msg.role, content: '' })
return
}
if (msg.role === 'user') {
convertUserMessage(content, output)
} else {
convertAssistantMessage(content, output)
}
}
function convertUserMessage(blocks: AnthropicContentBlock[], output: OpenAIChatMessage[]): void {
// Separate tool_result blocks from other content
const contentParts: OpenAIChatContentPart[] = []
for (const block of blocks) {
if (block.type === 'text') {
contentParts.push({ type: 'text', text: block.text })
} else if (block.type === 'image') {
const url = `data:${block.source.media_type};base64,${block.source.data}`
contentParts.push({ type: 'image_url', image_url: { url } })
} else if (block.type === 'tool_result') {
// tool_result → separate tool message
const resultContent = typeof block.content === 'string'
? block.content
: Array.isArray(block.content)
? block.content.filter((b): b is Extract<AnthropicContentBlock, { type: 'text' }> => b.type === 'text').map((b) => b.text).join('\n')
: ''
output.push({
role: 'tool',
tool_call_id: block.tool_use_id,
content: resultContent,
})
}
}
if (contentParts.length > 0) {
output.push({
role: 'user',
content: contentParts.length === 1 && contentParts[0].type === 'text'
? contentParts[0].text
: contentParts,
})
}
}
function convertAssistantMessage(blocks: AnthropicContentBlock[], output: OpenAIChatMessage[]): void {
let textContent = ''
const toolCalls: OpenAIToolCall[] = []
for (const block of blocks) {
if (block.type === 'text') {
textContent += block.text
} else if (block.type === 'tool_use') {
toolCalls.push({
id: block.id,
type: 'function',
function: {
name: block.name,
arguments: typeof block.input === 'string' ? block.input : JSON.stringify(block.input),
},
})
}
// Skip thinking blocks — no OpenAI equivalent
}
const msg: OpenAIChatMessage = {
role: 'assistant',
content: textContent || null,
}
if (toolCalls.length > 0) {
msg.tool_calls = toolCalls
}
output.push(msg)
}
function convertToolChoice(choice: unknown): unknown {
if (typeof choice === 'string') return choice
if (typeof choice === 'object' && choice !== null) {
const c = choice as Record<string, unknown>
if (c.type === 'auto') return 'auto'
if (c.type === 'any') return 'required'
if (c.type === 'none') return 'none'
if (c.type === 'tool' && typeof c.name === 'string') {
return { type: 'function', function: { name: c.name } }
}
}
return 'auto'
}

View File

@ -0,0 +1,168 @@
/**
* Request transformation: Anthropic Messages OpenAI Responses API
* Derived from cc-switch (https://github.com/farion1231/cc-switch)
* Original work by Jason Young, MIT License
*/
import type {
AnthropicRequest,
AnthropicContentBlock,
AnthropicMessage,
OpenAIResponsesRequest,
OpenAIResponsesInputItem,
OpenAITool,
OpenAIChatContentPart,
} from './types.js'
/**
* Convert Anthropic Messages request to OpenAI Responses API request.
*/
export function anthropicToOpenaiResponses(body: AnthropicRequest): OpenAIResponsesRequest {
const input: OpenAIResponsesInputItem[] = []
// Convert messages to input items
for (const msg of body.messages) {
convertMessageToInputItems(msg, input)
}
const result: OpenAIResponsesRequest = {
model: body.model,
input,
stream: body.stream,
}
// system → instructions
if (body.system) {
if (typeof body.system === 'string') {
result.instructions = body.system
} else if (Array.isArray(body.system)) {
result.instructions = body.system.map((b) => b.text).join('\n')
}
}
// max_tokens — omit to let upstream provider use its own default/max.
// Claude Code sends very large values that exceed many providers' limits.
// temperature & top_p
if (body.temperature !== undefined) result.temperature = body.temperature
if (body.top_p !== undefined) result.top_p = body.top_p
// tools
if (body.tools && body.tools.length > 0) {
result.tools = body.tools
.filter((t) => t.name !== 'BatchTool')
.map((t): OpenAITool => ({
type: 'function',
function: {
name: t.name,
description: t.description,
parameters: t.input_schema,
},
}))
}
// tool_choice
if (body.tool_choice !== undefined) {
result.tool_choice = convertToolChoice(body.tool_choice)
}
// thinking → reasoning
if (body.thinking) {
const budget = body.thinking.budget_tokens
if (budget !== undefined) {
if (budget <= 1024) result.reasoning = { effort: 'low' }
else if (budget <= 8192) result.reasoning = { effort: 'medium' }
else result.reasoning = { effort: 'high' }
} else if (body.thinking.type === 'enabled') {
result.reasoning = { effort: 'high' }
}
}
// stop_sequences not supported in Responses API, dropped
return result
}
function convertMessageToInputItems(msg: AnthropicMessage, output: OpenAIResponsesInputItem[]): void {
const content = msg.content
// Simple string content
if (typeof content === 'string') {
output.push({ type: 'message', role: msg.role, content })
return
}
if (!Array.isArray(content) || content.length === 0) {
output.push({ type: 'message', role: msg.role, content: '' })
return
}
// Collect text/image parts and handle tool blocks separately
const contentParts: (string | OpenAIChatContentPart)[] = []
for (const block of content) {
if (block.type === 'text') {
contentParts.push(block.text)
} else if (block.type === 'image') {
contentParts.push({
type: 'image_url',
image_url: { url: `data:${block.source.media_type};base64,${block.source.data}` },
})
} else if (block.type === 'tool_use') {
// Flush any accumulated content first
if (contentParts.length > 0) {
const flatContent = contentParts.length === 1 && typeof contentParts[0] === 'string'
? contentParts[0]
: contentParts.map((p) => typeof p === 'string' ? p : '').join('')
if (flatContent) {
output.push({ type: 'message', role: msg.role, content: flatContent })
}
contentParts.length = 0
}
// Lift to function_call item
output.push({
type: 'function_call',
call_id: block.id,
name: block.name,
arguments: typeof block.input === 'string' ? block.input : JSON.stringify(block.input),
})
} else if (block.type === 'tool_result') {
// Lift to function_call_output item
const resultContent = typeof block.content === 'string'
? block.content
: Array.isArray(block.content)
? block.content.filter((b): b is Extract<AnthropicContentBlock, { type: 'text' }> => b.type === 'text').map((b) => b.text).join('\n')
: ''
output.push({
type: 'function_call_output',
call_id: block.tool_use_id,
output: resultContent,
})
}
// Skip thinking blocks
}
// Flush remaining content
if (contentParts.length > 0) {
const flatContent = contentParts.length === 1 && typeof contentParts[0] === 'string'
? contentParts[0]
: contentParts.map((p) => typeof p === 'string' ? p : '').join('')
if (flatContent) {
output.push({ type: 'message', role: msg.role, content: flatContent })
}
}
}
function convertToolChoice(choice: unknown): unknown {
if (typeof choice === 'string') return choice
if (typeof choice === 'object' && choice !== null) {
const c = choice as Record<string, unknown>
if (c.type === 'auto') return 'auto'
if (c.type === 'any') return 'required'
if (c.type === 'none') return 'none'
if (c.type === 'tool' && typeof c.name === 'string') {
return { type: 'function', function: { name: c.name } }
}
}
return 'auto'
}

View File

@ -0,0 +1,116 @@
/**
* Response transformation: OpenAI Chat Completions Anthropic Messages
* Derived from cc-switch (https://github.com/farion1231/cc-switch)
* Original work by Jason Young, MIT License
*/
import type {
OpenAIChatResponse,
AnthropicResponse,
AnthropicContentBlock,
} from './types.js'
/**
* Convert OpenAI Chat Completions response to Anthropic Messages response.
*/
export function openaiChatToAnthropic(response: OpenAIChatResponse, model: string): AnthropicResponse {
const choice = response.choices?.[0]
if (!choice) {
return createEmptyResponse(response, model)
}
const content: AnthropicContentBlock[] = []
// Convert reasoning/thinking content (all provider formats)
const msg = choice.message as Record<string, unknown>
// Format 1: reasoning_content (DeepSeek, OpenRouter, XAI, Perplexity)
if (typeof msg.reasoning_content === 'string' && msg.reasoning_content) {
content.push({ type: 'thinking', thinking: msg.reasoning_content })
}
// Format 2: reasoning (GLM-5, Cerebras, Groq)
else if (typeof msg.reasoning === 'string' && msg.reasoning) {
content.push({ type: 'thinking', thinking: msg.reasoning })
}
// Format 3: thinking_blocks (OpenAI o-series)
else if (Array.isArray(msg.thinking_blocks)) {
for (const tb of msg.thinking_blocks as Array<Record<string, unknown>>) {
if (tb.type === 'thinking' && typeof tb.thinking === 'string') {
content.push({ type: 'thinking', thinking: tb.thinking, signature: tb.signature as string | undefined })
}
}
}
// Convert text content
if (choice.message.content) {
content.push({ type: 'text', text: choice.message.content })
}
// Convert tool calls
if (choice.message.tool_calls) {
for (const tc of choice.message.tool_calls) {
let input: Record<string, unknown> = {}
try {
input = JSON.parse(tc.function.arguments)
} catch {
input = { raw: tc.function.arguments }
}
content.push({
type: 'tool_use',
id: tc.id,
name: tc.function.name,
input,
})
}
}
// If no content at all, add empty text
if (content.length === 0) {
content.push({ type: 'text', text: '' })
}
return {
id: response.id || `msg_${Date.now()}`,
type: 'message',
role: 'assistant',
content,
model: response.model || model,
stop_reason: mapFinishReason(choice.finish_reason),
stop_sequence: null,
usage: mapUsage(response.usage),
}
}
function mapFinishReason(reason: string | null): string {
switch (reason) {
case 'stop': return 'end_turn'
case 'tool_calls': return 'tool_use'
case 'length': return 'max_tokens'
case 'content_filter': return 'end_turn'
default: return 'end_turn'
}
}
function mapUsage(usage?: OpenAIChatResponse['usage']): AnthropicResponse['usage'] {
if (!usage) {
return { input_tokens: 0, output_tokens: 0 }
}
return {
input_tokens: usage.prompt_tokens || 0,
output_tokens: usage.completion_tokens || 0,
cache_read_input_tokens: usage.prompt_tokens_details?.cached_tokens || 0,
}
}
function createEmptyResponse(response: OpenAIChatResponse, model: string): AnthropicResponse {
return {
id: response.id || `msg_${Date.now()}`,
type: 'message',
role: 'assistant',
content: [{ type: 'text', text: '' }],
model: response.model || model,
stop_reason: 'end_turn',
stop_sequence: null,
usage: mapUsage(response.usage),
}
}

View File

@ -0,0 +1,97 @@
/**
* Response transformation: OpenAI Responses API Anthropic Messages
* Derived from cc-switch (https://github.com/farion1231/cc-switch)
* Original work by Jason Young, MIT License
*/
import type {
OpenAIResponsesResponse,
OpenAIResponsesOutputItem,
AnthropicResponse,
AnthropicContentBlock,
} from './types.js'
/**
* Convert OpenAI Responses API response to Anthropic Messages response.
*/
export function openaiResponsesToAnthropic(response: OpenAIResponsesResponse, model: string): AnthropicResponse {
const content: AnthropicContentBlock[] = []
let hasToolUse = false
for (const item of response.output || []) {
convertOutputItem(item, content)
if (item.type === 'function_call') hasToolUse = true
}
// If no content, add empty text
if (content.length === 0) {
content.push({ type: 'text', text: '' })
}
return {
id: response.id || `msg_${Date.now()}`,
type: 'message',
role: 'assistant',
content,
model: response.model || model,
stop_reason: mapStatus(response.status, hasToolUse),
stop_sequence: null,
usage: {
input_tokens: response.usage?.input_tokens || 0,
output_tokens: response.usage?.output_tokens || 0,
},
}
}
function convertOutputItem(item: OpenAIResponsesOutputItem, content: AnthropicContentBlock[]): void {
switch (item.type) {
case 'message': {
for (const part of item.content || []) {
if (part.type === 'output_text' || part.type === 'text') {
content.push({ type: 'text', text: part.text || '' })
} else if (part.type === 'refusal') {
content.push({ type: 'text', text: part.refusal || '[Refusal]' })
}
}
break
}
case 'function_call': {
let input: Record<string, unknown> = {}
try {
input = JSON.parse(item.arguments)
} catch {
input = { raw: item.arguments }
}
content.push({
type: 'tool_use',
id: item.call_id,
name: item.name,
input,
})
break
}
case 'reasoning': {
if (item.summary) {
for (const s of item.summary) {
if (s.text) {
content.push({
type: 'thinking',
thinking: s.text,
})
}
}
}
break
}
}
}
function mapStatus(status: string, hasToolUse: boolean): string {
switch (status) {
case 'completed': return hasToolUse ? 'tool_use' : 'end_turn'
case 'failed': return 'end_turn'
case 'cancelled': return 'end_turn'
case 'incomplete': return 'max_tokens'
default: return 'end_turn'
}
}

View File

@ -0,0 +1,191 @@
/**
* OpenAI API type definitions for protocol transformation.
* Derived from cc-switch (https://github.com/farion1231/cc-switch)
* Original work by Jason Young, MIT License
*/
// ─── OpenAI Chat Completions ────────────────────────────────
export type OpenAIChatMessage = {
role: 'system' | 'user' | 'assistant' | 'tool'
content?: string | OpenAIChatContentPart[] | null
name?: string
tool_calls?: OpenAIToolCall[]
tool_call_id?: string
}
export type OpenAIChatContentPart =
| { type: 'text'; text: string }
| { type: 'image_url'; image_url: { url: string; detail?: string } }
export type OpenAIToolCall = {
id: string
type: 'function'
function: {
name: string
arguments: string
}
}
export type OpenAITool = {
type: 'function'
function: {
name: string
description?: string
parameters?: Record<string, unknown>
}
}
export type OpenAIChatRequest = {
model: string
messages: OpenAIChatMessage[]
max_tokens?: number
max_completion_tokens?: number
temperature?: number
top_p?: number
stop?: string | string[]
stream?: boolean
tools?: OpenAITool[]
tool_choice?: unknown
reasoning_effort?: 'low' | 'medium' | 'high'
}
export type OpenAIChatResponse = {
id: string
object: string
created: number
model: string
choices: Array<{
index: number
message: {
role: string
content: string | null
tool_calls?: OpenAIToolCall[]
}
finish_reason: string | null
}>
usage?: {
prompt_tokens: number
completion_tokens: number
total_tokens: number
prompt_tokens_details?: {
cached_tokens?: number
}
}
}
export type OpenAIChatStreamChunk = {
id: string
object: string
created: number
model: string
choices: Array<{
index: number
delta: {
role?: string
content?: string | null
tool_calls?: Array<{
index: number
id?: string
type?: string
function?: {
name?: string
arguments?: string
}
}>
}
finish_reason: string | null
}>
usage?: OpenAIChatResponse['usage']
}
// ─── OpenAI Responses API ───────────────────────────────────
export type OpenAIResponsesInputItem =
| { type: 'message'; role: 'user' | 'assistant' | 'system'; content: string | OpenAIChatContentPart[] }
| { type: 'function_call'; call_id: string; name: string; arguments: string }
| { type: 'function_call_output'; call_id: string; output: string }
export type OpenAIResponsesRequest = {
model: string
input: OpenAIResponsesInputItem[]
instructions?: string
max_output_tokens?: number
temperature?: number
top_p?: number
stream?: boolean
tools?: OpenAITool[]
tool_choice?: unknown
reasoning?: { effort?: 'low' | 'medium' | 'high' }
}
export type OpenAIResponsesOutputItem =
| { type: 'message'; role: string; content: Array<{ type: string; text?: string; refusal?: string }> }
| { type: 'function_call'; id: string; call_id: string; name: string; arguments: string }
| { type: 'reasoning'; id: string; summary?: Array<{ type: string; text: string }> }
export type OpenAIResponsesResponse = {
id: string
object: string
created_at: number
model: string
status: string
output: OpenAIResponsesOutputItem[]
usage?: {
input_tokens: number
output_tokens: number
total_tokens: number
}
}
// ─── Anthropic Types (subset used by transforms) ───────────
export type AnthropicContentBlock =
| { type: 'text'; text: string; cache_control?: unknown }
| { type: 'image'; source: { type: 'base64'; media_type: string; data: string }; cache_control?: unknown }
| { type: 'tool_use'; id: string; name: string; input: Record<string, unknown>; cache_control?: unknown }
| { type: 'tool_result'; tool_use_id: string; content: string | AnthropicContentBlock[]; is_error?: boolean; cache_control?: unknown }
| { type: 'thinking'; thinking: string; signature?: string }
export type AnthropicMessage = {
role: 'user' | 'assistant'
content: string | AnthropicContentBlock[]
}
export type AnthropicRequest = {
model: string
system?: string | Array<{ type: 'text'; text: string; cache_control?: unknown }>
messages: AnthropicMessage[]
max_tokens: number
temperature?: number
top_p?: number
stop_sequences?: string[]
stream?: boolean
tools?: Array<{
name: string
description?: string
input_schema: Record<string, unknown>
cache_control?: unknown
}>
tool_choice?: unknown
thinking?: {
type: string
budget_tokens?: number
}
}
export type AnthropicResponse = {
id: string
type: 'message'
role: 'assistant'
content: AnthropicContentBlock[]
model: string
stop_reason: string | null
stop_sequence: string | null
usage: {
input_tokens: number
output_tokens: number
cache_read_input_tokens?: number
cache_creation_input_tokens?: number
}
}

View File

@ -9,6 +9,11 @@ import * as fs from 'fs/promises'
import * as path from 'path'
import * as os from 'os'
import { ApiError } from '../middleware/errorHandler.js'
import { anthropicToOpenaiChat } from '../proxy/transform/anthropicToOpenaiChat.js'
import { anthropicToOpenaiResponses } from '../proxy/transform/anthropicToOpenaiResponses.js'
import { openaiChatToAnthropic } from '../proxy/transform/openaiChatToAnthropic.js'
import { openaiResponsesToAnthropic } from '../proxy/transform/openaiResponsesToAnthropic.js'
import type { AnthropicRequest, AnthropicResponse } from '../proxy/transform/types.js'
import type {
SavedProvider,
ProvidersIndex,
@ -16,6 +21,8 @@ import type {
UpdateProviderInput,
TestProviderInput,
ProviderTestResult,
ProviderTestStepResult,
ApiFormat,
} from '../types/provider.js'
const MANAGED_ENV_KEYS = [
@ -30,6 +37,15 @@ const MANAGED_ENV_KEYS = [
const DEFAULT_INDEX: ProvidersIndex = { activeId: null, providers: [] }
export class ProviderService {
private static serverPort = 3456
static setServerPort(port: number): void {
ProviderService.serverPort = port
}
static getServerPort(): number {
return ProviderService.serverPort
}
private getConfigDir(): string {
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
}
@ -121,6 +137,7 @@ export class ProviderService {
name: input.name,
apiKey: input.apiKey,
baseUrl: input.baseUrl,
apiFormat: input.apiFormat ?? 'anthropic',
models: input.models,
...(input.notes !== undefined && { notes: input.notes }),
}
@ -141,6 +158,7 @@ export class ProviderService {
...(input.name !== undefined && { name: input.name }),
...(input.apiKey !== undefined && { apiKey: input.apiKey }),
...(input.baseUrl !== undefined && { baseUrl: input.baseUrl }),
...(input.apiFormat !== undefined && { apiFormat: input.apiFormat }),
...(input.models !== undefined && { models: input.models }),
...(input.notes !== undefined && { notes: input.notes }),
}
@ -198,10 +216,15 @@ export class ProviderService {
const settings = await this.readSettings()
const existingEnv = (settings.env as Record<string, string>) || {}
const needsProxy = provider.apiFormat != null && provider.apiFormat !== 'anthropic'
const baseUrl = needsProxy
? `http://127.0.0.1:${ProviderService.serverPort}/proxy`
: provider.baseUrl
settings.env = {
...existingEnv,
ANTHROPIC_BASE_URL: provider.baseUrl,
ANTHROPIC_AUTH_TOKEN: provider.apiKey,
ANTHROPIC_BASE_URL: baseUrl,
ANTHROPIC_AUTH_TOKEN: needsProxy ? 'proxy-managed' : provider.apiKey,
ANTHROPIC_MODEL: provider.models.main,
ANTHROPIC_DEFAULT_HAIKU_MODEL: provider.models.haiku,
ANTHROPIC_DEFAULT_SONNET_MODEL: provider.models.sonnet,
@ -227,65 +250,239 @@ export class ProviderService {
await this.writeSettings(settings)
}
// --- Test ---
// --- Proxy support ---
async testProvider(id: string): Promise<ProviderTestResult> {
const provider = await this.getProvider(id)
if (!provider.baseUrl || !provider.apiKey) {
return { success: false, latencyMs: 0, error: 'Missing baseUrl or apiKey' }
}
return this.testProviderConfig({
async getActiveProviderForProxy(): Promise<{
baseUrl: string
apiKey: string
apiFormat: ApiFormat
} | null> {
const index = await this.readIndex()
if (!index.activeId) return null
const provider = index.providers.find((p) => p.id === index.activeId)
if (!provider) return null
return {
baseUrl: provider.baseUrl,
apiKey: provider.apiKey,
modelId: provider.models.main,
apiFormat: provider.apiFormat ?? 'anthropic',
}
}
// --- Test ---
async testProvider(
id: string,
overrides?: { baseUrl?: string; modelId?: string; apiFormat?: ApiFormat },
): Promise<ProviderTestResult> {
const provider = await this.getProvider(id)
const baseUrl = overrides?.baseUrl || provider.baseUrl
const modelId = overrides?.modelId || provider.models.main
const apiFormat = overrides?.apiFormat ?? provider.apiFormat ?? 'anthropic'
if (!baseUrl || !provider.apiKey) {
return { connectivity: { success: false, latencyMs: 0, error: 'Missing baseUrl or apiKey' } }
}
return this.testProviderConfig({
baseUrl,
apiKey: provider.apiKey,
modelId,
apiFormat,
})
}
async testProviderConfig(input: TestProviderInput): Promise<ProviderTestResult> {
const url = `${input.baseUrl.replace(/\/+$/, '')}/v1/messages`
const start = Date.now()
const format: ApiFormat = input.apiFormat ?? 'anthropic'
const base = input.baseUrl.replace(/\/+$/, '')
// ── Step 1: Basic connectivity ───────────────────────────
// Directly call the upstream API to verify URL, key, and model.
const step1 = await this.testConnectivity(base, input.apiKey, input.modelId, format)
// If connectivity failed, no point running step 2
if (!step1.success) {
return { connectivity: step1 }
}
// For native Anthropic format, no proxy pipeline to test
if (format === 'anthropic') {
return { connectivity: step1 }
}
// ── Step 2: Full proxy pipeline ──────────────────────────
// Anthropic request → transform → upstream → transform back → validate
const step2 = await this.testProxyPipeline(base, input.apiKey, input.modelId, format)
return { connectivity: step1, proxy: step2 }
}
/** Step 1: Direct upstream call to verify connectivity, auth, and model. */
private async testConnectivity(
base: string,
apiKey: string,
modelId: string,
format: ApiFormat,
): Promise<ProviderTestStepResult> {
const start = Date.now()
try {
const { url, headers, body } = buildDirectTestRequest(base, apiKey, modelId, format)
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': input.apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: input.modelId,
max_tokens: 1,
messages: [{ role: 'user', content: 'Hi' }],
}),
signal: AbortSignal.timeout(15000),
headers,
body: JSON.stringify(body),
signal: AbortSignal.timeout(30000),
})
const latencyMs = Date.now() - start
const resBody = await response.json().catch(() => null) as Record<string, unknown> | null
if (response.ok) {
return { success: true, latencyMs, modelUsed: input.modelId, httpStatus: response.status }
}
let errorMessage = `HTTP ${response.status}`
try {
const body = (await response.json()) as Record<string, unknown>
if (body.error && typeof body.error === 'object') {
errorMessage = ((body.error as Record<string, unknown>).message as string) || errorMessage
} else if (typeof body.message === 'string') {
errorMessage = body.message
if (!response.ok) {
let error = `HTTP ${response.status}`
if (resBody?.error && typeof resBody.error === 'object') {
error = ((resBody.error as Record<string, unknown>).message as string) || error
}
} catch {
errorMessage = `HTTP ${response.status} ${response.statusText}`
return { success: false, latencyMs, error, modelUsed: modelId, httpStatus: response.status }
}
return { success: false, latencyMs, error: errorMessage, modelUsed: input.modelId, httpStatus: response.status }
// Validate response structure
const valid = validateResponseBody(resBody, format)
if (!valid.ok) {
return { success: false, latencyMs, error: valid.error, modelUsed: modelId, httpStatus: response.status }
}
return { success: true, latencyMs, modelUsed: valid.model || modelId, httpStatus: response.status }
} catch (err: unknown) {
const latencyMs = Date.now() - start
if (err instanceof DOMException && err.name === 'TimeoutError') {
return { success: false, latencyMs, error: 'Request timed out after 15 seconds', modelUsed: input.modelId }
return { success: false, latencyMs, error: 'Request timed out (30s)', modelUsed: modelId }
}
return { success: false, latencyMs, error: err instanceof Error ? err.message : String(err), modelUsed: input.modelId }
return { success: false, latencyMs, error: err instanceof Error ? err.message : String(err), modelUsed: modelId }
}
}
/** Step 2: Full proxy pipeline — Anthropic → transform → upstream → transform back → validate. */
private async testProxyPipeline(
base: string,
apiKey: string,
modelId: string,
format: 'openai_chat' | 'openai_responses',
): Promise<ProviderTestStepResult> {
const start = Date.now()
try {
// Build an Anthropic Messages API request (same shape as what CLI sends)
const anthropicReq: AnthropicRequest = {
model: modelId,
max_tokens: 64,
messages: [{ role: 'user', content: 'Say "ok" and nothing else.' }],
}
// Transform to OpenAI format
let upstreamUrl: string
let transformedBody: unknown
if (format === 'openai_chat') {
transformedBody = anthropicToOpenaiChat(anthropicReq)
upstreamUrl = `${base}/v1/chat/completions`
} else {
transformedBody = anthropicToOpenaiResponses(anthropicReq)
upstreamUrl = `${base}/v1/responses`
}
// Call upstream with transformed request
const response = await fetch(upstreamUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify(transformedBody),
signal: AbortSignal.timeout(30000),
})
if (!response.ok) {
const latencyMs = Date.now() - start
const errText = await response.text().catch(() => '')
return { success: false, latencyMs, modelUsed: modelId, httpStatus: response.status,
error: `Upstream HTTP ${response.status}: ${errText.slice(0, 200)}` }
}
// Transform response back to Anthropic format
const responseBody = await response.json()
const anthropicRes = format === 'openai_chat'
? openaiChatToAnthropic(responseBody, modelId)
: openaiResponsesToAnthropic(responseBody, modelId)
const latencyMs = Date.now() - start
// Validate the final Anthropic response
if (anthropicRes.type !== 'message' || !Array.isArray(anthropicRes.content)) {
return { success: false, latencyMs, modelUsed: modelId,
error: 'Proxy transform produced invalid Anthropic response' }
}
return { success: true, latencyMs, modelUsed: anthropicRes.model || modelId, httpStatus: response.status }
} catch (err: unknown) {
const latencyMs = Date.now() - start
if (err instanceof DOMException && err.name === 'TimeoutError') {
return { success: false, latencyMs, error: 'Proxy pipeline timed out (30s)', modelUsed: modelId }
}
return { success: false, latencyMs, error: err instanceof Error ? err.message : String(err), modelUsed: modelId }
}
}
}
// ─── Helpers ───────────────────────────────────────────────
function buildDirectTestRequest(
base: string,
apiKey: string,
modelId: string,
format: ApiFormat,
): { url: string; headers: Record<string, string>; body: Record<string, unknown> } {
const prompt = 'Say "ok" and nothing else.'
if (format === 'openai_chat') {
return {
url: `${base}/v1/chat/completions`,
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: { model: modelId, max_tokens: 16, messages: [{ role: 'user', content: prompt }] },
}
}
if (format === 'openai_responses') {
return {
url: `${base}/v1/responses`,
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: { model: modelId, max_output_tokens: 16, input: [{ type: 'message', role: 'user', content: prompt }] },
}
}
// anthropic
return {
url: `${base}/v1/messages`,
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
body: { model: modelId, max_tokens: 16, messages: [{ role: 'user', content: prompt }] },
}
}
function validateResponseBody(
body: Record<string, unknown> | null,
format: ApiFormat,
): { ok: true; model?: string } | { ok: false; error: string } {
if (!body) return { ok: false, error: 'Empty response — not a valid API endpoint' }
if (body.error && typeof body.error === 'object') {
return { ok: false, error: ((body.error as Record<string, unknown>).message as string) || 'Error in response body' }
}
if (format === 'openai_chat') {
if (!Array.isArray(body.choices) || body.choices.length === 0) {
return { ok: false, error: 'Response missing choices — not a valid Chat Completions endpoint' }
}
return { ok: true, model: (body.model as string) || undefined }
}
if (format === 'openai_responses') {
if (!Array.isArray(body.output)) {
return { ok: false, error: 'Response missing output — not a valid Responses API endpoint' }
}
return { ok: true, model: (body.model as string) || undefined }
}
// anthropic
if (body.type !== 'message' || !Array.isArray(body.content)) {
return { ok: false, error: 'Not a valid Anthropic Messages endpoint' }
}
return { ok: true, model: (body.model as string) || undefined }
}

View File

@ -7,6 +7,13 @@
import { z } from 'zod'
export const ApiFormatSchema = z.enum([
'anthropic', // Native Anthropic Messages API (passthrough, no proxy)
'openai_chat', // OpenAI Chat Completions /v1/chat/completions
'openai_responses', // OpenAI Responses API /v1/responses
])
export type ApiFormat = z.infer<typeof ApiFormatSchema>
export const ModelMappingSchema = z.object({
main: z.string(),
haiku: z.string(),
@ -20,6 +27,7 @@ export const SavedProviderSchema = z.object({
name: z.string().min(1),
apiKey: z.string(),
baseUrl: z.string(),
apiFormat: ApiFormatSchema.default('anthropic'),
models: ModelMappingSchema,
notes: z.string().optional(),
})
@ -34,6 +42,7 @@ export const CreateProviderSchema = z.object({
name: z.string().min(1),
apiKey: z.string(),
baseUrl: z.string(),
apiFormat: ApiFormatSchema.default('anthropic'),
models: ModelMappingSchema,
notes: z.string().optional(),
})
@ -42,6 +51,7 @@ export const UpdateProviderSchema = z.object({
name: z.string().min(1).optional(),
apiKey: z.string().optional(),
baseUrl: z.string().optional(),
apiFormat: ApiFormatSchema.optional(),
models: ModelMappingSchema.optional(),
notes: z.string().optional(),
})
@ -50,6 +60,7 @@ export const TestProviderSchema = z.object({
baseUrl: z.string().url(),
apiKey: z.string().min(1),
modelId: z.string().min(1),
apiFormat: ApiFormatSchema.default('anthropic'),
})
// TypeScript types
@ -60,10 +71,17 @@ export type CreateProviderInput = z.infer<typeof CreateProviderSchema>
export type UpdateProviderInput = z.infer<typeof UpdateProviderSchema>
export type TestProviderInput = z.infer<typeof TestProviderSchema>
export interface ProviderTestResult {
export interface ProviderTestStepResult {
success: boolean
latencyMs: number
error?: string
modelUsed?: string
httpStatus?: number
}
export interface ProviderTestResult {
/** Step 1: Basic connectivity — API reachable, key valid, model exists */
connectivity: ProviderTestStepResult
/** Step 2: Proxy pipeline — full Anthropic→OpenAI→Anthropic round-trip (only for openai_* formats) */
proxy?: ProviderTestStepResult
}