diff --git a/desktop/src/api/providers.ts b/desktop/src/api/providers.ts index 1cf682b1..ae827c61 100644 --- a/desktop/src/api/providers.ts +++ b/desktop/src/api/providers.ts @@ -44,8 +44,8 @@ export const providersApi = { return api.post<{ ok: true }>('/api/providers/official') }, - test(id: string) { - return api.post(`/api/providers/${id}/test`) + test(id: string, overrides?: { baseUrl?: string; modelId?: string; apiFormat?: string }) { + return api.post(`/api/providers/${id}/test`, overrides) }, testConfig(input: TestProviderConfigInput) { diff --git a/desktop/src/config/providerPresets.ts b/desktop/src/config/providerPresets.ts index df62f5dd..984514de 100644 --- a/desktop/src/config/providerPresets.ts +++ b/desktop/src/config/providerPresets.ts @@ -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: '', diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 56d2fb29..2e76f6d7 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index aaaf0d3f..acada05e 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -59,6 +59,10 @@ export const zh: Record = { '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 = { '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': '权限模式', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 6f10f0cd..8253ffa6 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -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' && ( {preset.name} )} + {provider.apiFormat && provider.apiFormat !== 'anthropic' && ( + + {provider.apiFormat === 'openai_chat' ? 'OpenAI Chat' : 'OpenAI Responses'} + + )} {isActive && ( {t('common.active')} )} @@ -177,8 +182,19 @@ function ProviderSettings() { {provider.baseUrl} · {provider.models.main} {test && !test.loading && test.result && ( -
- {test.result.success ? t('settings.providers.connected', { latency: String(test.result.latencyMs) }) : t('settings.providers.failed', { error: test.result.error || '' })} +
+ + {test.result.connectivity.success + ? t('settings.providers.connectivityOk', { latency: String(test.result.connectivity.latencyMs) }) + : t('settings.providers.connectivityFailed', { error: test.result.connectivity.error || '' })} + + {test.result.proxy && ( + + {test.result.proxy.success + ? t('settings.providers.proxyOk', { latency: String(test.result.proxy.latencyMs) }) + : t('settings.providers.proxyFailed', { error: test.result.proxy.error || '' })} + + )}
)}
@@ -245,6 +261,7 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps) const [selectedPreset, setSelectedPreset] = useState(initialPreset) const [name, setName] = useState(provider?.name ?? initialPreset.name) const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? initialPreset.baseUrl) + const [apiFormat, setApiFormat] = useState(provider?.apiFormat ?? initialPreset.apiFormat ?? 'anthropic') const [apiKey, setApiKey] = useState('') const [notes, setNotes] = useState(provider?.notes ?? '') const [models, setModels] = useState(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) || {}), - 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) )} + {/* API Format */} + {(isCustom || mode === 'edit') ? ( +
+ + + {apiFormat !== 'anthropic' && ( +

{t('settings.providers.proxyHint')}

+ )} +
+ ) : apiFormat !== 'anthropic' ? ( +
+ +
+ {apiFormat === 'openai_chat' ? t('settings.providers.apiFormatOpenaiChat') : t('settings.providers.apiFormatOpenaiResponses')} +
+
+ ) : null} + {testResult && ( - - {testResult.success ? t('settings.providers.connected', { latency: String(testResult.latencyMs) }) : t('settings.providers.failed', { error: testResult.error || '' })} - +
+ + {testResult.connectivity.success + ? t('settings.providers.connectivityOk', { latency: String(testResult.connectivity.latencyMs) }) + : t('settings.providers.connectivityFailed', { error: testResult.connectivity.error || '' })} + + {testResult.proxy && ( + + {testResult.proxy.success + ? t('settings.providers.proxyOk', { latency: String(testResult.proxy.latencyMs) }) + : t('settings.providers.proxyFailed', { error: testResult.proxy.error || '' })} + + )} +
)} diff --git a/desktop/src/stores/providerStore.ts b/desktop/src/stores/providerStore.ts index 0d803f80..27b3ee93 100644 --- a/desktop/src/stores/providerStore.ts +++ b/desktop/src/stores/providerStore.ts @@ -21,7 +21,7 @@ type ProviderStore = { deleteProvider: (id: string) => Promise activateProvider: (id: string) => Promise activateOfficial: () => Promise - testProvider: (id: string) => Promise + testProvider: (id: string, overrides?: { baseUrl?: string; modelId?: string; apiFormat?: string }) => Promise testConfig: (input: TestProviderConfigInput) => Promise } @@ -67,8 +67,8 @@ export const useProviderStore = create((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 }, diff --git a/desktop/src/types/provider.ts b/desktop/src/types/provider.ts index 33a123a7..bc03649a 100644 --- a/desktop/src/types/provider.ts +++ b/desktop/src/types/provider.ts @@ -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 +} diff --git a/src/server/__tests__/proxy-streaming.test.ts b/src/server/__tests__/proxy-streaming.test.ts new file mode 100644 index 00000000..6172f4bb --- /dev/null +++ b/src/server/__tests__/proxy-streaming.test.ts @@ -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 { + 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): Promise }>> { + 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 }> = [] + 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).model).toBe('gpt-4') + expect((msgStart.data.message as Record).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).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).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)?.type === 'tool_use', + ) + expect(toolStart).toBeDefined() + expect((toolStart!.data.content_block as Record).name).toBe('get_weather') + expect((toolStart!.data.content_block as Record).id).toBe('call_1') + + // Should have input_json_delta + const jsonDeltas = events.filter( + (e) => e.event === 'content_block_delta' && (e.data.delta as Record)?.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).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)?.type === 'thinking', + ) + expect(thinkingStart).toBeDefined() + + // Should have thinking deltas + const thinkingDeltas = events.filter( + (e) => e.event === 'content_block_delta' && (e.data.delta as Record)?.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)?.type === 'text', + ) + expect(textStart).toBeDefined() + + // Text should come after thinking in index order + expect((textStart!.data as Record).index).toBeGreaterThan( + (thinkingStart!.data as Record).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)?.type === 'thinking_delta', + ) + expect(thinkingDeltas.length).toBe(1) + expect((thinkingDeltas[0].data.delta as Record).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)?.type === 'thinking_delta', + ) + expect(thinkingDeltas.length).toBe(1) + expect((thinkingDeltas[0].data.delta as Record).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)?.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).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)?.type === 'tool_use', + ) + expect(toolStart).toBeDefined() + expect((toolStart!.data.content_block as Record).name).toBe('search') + + // Should have input_json_delta + const jsonDeltas = events.filter( + (e) => e.event === 'content_block_delta' && (e.data.delta as Record)?.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).stop_reason).toBe('tool_use') + }) +}) diff --git a/src/server/__tests__/proxy-transform.test.ts b/src/server/__tests__/proxy-transform.test.ts new file mode 100644 index 00000000..c62cac38 --- /dev/null +++ b/src/server/__tests__/proxy-transform.test.ts @@ -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).stop).toBeUndefined() + expect((result as Record).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: '' }]) + }) +}) diff --git a/src/server/api/providers.ts b/src/server/api/providers.ts index cdbc673b..d83324a5 100644 --- a/src/server/api/providers.ts +++ b/src/server/api/providers.ts @@ -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 }) } diff --git a/src/server/config/providerPresets.ts b/src/server/config/providerPresets.ts index df62f5dd..7c3e327b 100644 --- a/src/server/config/providerPresets.ts +++ b/src/server/config/providerPresets.ts @@ -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: '', diff --git a/src/server/index.ts b/src/server/index.ts index 441076a7..3d9dd6fd 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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( diff --git a/src/server/proxy/LICENSE-cc-switch.md b/src/server/proxy/LICENSE-cc-switch.md new file mode 100644 index 00000000..fc1d52cc --- /dev/null +++ b/src/server/proxy/LICENSE-cc-switch.md @@ -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. diff --git a/src/server/proxy/handler.ts b/src/server/proxy/handler.ts new file mode 100644 index 00000000..6569eb19 --- /dev/null +++ b/src/server/proxy/handler.ts @@ -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 { + // 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 { + 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 { + 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) +} diff --git a/src/server/proxy/streaming/openaiChatStreamToAnthropic.ts b/src/server/proxy/streaming/openaiChatStreamToAnthropic.ts new file mode 100644 index 00000000..054ecc7f --- /dev/null +++ b/src/server/proxy/streaming/openaiChatStreamToAnthropic.ts @@ -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 + + // 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, + model: string, +): ReadableStream { + 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): 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): 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 & { + 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> | 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, +): void { + if (!state.heldMessageDelta) return + + const data = state.heldMessageDelta.data as Record + 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' + } +} diff --git a/src/server/proxy/streaming/openaiResponsesStreamToAnthropic.ts b/src/server/proxy/streaming/openaiResponsesStreamToAnthropic.ts new file mode 100644 index 00000000..560fe05c --- /dev/null +++ b/src/server/proxy/streaming/openaiResponsesStreamToAnthropic.ts @@ -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 // content part key → Anthropic index + toolIndexByItemId: Map // 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, + model: string, +): ReadableStream { + 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 + 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, + state: StreamState, + controller: ReadableStreamDefaultController, + encoder: TextEncoder, +): void { + switch (event) { + case 'response.created': { + const response = data as Record + 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 | 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 | 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 | undefined + const status = (response?.status as string) || 'completed' + const usage = response?.usage as Record | 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 + } + } +} diff --git a/src/server/proxy/transform/anthropicToOpenaiChat.ts b/src/server/proxy/transform/anthropicToOpenaiChat.ts new file mode 100644 index 00000000..47802070 --- /dev/null +++ b/src/server/proxy/transform/anthropicToOpenaiChat.ts @@ -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 => 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 + 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' +} diff --git a/src/server/proxy/transform/anthropicToOpenaiResponses.ts b/src/server/proxy/transform/anthropicToOpenaiResponses.ts new file mode 100644 index 00000000..2ba121c9 --- /dev/null +++ b/src/server/proxy/transform/anthropicToOpenaiResponses.ts @@ -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 => 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 + 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' +} diff --git a/src/server/proxy/transform/openaiChatToAnthropic.ts b/src/server/proxy/transform/openaiChatToAnthropic.ts new file mode 100644 index 00000000..bf4fd66f --- /dev/null +++ b/src/server/proxy/transform/openaiChatToAnthropic.ts @@ -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 + + // 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>) { + 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 = {} + 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), + } +} diff --git a/src/server/proxy/transform/openaiResponsesToAnthropic.ts b/src/server/proxy/transform/openaiResponsesToAnthropic.ts new file mode 100644 index 00000000..70a82265 --- /dev/null +++ b/src/server/proxy/transform/openaiResponsesToAnthropic.ts @@ -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 = {} + 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' + } +} diff --git a/src/server/proxy/transform/types.ts b/src/server/proxy/transform/types.ts new file mode 100644 index 00000000..103da968 --- /dev/null +++ b/src/server/proxy/transform/types.ts @@ -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 + } +} + +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; 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 + 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 + } +} diff --git a/src/server/services/providerService.ts b/src/server/services/providerService.ts index 9f1d08aa..8556ac07 100644 --- a/src/server/services/providerService.ts +++ b/src/server/services/providerService.ts @@ -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) || {} + 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 { - 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 { + 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 { - 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 { + 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 | 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 - if (body.error && typeof body.error === 'object') { - errorMessage = ((body.error as Record).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).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 { + 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; body: Record } { + 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 | 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).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 } +} + diff --git a/src/server/types/provider.ts b/src/server/types/provider.ts index 0a94a9a7..e8f65e3f 100644 --- a/src/server/types/provider.ts +++ b/src/server/types/provider.ts @@ -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 + 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 export type UpdateProviderInput = z.infer export type TestProviderInput = z.infer -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 +}