mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
feat(proxy): add multi-protocol proxy for OpenAI-compatible providers
Add a protocol-translating reverse proxy that allows using OpenAI-compatible API providers (DeepSeek, OpenRouter, Groq, etc.) with Claude Code. The proxy intercepts Anthropic Messages API requests from the CLI, transforms them to OpenAI Chat Completions or Responses API format, forwards to the upstream provider, and transforms streaming/non-streaming responses back. Key features: - Request transform: Anthropic Messages → OpenAI Chat/Responses - Response transform: OpenAI → Anthropic (streaming SSE + non-streaming) - Provider-agnostic reasoning support (reasoning_content, thinking_blocks, reasoning fields from DeepSeek, OpenAI o-series, GLM-5, Groq, etc.) - Event queue pattern for correct Anthropic SSE event ordering - Two-step test: ① connectivity check ② full proxy pipeline validation - Desktop UI: API format selector, two-step test results display - License attribution for cc-switch (MIT, Jason Young) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
85969318b9
commit
98a24f99b4
@ -44,8 +44,8 @@ export const providersApi = {
|
||||
return api.post<{ ok: true }>('/api/providers/official')
|
||||
},
|
||||
|
||||
test(id: string) {
|
||||
return api.post<TestResultResponse>(`/api/providers/${id}/test`)
|
||||
test(id: string, overrides?: { baseUrl?: string; modelId?: string; apiFormat?: string }) {
|
||||
return api.post<TestResultResponse>(`/api/providers/${id}/test`, overrides)
|
||||
},
|
||||
|
||||
testConfig(input: TestProviderConfigInput) {
|
||||
|
||||
@ -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: '',
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -59,6 +59,10 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.providers.officialDesc': 'Anthropic 原生接入 — 无需 API 密钥',
|
||||
'settings.providers.connected': '已连接 ({latency}ms)',
|
||||
'settings.providers.failed': '失败: {error}',
|
||||
'settings.providers.connectivityOk': '① 连通 ({latency}ms)',
|
||||
'settings.providers.connectivityFailed': '① 连通失败: {error}',
|
||||
'settings.providers.proxyOk': '② 代理转换 ({latency}ms)',
|
||||
'settings.providers.proxyFailed': '② 代理转换失败: {error}',
|
||||
'settings.providers.confirmDelete': '删除服务商 "{name}"?此操作不可撤销。',
|
||||
'settings.providers.activate': '激活',
|
||||
'settings.providers.test': '测试',
|
||||
@ -85,6 +89,11 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.providers.settingsJson': '设置 JSON',
|
||||
'settings.providers.settingsJsonDesc': '~/.claude/settings.json — 直接编辑,保存时写入。',
|
||||
'settings.providers.jsonError': 'JSON 语法错误: {error}',
|
||||
'settings.providers.apiFormat': 'API 格式',
|
||||
'settings.providers.apiFormatAnthropic': 'Anthropic Messages (原生)',
|
||||
'settings.providers.apiFormatOpenaiChat': 'OpenAI Chat Completions (代理转换)',
|
||||
'settings.providers.apiFormatOpenaiResponses': 'OpenAI Responses API (代理转换)',
|
||||
'settings.providers.proxyHint': '请求将通过本地代理转换协议格式',
|
||||
|
||||
// Settings > Permissions
|
||||
'settings.permissions.title': '权限模式',
|
||||
|
||||
@ -9,7 +9,7 @@ import type { PermissionMode, EffortLevel } from '../types/settings'
|
||||
import type { Locale } from '../i18n'
|
||||
import { PROVIDER_PRESETS } from '../config/providerPresets'
|
||||
import type { ProviderPreset } from '../config/providerPresets'
|
||||
import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping } from '../types/provider'
|
||||
import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat } from '../types/provider'
|
||||
import { AdapterSettings } from './AdapterSettings'
|
||||
import { useAgentStore } from '../stores/agentStore'
|
||||
import { useSessionStore } from '../stores/sessionStore'
|
||||
@ -92,7 +92,7 @@ function ProviderSettings() {
|
||||
const result = await testProvider(provider.id)
|
||||
setTestResults((r) => ({ ...r, [provider.id]: { loading: false, result } }))
|
||||
} catch {
|
||||
setTestResults((r) => ({ ...r, [provider.id]: { loading: false, result: { success: false, latencyMs: 0, error: t('settings.providers.requestFailed') } } }))
|
||||
setTestResults((r) => ({ ...r, [provider.id]: { loading: false, result: { connectivity: { success: false, latencyMs: 0, error: t('settings.providers.requestFailed') } } } }))
|
||||
}
|
||||
}
|
||||
|
||||
@ -169,6 +169,11 @@ function ProviderSettings() {
|
||||
{preset && preset.id !== 'custom' && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-[var(--color-surface-container-high)] text-[var(--color-text-tertiary)] leading-none">{preset.name}</span>
|
||||
)}
|
||||
{provider.apiFormat && provider.apiFormat !== 'anthropic' && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-medium rounded bg-[var(--color-surface-container-high)] text-[var(--color-warning)] leading-none">
|
||||
{provider.apiFormat === 'openai_chat' ? 'OpenAI Chat' : 'OpenAI Responses'}
|
||||
</span>
|
||||
)}
|
||||
{isActive && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-[var(--color-brand)] text-white leading-none">{t('common.active')}</span>
|
||||
)}
|
||||
@ -177,8 +182,19 @@ function ProviderSettings() {
|
||||
{provider.baseUrl} · {provider.models.main}
|
||||
</div>
|
||||
{test && !test.loading && test.result && (
|
||||
<div className={`text-xs mt-1 ${test.result.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
|
||||
{test.result.success ? t('settings.providers.connected', { latency: String(test.result.latencyMs) }) : t('settings.providers.failed', { error: test.result.error || '' })}
|
||||
<div className="text-xs mt-1 flex flex-col gap-0.5">
|
||||
<span className={test.result.connectivity.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}>
|
||||
{test.result.connectivity.success
|
||||
? t('settings.providers.connectivityOk', { latency: String(test.result.connectivity.latencyMs) })
|
||||
: t('settings.providers.connectivityFailed', { error: test.result.connectivity.error || '' })}
|
||||
</span>
|
||||
{test.result.proxy && (
|
||||
<span className={test.result.proxy.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}>
|
||||
{test.result.proxy.success
|
||||
? t('settings.providers.proxyOk', { latency: String(test.result.proxy.latencyMs) })
|
||||
: t('settings.providers.proxyFailed', { error: test.result.proxy.error || '' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -245,6 +261,7 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
|
||||
const [selectedPreset, setSelectedPreset] = useState<ProviderPreset>(initialPreset)
|
||||
const [name, setName] = useState(provider?.name ?? initialPreset.name)
|
||||
const [baseUrl, setBaseUrl] = useState(provider?.baseUrl ?? initialPreset.baseUrl)
|
||||
const [apiFormat, setApiFormat] = useState<ApiFormat>(provider?.apiFormat ?? initialPreset.apiFormat ?? 'anthropic')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [notes, setNotes] = useState(provider?.notes ?? '')
|
||||
const [models, setModels] = useState<ModelMapping>(provider?.models ?? { ...initialPreset.defaultModels })
|
||||
@ -264,12 +281,13 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
|
||||
}
|
||||
import('../api/settings').then(({ settingsApi }) => {
|
||||
settingsApi.getUser().then((settings) => {
|
||||
const needsProxy = apiFormat !== 'anthropic'
|
||||
const merged = {
|
||||
...settings,
|
||||
env: {
|
||||
...((settings.env as Record<string, string>) || {}),
|
||||
ANTHROPIC_BASE_URL: baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: apiKey || '(your API key)',
|
||||
ANTHROPIC_BASE_URL: needsProxy ? 'http://127.0.0.1:3456/proxy' : baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: needsProxy ? 'proxy-managed' : (apiKey || '(your API key)'),
|
||||
ANTHROPIC_MODEL: models.main,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
|
||||
@ -288,6 +306,7 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
|
||||
setSelectedPreset(preset)
|
||||
setName(preset.name)
|
||||
setBaseUrl(preset.baseUrl)
|
||||
setApiFormat(preset.apiFormat ?? 'anthropic')
|
||||
setModels({ ...preset.defaultModels })
|
||||
setTestResult(null)
|
||||
}
|
||||
@ -316,6 +335,7 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
|
||||
name: name.trim(),
|
||||
apiKey: apiKey.trim(),
|
||||
baseUrl: baseUrl.trim(),
|
||||
apiFormat,
|
||||
models,
|
||||
notes: notes.trim() || undefined,
|
||||
})
|
||||
@ -323,6 +343,7 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
|
||||
const input: UpdateProviderInput = {
|
||||
name: name.trim(),
|
||||
baseUrl: baseUrl.trim(),
|
||||
apiFormat,
|
||||
models,
|
||||
notes: notes.trim() || undefined,
|
||||
}
|
||||
@ -345,14 +366,18 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
|
||||
try {
|
||||
let result: ProviderTestResult
|
||||
if (mode === 'edit' && provider && !apiKey.trim()) {
|
||||
result = await useProviderStore.getState().testProvider(provider.id)
|
||||
result = await useProviderStore.getState().testProvider(provider.id, {
|
||||
baseUrl: baseUrl.trim(),
|
||||
modelId: models.main.trim(),
|
||||
apiFormat,
|
||||
})
|
||||
} else {
|
||||
if (!apiKey.trim()) return
|
||||
result = await testConfig({ baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), modelId: models.main.trim() })
|
||||
result = await testConfig({ baseUrl: baseUrl.trim(), apiKey: apiKey.trim(), modelId: models.main.trim(), apiFormat })
|
||||
}
|
||||
setTestResult(result)
|
||||
} catch {
|
||||
setTestResult({ success: false, latencyMs: 0, error: t('settings.providers.requestFailed') })
|
||||
setTestResult({ connectivity: { success: false, latencyMs: 0, error: t('settings.providers.requestFailed') } })
|
||||
} finally {
|
||||
setIsTesting(false)
|
||||
}
|
||||
@ -412,6 +437,32 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Format */}
|
||||
{(isCustom || mode === 'edit') ? (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-1 block">{t('settings.providers.apiFormat')}</label>
|
||||
<select
|
||||
value={apiFormat}
|
||||
onChange={(e) => setApiFormat(e.target.value as ApiFormat)}
|
||||
className="w-full text-sm px-3 py-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container-low)] border border-[var(--color-border)] text-[var(--color-text-primary)] outline-none focus:border-[var(--color-border-focus)]"
|
||||
>
|
||||
<option value="anthropic">{t('settings.providers.apiFormatAnthropic')}</option>
|
||||
<option value="openai_chat">{t('settings.providers.apiFormatOpenaiChat')}</option>
|
||||
<option value="openai_responses">{t('settings.providers.apiFormatOpenaiResponses')}</option>
|
||||
</select>
|
||||
{apiFormat !== 'anthropic' && (
|
||||
<p className="text-[11px] text-[var(--color-text-tertiary)] mt-1">{t('settings.providers.proxyHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
) : apiFormat !== 'anthropic' ? (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-[var(--color-text-primary)] mb-1 block">{t('settings.providers.apiFormat')}</label>
|
||||
<div className="text-xs text-[var(--color-text-tertiary)] px-3 py-2 rounded-[var(--radius-md)] bg-[var(--color-surface-container-low)] border border-[var(--color-border)]">
|
||||
{apiFormat === 'openai_chat' ? t('settings.providers.apiFormatOpenaiChat') : t('settings.providers.apiFormatOpenaiResponses')}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Input
|
||||
label={mode === 'edit' ? t('settings.providers.apiKeyKeep') : t('settings.providers.apiKey')}
|
||||
required={mode === 'create'}
|
||||
@ -438,9 +489,20 @@ function ProviderFormModal({ open, onClose, mode, provider }: ProviderFormProps)
|
||||
{t('settings.providers.testConnection')}
|
||||
</Button>
|
||||
{testResult && (
|
||||
<span className={`text-xs ${testResult.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
|
||||
{testResult.success ? t('settings.providers.connected', { latency: String(testResult.latencyMs) }) : t('settings.providers.failed', { error: testResult.error || '' })}
|
||||
</span>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className={`text-xs ${testResult.connectivity.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
|
||||
{testResult.connectivity.success
|
||||
? t('settings.providers.connectivityOk', { latency: String(testResult.connectivity.latencyMs) })
|
||||
: t('settings.providers.connectivityFailed', { error: testResult.connectivity.error || '' })}
|
||||
</span>
|
||||
{testResult.proxy && (
|
||||
<span className={`text-xs ${testResult.proxy.success ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'}`}>
|
||||
{testResult.proxy.success
|
||||
? t('settings.providers.proxyOk', { latency: String(testResult.proxy.latencyMs) })
|
||||
: t('settings.providers.proxyFailed', { error: testResult.proxy.error || '' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@ type ProviderStore = {
|
||||
deleteProvider: (id: string) => Promise<void>
|
||||
activateProvider: (id: string) => Promise<void>
|
||||
activateOfficial: () => Promise<void>
|
||||
testProvider: (id: string) => Promise<ProviderTestResult>
|
||||
testProvider: (id: string, overrides?: { baseUrl?: string; modelId?: string; apiFormat?: string }) => Promise<ProviderTestResult>
|
||||
testConfig: (input: TestProviderConfigInput) => Promise<ProviderTestResult>
|
||||
}
|
||||
|
||||
@ -67,8 +67,8 @@ export const useProviderStore = create<ProviderStore>((set, get) => ({
|
||||
await get().fetchProviders()
|
||||
},
|
||||
|
||||
testProvider: async (id) => {
|
||||
const { result } = await providersApi.test(id)
|
||||
testProvider: async (id, overrides?) => {
|
||||
const { result } = await providersApi.test(id, overrides)
|
||||
return result
|
||||
},
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
317
src/server/__tests__/proxy-streaming.test.ts
Normal file
317
src/server/__tests__/proxy-streaming.test.ts
Normal file
@ -0,0 +1,317 @@
|
||||
/**
|
||||
* Unit tests for proxy streaming SSE transformation
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { openaiChatStreamToAnthropic } from '../proxy/streaming/openaiChatStreamToAnthropic.js'
|
||||
import { openaiResponsesStreamToAnthropic } from '../proxy/streaming/openaiResponsesStreamToAnthropic.js'
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
function makeStream(chunks: string[]): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder()
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(encoder.encode(chunk))
|
||||
}
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function collectSse(stream: ReadableStream<Uint8Array>): Promise<Array<{ event: string; data: Record<string, unknown> }>> {
|
||||
const decoder = new TextDecoder()
|
||||
const reader = stream.getReader()
|
||||
let text = ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
text += decoder.decode(value, { stream: true })
|
||||
}
|
||||
|
||||
const events: Array<{ event: string; data: Record<string, unknown> }> = []
|
||||
const blocks = text.split('\n\n').filter(Boolean)
|
||||
for (const block of blocks) {
|
||||
const lines = block.split('\n')
|
||||
let event = ''
|
||||
let data = ''
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) event = line.slice(7)
|
||||
if (line.startsWith('data: ')) data = line.slice(6)
|
||||
}
|
||||
if (event && data) {
|
||||
try {
|
||||
events.push({ event, data: JSON.parse(data) })
|
||||
} catch {
|
||||
// skip unparseable
|
||||
}
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
// ─── OpenAI Chat Completions SSE → Anthropic SSE ───────────────
|
||||
|
||||
describe('openaiChatStreamToAnthropic', () => {
|
||||
test('basic text streaming', async () => {
|
||||
const sseChunks = [
|
||||
'data: {"id":"c1","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}\n\n',
|
||||
'data: {"id":"c1","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}\n\n',
|
||||
'data: {"id":"c1","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"content":" world"},"finish_reason":null}]}\n\n',
|
||||
'data: {"id":"c1","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]
|
||||
|
||||
const upstream = makeStream(sseChunks)
|
||||
const anthropicStream = openaiChatStreamToAnthropic(upstream, 'gpt-4')
|
||||
const events = await collectSse(anthropicStream)
|
||||
|
||||
// Should have: message_start, content_block_start, content_block_delta x2, message_delta, content_block_stop, message_stop
|
||||
const eventTypes = events.map((e) => e.event)
|
||||
expect(eventTypes[0]).toBe('message_start')
|
||||
expect(eventTypes).toContain('content_block_start')
|
||||
expect(eventTypes).toContain('content_block_delta')
|
||||
expect(eventTypes).toContain('message_delta')
|
||||
expect(eventTypes).toContain('message_stop')
|
||||
|
||||
// Check message_start
|
||||
const msgStart = events.find((e) => e.event === 'message_start')!
|
||||
expect((msgStart.data.message as Record<string, unknown>).model).toBe('gpt-4')
|
||||
expect((msgStart.data.message as Record<string, unknown>).role).toBe('assistant')
|
||||
|
||||
// Check text deltas
|
||||
const textDeltas = events.filter((e) => e.event === 'content_block_delta')
|
||||
const texts = textDeltas.map((e) => (e.data.delta as Record<string, unknown>).text)
|
||||
expect(texts).toContain('Hello')
|
||||
expect(texts).toContain(' world')
|
||||
|
||||
// Check stop reason
|
||||
const msgDelta = events.find((e) => e.event === 'message_delta')!
|
||||
expect((msgDelta.data.delta as Record<string, unknown>).stop_reason).toBe('end_turn')
|
||||
})
|
||||
|
||||
test('tool call streaming', async () => {
|
||||
const sseChunks = [
|
||||
'data: {"id":"c2","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"role":"assistant","content":null},"finish_reason":null}]}\n\n',
|
||||
'data: {"id":"c2","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":null}]}\n\n',
|
||||
'data: {"id":"c2","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"city\\""}}]},"finish_reason":null}]}\n\n',
|
||||
'data: {"id":"c2","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":":\\"NYC\\"}"}}]},"finish_reason":null}]}\n\n',
|
||||
'data: {"id":"c2","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]
|
||||
|
||||
const upstream = makeStream(sseChunks)
|
||||
const anthropicStream = openaiChatStreamToAnthropic(upstream, 'gpt-4')
|
||||
const events = await collectSse(anthropicStream)
|
||||
|
||||
// Should have content_block_start with type tool_use
|
||||
const toolStart = events.find(
|
||||
(e) => e.event === 'content_block_start' && (e.data.content_block as Record<string, unknown>)?.type === 'tool_use',
|
||||
)
|
||||
expect(toolStart).toBeDefined()
|
||||
expect((toolStart!.data.content_block as Record<string, unknown>).name).toBe('get_weather')
|
||||
expect((toolStart!.data.content_block as Record<string, unknown>).id).toBe('call_1')
|
||||
|
||||
// Should have input_json_delta
|
||||
const jsonDeltas = events.filter(
|
||||
(e) => e.event === 'content_block_delta' && (e.data.delta as Record<string, unknown>)?.type === 'input_json_delta',
|
||||
)
|
||||
expect(jsonDeltas.length).toBeGreaterThan(0)
|
||||
|
||||
// Stop reason should be tool_use
|
||||
const msgDelta = events.find((e) => e.event === 'message_delta')!
|
||||
expect((msgDelta.data.delta as Record<string, unknown>).stop_reason).toBe('tool_use')
|
||||
})
|
||||
|
||||
test('empty stream (just DONE)', async () => {
|
||||
const upstream = makeStream(['data: [DONE]\n\n'])
|
||||
const anthropicStream = openaiChatStreamToAnthropic(upstream, 'gpt-4')
|
||||
const events = await collectSse(anthropicStream)
|
||||
// Should at least have message_stop
|
||||
expect(events.some((e) => e.event === 'message_stop')).toBe(true)
|
||||
})
|
||||
|
||||
test('event ordering: content_block_stop before message_delta', async () => {
|
||||
const sseChunks = [
|
||||
'data: {"id":"c3","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}\n\n',
|
||||
'data: {"id":"c3","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"content":"Hi"},"finish_reason":null}]}\n\n',
|
||||
'data: {"id":"c3","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]
|
||||
|
||||
const upstream = makeStream(sseChunks)
|
||||
const events = await collectSse(openaiChatStreamToAnthropic(upstream, 'gpt-4'))
|
||||
const types = events.map((e) => e.event)
|
||||
|
||||
// content_block_stop MUST appear before message_delta
|
||||
const stopIdx = types.indexOf('content_block_stop')
|
||||
const deltaIdx = types.indexOf('message_delta')
|
||||
expect(stopIdx).toBeGreaterThan(-1)
|
||||
expect(deltaIdx).toBeGreaterThan(-1)
|
||||
expect(stopIdx).toBeLessThan(deltaIdx)
|
||||
|
||||
// message_delta before message_stop
|
||||
const msgStopIdx = types.indexOf('message_stop')
|
||||
expect(deltaIdx).toBeLessThan(msgStopIdx)
|
||||
})
|
||||
|
||||
test('reasoning_content (DeepSeek, OpenRouter, XAI)', async () => {
|
||||
const sseChunks = [
|
||||
'data: {"id":"c4","object":"chat.completion.chunk","created":0,"model":"deepseek-chat","choices":[{"index":0,"delta":{"role":"assistant","content":"","reasoning_content":"Let me think"},"finish_reason":null}]}\n\n',
|
||||
'data: {"id":"c4","object":"chat.completion.chunk","created":0,"model":"deepseek-chat","choices":[{"index":0,"delta":{"reasoning_content":" about this..."},"finish_reason":null}]}\n\n',
|
||||
'data: {"id":"c4","object":"chat.completion.chunk","created":0,"model":"deepseek-chat","choices":[{"index":0,"delta":{"content":"Hello!"},"finish_reason":null}]}\n\n',
|
||||
'data: {"id":"c4","object":"chat.completion.chunk","created":0,"model":"deepseek-chat","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]
|
||||
|
||||
const upstream = makeStream(sseChunks)
|
||||
const events = await collectSse(openaiChatStreamToAnthropic(upstream, 'deepseek-chat'))
|
||||
|
||||
// Should have thinking block
|
||||
const thinkingStart = events.find(
|
||||
(e) => e.event === 'content_block_start' && (e.data.content_block as Record<string, unknown>)?.type === 'thinking',
|
||||
)
|
||||
expect(thinkingStart).toBeDefined()
|
||||
|
||||
// Should have thinking deltas
|
||||
const thinkingDeltas = events.filter(
|
||||
(e) => e.event === 'content_block_delta' && (e.data.delta as Record<string, unknown>)?.type === 'thinking_delta',
|
||||
)
|
||||
expect(thinkingDeltas.length).toBeGreaterThan(0)
|
||||
|
||||
// Should have text block after thinking
|
||||
const textStart = events.find(
|
||||
(e) => e.event === 'content_block_start' && (e.data.content_block as Record<string, unknown>)?.type === 'text',
|
||||
)
|
||||
expect(textStart).toBeDefined()
|
||||
|
||||
// Text should come after thinking in index order
|
||||
expect((textStart!.data as Record<string, unknown>).index).toBeGreaterThan(
|
||||
(thinkingStart!.data as Record<string, unknown>).index as number,
|
||||
)
|
||||
})
|
||||
|
||||
test('reasoning field (GLM-5, Cerebras, Groq)', async () => {
|
||||
const sseChunks = [
|
||||
'data: {"id":"c5","object":"chat.completion.chunk","created":0,"model":"glm-5","choices":[{"index":0,"delta":{"role":"assistant","reasoning":"Thinking here"},"finish_reason":null}]}\n\n',
|
||||
'data: {"id":"c5","object":"chat.completion.chunk","created":0,"model":"glm-5","choices":[{"index":0,"delta":{"content":"Result"},"finish_reason":null}]}\n\n',
|
||||
'data: {"id":"c5","object":"chat.completion.chunk","created":0,"model":"glm-5","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]
|
||||
|
||||
const upstream = makeStream(sseChunks)
|
||||
const events = await collectSse(openaiChatStreamToAnthropic(upstream, 'glm-5'))
|
||||
|
||||
// Should produce thinking delta from "reasoning" field
|
||||
const thinkingDeltas = events.filter(
|
||||
(e) => e.event === 'content_block_delta' && (e.data.delta as Record<string, unknown>)?.type === 'thinking_delta',
|
||||
)
|
||||
expect(thinkingDeltas.length).toBe(1)
|
||||
expect((thinkingDeltas[0].data.delta as Record<string, unknown>).thinking).toBe('Thinking here')
|
||||
})
|
||||
|
||||
test('thinking_blocks (OpenAI o-series)', async () => {
|
||||
const sseChunks = [
|
||||
'data: {"id":"c6","object":"chat.completion.chunk","created":0,"model":"o3","choices":[{"index":0,"delta":{"role":"assistant","thinking_blocks":[{"type":"thinking","thinking":"Deep thought"}]},"finish_reason":null}]}\n\n',
|
||||
'data: {"id":"c6","object":"chat.completion.chunk","created":0,"model":"o3","choices":[{"index":0,"delta":{"content":"Answer"},"finish_reason":null}]}\n\n',
|
||||
'data: {"id":"c6","object":"chat.completion.chunk","created":0,"model":"o3","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]
|
||||
|
||||
const upstream = makeStream(sseChunks)
|
||||
const events = await collectSse(openaiChatStreamToAnthropic(upstream, 'o3'))
|
||||
|
||||
const thinkingDeltas = events.filter(
|
||||
(e) => e.event === 'content_block_delta' && (e.data.delta as Record<string, unknown>)?.type === 'thinking_delta',
|
||||
)
|
||||
expect(thinkingDeltas.length).toBe(1)
|
||||
expect((thinkingDeltas[0].data.delta as Record<string, unknown>).thinking).toBe('Deep thought')
|
||||
})
|
||||
|
||||
test('text + tool transition closes text block first', async () => {
|
||||
const sseChunks = [
|
||||
'data: {"id":"c7","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"content":"Let me search"},"finish_reason":null}]}\n\n',
|
||||
'data: {"id":"c7","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_x","type":"function","function":{"name":"search","arguments":"{\\"q\\":\\"test\\"}"}}]},"finish_reason":null}]}\n\n',
|
||||
'data: {"id":"c7","object":"chat.completion.chunk","created":0,"model":"gpt-4","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]
|
||||
|
||||
const upstream = makeStream(sseChunks)
|
||||
const events = await collectSse(openaiChatStreamToAnthropic(upstream, 'gpt-4'))
|
||||
const types = events.map((e) => e.event)
|
||||
|
||||
// Should see: text block start, text delta, text block stop, tool block start, ...
|
||||
const firstBlockStop = types.indexOf('content_block_stop')
|
||||
const toolBlockStart = types.findIndex(
|
||||
(_, i) => events[i].event === 'content_block_start' && (events[i].data.content_block as Record<string, unknown>)?.type === 'tool_use',
|
||||
)
|
||||
expect(firstBlockStop).toBeLessThan(toolBlockStart)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── OpenAI Responses SSE → Anthropic SSE ──────────────────────
|
||||
|
||||
describe('openaiResponsesStreamToAnthropic', () => {
|
||||
test('basic text streaming', async () => {
|
||||
const sseChunks = [
|
||||
'event: response.created\ndata: {"id":"r1","model":"gpt-4o","status":"in_progress"}\n\n',
|
||||
'event: response.output_item.added\ndata: {"output_index":0,"item":{"type":"message","role":"assistant"}}\n\n',
|
||||
'event: response.content_part.added\ndata: {"output_index":0,"content_index":0,"part":{"type":"output_text","text":""}}\n\n',
|
||||
'event: response.output_text.delta\ndata: {"output_index":0,"content_index":0,"delta":"Hello"}\n\n',
|
||||
'event: response.output_text.delta\ndata: {"output_index":0,"content_index":0,"delta":" world"}\n\n',
|
||||
'event: response.output_text.done\ndata: {"output_index":0,"content_index":0,"text":"Hello world"}\n\n',
|
||||
'event: response.completed\ndata: {"response":{"id":"r1","model":"gpt-4o","status":"completed","usage":{"input_tokens":10,"output_tokens":5}}}\n\n',
|
||||
]
|
||||
|
||||
const upstream = makeStream(sseChunks)
|
||||
const anthropicStream = openaiResponsesStreamToAnthropic(upstream, 'gpt-4o')
|
||||
const events = await collectSse(anthropicStream)
|
||||
|
||||
const eventTypes = events.map((e) => e.event)
|
||||
expect(eventTypes[0]).toBe('message_start')
|
||||
expect(eventTypes).toContain('content_block_start')
|
||||
expect(eventTypes).toContain('content_block_delta')
|
||||
expect(eventTypes).toContain('content_block_stop')
|
||||
expect(eventTypes).toContain('message_delta')
|
||||
expect(eventTypes).toContain('message_stop')
|
||||
|
||||
// Check text deltas
|
||||
const textDeltas = events.filter((e) => e.event === 'content_block_delta')
|
||||
const texts = textDeltas.map((e) => (e.data.delta as Record<string, unknown>).text)
|
||||
expect(texts).toContain('Hello')
|
||||
expect(texts).toContain(' world')
|
||||
})
|
||||
|
||||
test('function call streaming', async () => {
|
||||
const sseChunks = [
|
||||
'event: response.created\ndata: {"id":"r2","model":"gpt-4o","status":"in_progress"}\n\n',
|
||||
'event: response.output_item.added\ndata: {"output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"search"}}\n\n',
|
||||
'event: response.function_call_arguments.delta\ndata: {"item_id":"fc_1","delta":"{\\"q\\":"}\n\n',
|
||||
'event: response.function_call_arguments.delta\ndata: {"item_id":"fc_1","delta":"\\"test\\"}"}\n\n',
|
||||
'event: response.function_call_arguments.done\ndata: {"item_id":"fc_1","arguments":"{\\"q\\":\\"test\\"}"}\n\n',
|
||||
'event: response.completed\ndata: {"response":{"id":"r2","model":"gpt-4o","status":"completed","usage":{"input_tokens":10,"output_tokens":5}}}\n\n',
|
||||
]
|
||||
|
||||
const upstream = makeStream(sseChunks)
|
||||
const anthropicStream = openaiResponsesStreamToAnthropic(upstream, 'gpt-4o')
|
||||
const events = await collectSse(anthropicStream)
|
||||
|
||||
// Should have tool_use content_block_start
|
||||
const toolStart = events.find(
|
||||
(e) => e.event === 'content_block_start' && (e.data.content_block as Record<string, unknown>)?.type === 'tool_use',
|
||||
)
|
||||
expect(toolStart).toBeDefined()
|
||||
expect((toolStart!.data.content_block as Record<string, unknown>).name).toBe('search')
|
||||
|
||||
// Should have input_json_delta
|
||||
const jsonDeltas = events.filter(
|
||||
(e) => e.event === 'content_block_delta' && (e.data.delta as Record<string, unknown>)?.type === 'input_json_delta',
|
||||
)
|
||||
expect(jsonDeltas.length).toBeGreaterThan(0)
|
||||
|
||||
// Stop reason should be tool_use
|
||||
const msgDelta = events.find((e) => e.event === 'message_delta')!
|
||||
expect((msgDelta.data.delta as Record<string, unknown>).stop_reason).toBe('tool_use')
|
||||
})
|
||||
})
|
||||
469
src/server/__tests__/proxy-transform.test.ts
Normal file
469
src/server/__tests__/proxy-transform.test.ts
Normal file
@ -0,0 +1,469 @@
|
||||
/**
|
||||
* Unit tests for proxy protocol transformation
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test'
|
||||
import { anthropicToOpenaiChat } from '../proxy/transform/anthropicToOpenaiChat.js'
|
||||
import { anthropicToOpenaiResponses } from '../proxy/transform/anthropicToOpenaiResponses.js'
|
||||
import { openaiChatToAnthropic } from '../proxy/transform/openaiChatToAnthropic.js'
|
||||
import { openaiResponsesToAnthropic } from '../proxy/transform/openaiResponsesToAnthropic.js'
|
||||
import type { AnthropicRequest, OpenAIChatResponse, OpenAIResponsesResponse } from '../proxy/transform/types.js'
|
||||
|
||||
// ─── anthropicToOpenaiChat ──────────────────────────────────────
|
||||
|
||||
describe('anthropicToOpenaiChat', () => {
|
||||
test('basic text message', () => {
|
||||
const req: AnthropicRequest = {
|
||||
model: 'gpt-4',
|
||||
max_tokens: 1024,
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
}
|
||||
const result = anthropicToOpenaiChat(req)
|
||||
expect(result.model).toBe('gpt-4')
|
||||
expect(result.max_tokens).toBeUndefined()
|
||||
expect(result.messages).toEqual([{ role: 'user', content: 'Hello' }])
|
||||
})
|
||||
|
||||
test('system prompt string', () => {
|
||||
const req: AnthropicRequest = {
|
||||
model: 'gpt-4',
|
||||
max_tokens: 100,
|
||||
system: 'You are helpful',
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
}
|
||||
const result = anthropicToOpenaiChat(req)
|
||||
expect(result.messages[0]).toEqual({ role: 'system', content: 'You are helpful' })
|
||||
expect(result.messages[1]).toEqual({ role: 'user', content: 'Hi' })
|
||||
})
|
||||
|
||||
test('system prompt array', () => {
|
||||
const req: AnthropicRequest = {
|
||||
model: 'gpt-4',
|
||||
max_tokens: 100,
|
||||
system: [{ type: 'text', text: 'Part 1' }, { type: 'text', text: 'Part 2' }],
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
}
|
||||
const result = anthropicToOpenaiChat(req)
|
||||
expect(result.messages[0]).toEqual({ role: 'system', content: 'Part 1\nPart 2' })
|
||||
})
|
||||
|
||||
test('stop_sequences → stop', () => {
|
||||
const req: AnthropicRequest = {
|
||||
model: 'gpt-4',
|
||||
max_tokens: 100,
|
||||
stop_sequences: ['END', 'STOP'],
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
}
|
||||
const result = anthropicToOpenaiChat(req)
|
||||
expect(result.stop).toEqual(['END', 'STOP'])
|
||||
})
|
||||
|
||||
test('tools conversion', () => {
|
||||
const req: AnthropicRequest = {
|
||||
model: 'gpt-4',
|
||||
max_tokens: 100,
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
tools: [{
|
||||
name: 'get_weather',
|
||||
description: 'Get weather',
|
||||
input_schema: { type: 'object', properties: { city: { type: 'string' } } },
|
||||
}],
|
||||
}
|
||||
const result = anthropicToOpenaiChat(req)
|
||||
expect(result.tools).toHaveLength(1)
|
||||
expect(result.tools![0].type).toBe('function')
|
||||
expect(result.tools![0].function.name).toBe('get_weather')
|
||||
expect(result.tools![0].function.parameters).toEqual({ type: 'object', properties: { city: { type: 'string' } } })
|
||||
})
|
||||
|
||||
test('filters BatchTool', () => {
|
||||
const req: AnthropicRequest = {
|
||||
model: 'gpt-4',
|
||||
max_tokens: 100,
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
tools: [
|
||||
{ name: 'BatchTool', input_schema: {} },
|
||||
{ name: 'real_tool', input_schema: {} },
|
||||
],
|
||||
}
|
||||
const result = anthropicToOpenaiChat(req)
|
||||
expect(result.tools).toHaveLength(1)
|
||||
expect(result.tools![0].function.name).toBe('real_tool')
|
||||
})
|
||||
|
||||
test('tool_choice conversion', () => {
|
||||
const req: AnthropicRequest = {
|
||||
model: 'gpt-4',
|
||||
max_tokens: 100,
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
tool_choice: { type: 'any' },
|
||||
}
|
||||
const result = anthropicToOpenaiChat(req)
|
||||
expect(result.tool_choice).toBe('required')
|
||||
})
|
||||
|
||||
test('tool_choice type=tool', () => {
|
||||
const req: AnthropicRequest = {
|
||||
model: 'gpt-4',
|
||||
max_tokens: 100,
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
tool_choice: { type: 'tool', name: 'get_weather' },
|
||||
}
|
||||
const result = anthropicToOpenaiChat(req)
|
||||
expect(result.tool_choice).toEqual({ type: 'function', function: { name: 'get_weather' } })
|
||||
})
|
||||
|
||||
test('thinking budget → reasoning_effort', () => {
|
||||
const lowReq: AnthropicRequest = {
|
||||
model: 'gpt-4',
|
||||
max_tokens: 100,
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
thinking: { type: 'enabled', budget_tokens: 512 },
|
||||
}
|
||||
expect(anthropicToOpenaiChat(lowReq).reasoning_effort).toBe('low')
|
||||
|
||||
const medReq: AnthropicRequest = {
|
||||
model: 'gpt-4',
|
||||
max_tokens: 100,
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
thinking: { type: 'enabled', budget_tokens: 4096 },
|
||||
}
|
||||
expect(anthropicToOpenaiChat(medReq).reasoning_effort).toBe('medium')
|
||||
|
||||
const highReq: AnthropicRequest = {
|
||||
model: 'gpt-4',
|
||||
max_tokens: 100,
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
thinking: { type: 'enabled', budget_tokens: 16000 },
|
||||
}
|
||||
expect(anthropicToOpenaiChat(highReq).reasoning_effort).toBe('high')
|
||||
})
|
||||
|
||||
test('assistant message with tool_use', () => {
|
||||
const req: AnthropicRequest = {
|
||||
model: 'gpt-4',
|
||||
max_tokens: 100,
|
||||
messages: [{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: 'Let me check' },
|
||||
{ type: 'tool_use', id: 'tc_1', name: 'get_weather', input: { city: 'NYC' } },
|
||||
],
|
||||
}],
|
||||
}
|
||||
const result = anthropicToOpenaiChat(req)
|
||||
const msg = result.messages[0]
|
||||
expect(msg.role).toBe('assistant')
|
||||
expect(msg.content).toBe('Let me check')
|
||||
expect(msg.tool_calls).toHaveLength(1)
|
||||
expect(msg.tool_calls![0].id).toBe('tc_1')
|
||||
expect(msg.tool_calls![0].function.name).toBe('get_weather')
|
||||
expect(msg.tool_calls![0].function.arguments).toBe('{"city":"NYC"}')
|
||||
})
|
||||
|
||||
test('user message with tool_result', () => {
|
||||
const req: AnthropicRequest = {
|
||||
model: 'gpt-4',
|
||||
max_tokens: 100,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: 'tc_1', content: 'Sunny, 72°F' },
|
||||
],
|
||||
}],
|
||||
}
|
||||
const result = anthropicToOpenaiChat(req)
|
||||
expect(result.messages[0].role).toBe('tool')
|
||||
expect(result.messages[0].tool_call_id).toBe('tc_1')
|
||||
expect(result.messages[0].content).toBe('Sunny, 72°F')
|
||||
})
|
||||
|
||||
test('image content conversion', () => {
|
||||
const req: AnthropicRequest = {
|
||||
model: 'gpt-4',
|
||||
max_tokens: 100,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'abc123' } },
|
||||
],
|
||||
}],
|
||||
}
|
||||
const result = anthropicToOpenaiChat(req)
|
||||
const content = result.messages[0].content as Array<{ type: string; image_url?: { url: string } }>
|
||||
expect(content[0].type).toBe('image_url')
|
||||
expect(content[0].image_url!.url).toBe('data:image/png;base64,abc123')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── openaiChatToAnthropic ──────────────────────────────────────
|
||||
|
||||
describe('openaiChatToAnthropic', () => {
|
||||
test('basic text response', () => {
|
||||
const res: OpenAIChatResponse = {
|
||||
id: 'chatcmpl-1',
|
||||
object: 'chat.completion',
|
||||
created: 1234567890,
|
||||
model: 'gpt-4',
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: 'Hello!' },
|
||||
finish_reason: 'stop',
|
||||
}],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
||||
}
|
||||
const result = openaiChatToAnthropic(res, 'gpt-4')
|
||||
expect(result.type).toBe('message')
|
||||
expect(result.role).toBe('assistant')
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'Hello!' }])
|
||||
expect(result.stop_reason).toBe('end_turn')
|
||||
expect(result.usage.input_tokens).toBe(10)
|
||||
expect(result.usage.output_tokens).toBe(5)
|
||||
})
|
||||
|
||||
test('tool_calls response', () => {
|
||||
const res: OpenAIChatResponse = {
|
||||
id: 'chatcmpl-2',
|
||||
object: 'chat.completion',
|
||||
created: 1234567890,
|
||||
model: 'gpt-4',
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: null,
|
||||
tool_calls: [{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'get_weather', arguments: '{"city":"NYC"}' },
|
||||
}],
|
||||
},
|
||||
finish_reason: 'tool_calls',
|
||||
}],
|
||||
}
|
||||
const result = openaiChatToAnthropic(res, 'gpt-4')
|
||||
expect(result.stop_reason).toBe('tool_use')
|
||||
expect(result.content).toHaveLength(1)
|
||||
expect(result.content[0].type).toBe('tool_use')
|
||||
if (result.content[0].type === 'tool_use') {
|
||||
expect(result.content[0].id).toBe('call_1')
|
||||
expect(result.content[0].name).toBe('get_weather')
|
||||
expect(result.content[0].input).toEqual({ city: 'NYC' })
|
||||
}
|
||||
})
|
||||
|
||||
test('finish_reason mapping', () => {
|
||||
const make = (reason: string) => ({
|
||||
id: 'x', object: 'chat.completion', created: 0, model: 'gpt-4',
|
||||
choices: [{ index: 0, message: { role: 'assistant', content: 'hi' }, finish_reason: reason }],
|
||||
} as OpenAIChatResponse)
|
||||
|
||||
expect(openaiChatToAnthropic(make('stop'), 'gpt-4').stop_reason).toBe('end_turn')
|
||||
expect(openaiChatToAnthropic(make('length'), 'gpt-4').stop_reason).toBe('max_tokens')
|
||||
expect(openaiChatToAnthropic(make('tool_calls'), 'gpt-4').stop_reason).toBe('tool_use')
|
||||
expect(openaiChatToAnthropic(make('content_filter'), 'gpt-4').stop_reason).toBe('end_turn')
|
||||
})
|
||||
|
||||
test('empty choices', () => {
|
||||
const res: OpenAIChatResponse = {
|
||||
id: 'x', object: 'chat.completion', created: 0, model: 'gpt-4',
|
||||
choices: [],
|
||||
}
|
||||
const result = openaiChatToAnthropic(res, 'gpt-4')
|
||||
expect(result.content).toEqual([{ type: 'text', text: '' }])
|
||||
expect(result.stop_reason).toBe('end_turn')
|
||||
})
|
||||
|
||||
test('cached tokens mapping', () => {
|
||||
const res: OpenAIChatResponse = {
|
||||
id: 'x', object: 'chat.completion', created: 0, model: 'gpt-4',
|
||||
choices: [{ index: 0, message: { role: 'assistant', content: 'hi' }, finish_reason: 'stop' }],
|
||||
usage: {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 50,
|
||||
total_tokens: 150,
|
||||
prompt_tokens_details: { cached_tokens: 80 },
|
||||
},
|
||||
}
|
||||
const result = openaiChatToAnthropic(res, 'gpt-4')
|
||||
expect(result.usage.cache_read_input_tokens).toBe(80)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── anthropicToOpenaiResponses ─────────────────────────────────
|
||||
|
||||
describe('anthropicToOpenaiResponses', () => {
|
||||
test('basic message', () => {
|
||||
const req: AnthropicRequest = {
|
||||
model: 'gpt-4o',
|
||||
max_tokens: 1024,
|
||||
system: 'Be helpful',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
}
|
||||
const result = anthropicToOpenaiResponses(req)
|
||||
expect(result.model).toBe('gpt-4o')
|
||||
expect(result.instructions).toBe('Be helpful')
|
||||
expect(result.max_output_tokens).toBeUndefined()
|
||||
expect(result.input).toEqual([{ type: 'message', role: 'user', content: 'Hello' }])
|
||||
})
|
||||
|
||||
test('tool_use lifted to function_call', () => {
|
||||
const req: AnthropicRequest = {
|
||||
model: 'gpt-4o',
|
||||
max_tokens: 100,
|
||||
messages: [{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', id: 'tc_1', name: 'search', input: { q: 'test' } },
|
||||
],
|
||||
}],
|
||||
}
|
||||
const result = anthropicToOpenaiResponses(req)
|
||||
const fc = result.input.find((i) => i.type === 'function_call')
|
||||
expect(fc).toBeDefined()
|
||||
if (fc && fc.type === 'function_call') {
|
||||
expect(fc.call_id).toBe('tc_1')
|
||||
expect(fc.name).toBe('search')
|
||||
expect(fc.arguments).toBe('{"q":"test"}')
|
||||
}
|
||||
})
|
||||
|
||||
test('tool_result lifted to function_call_output', () => {
|
||||
const req: AnthropicRequest = {
|
||||
model: 'gpt-4o',
|
||||
max_tokens: 100,
|
||||
messages: [{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: 'tc_1', content: 'found it' },
|
||||
],
|
||||
}],
|
||||
}
|
||||
const result = anthropicToOpenaiResponses(req)
|
||||
const fco = result.input.find((i) => i.type === 'function_call_output')
|
||||
expect(fco).toBeDefined()
|
||||
if (fco && fco.type === 'function_call_output') {
|
||||
expect(fco.call_id).toBe('tc_1')
|
||||
expect(fco.output).toBe('found it')
|
||||
}
|
||||
})
|
||||
|
||||
test('thinking → reasoning', () => {
|
||||
const req: AnthropicRequest = {
|
||||
model: 'gpt-4o',
|
||||
max_tokens: 100,
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
thinking: { type: 'enabled', budget_tokens: 10000 },
|
||||
}
|
||||
const result = anthropicToOpenaiResponses(req)
|
||||
expect(result.reasoning).toEqual({ effort: 'high' })
|
||||
})
|
||||
|
||||
test('stop_sequences dropped', () => {
|
||||
const req: AnthropicRequest = {
|
||||
model: 'gpt-4o',
|
||||
max_tokens: 100,
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
stop_sequences: ['END'],
|
||||
}
|
||||
const result = anthropicToOpenaiResponses(req)
|
||||
expect((result as Record<string, unknown>).stop).toBeUndefined()
|
||||
expect((result as Record<string, unknown>).stop_sequences).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── openaiResponsesToAnthropic ─────────────────────────────────
|
||||
|
||||
describe('openaiResponsesToAnthropic', () => {
|
||||
test('basic text response', () => {
|
||||
const res: OpenAIResponsesResponse = {
|
||||
id: 'resp_1',
|
||||
object: 'response',
|
||||
created_at: 1234567890,
|
||||
model: 'gpt-4o',
|
||||
status: 'completed',
|
||||
output: [{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'output_text', text: 'Hello!' }],
|
||||
}],
|
||||
usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 },
|
||||
}
|
||||
const result = openaiResponsesToAnthropic(res, 'gpt-4o')
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'Hello!' }])
|
||||
expect(result.stop_reason).toBe('end_turn')
|
||||
expect(result.usage.input_tokens).toBe(10)
|
||||
expect(result.usage.output_tokens).toBe(5)
|
||||
})
|
||||
|
||||
test('function_call → tool_use', () => {
|
||||
const res: OpenAIResponsesResponse = {
|
||||
id: 'resp_2',
|
||||
object: 'response',
|
||||
created_at: 0,
|
||||
model: 'gpt-4o',
|
||||
status: 'completed',
|
||||
output: [{
|
||||
type: 'function_call',
|
||||
id: 'fc_1',
|
||||
call_id: 'call_1',
|
||||
name: 'search',
|
||||
arguments: '{"q":"test"}',
|
||||
}],
|
||||
}
|
||||
const result = openaiResponsesToAnthropic(res, 'gpt-4o')
|
||||
expect(result.stop_reason).toBe('tool_use')
|
||||
expect(result.content[0].type).toBe('tool_use')
|
||||
if (result.content[0].type === 'tool_use') {
|
||||
expect(result.content[0].id).toBe('call_1')
|
||||
expect(result.content[0].input).toEqual({ q: 'test' })
|
||||
}
|
||||
})
|
||||
|
||||
test('reasoning → thinking', () => {
|
||||
const res: OpenAIResponsesResponse = {
|
||||
id: 'resp_3',
|
||||
object: 'response',
|
||||
created_at: 0,
|
||||
model: 'gpt-4o',
|
||||
status: 'completed',
|
||||
output: [
|
||||
{ type: 'reasoning', id: 'r_1', summary: [{ type: 'text', text: 'Thinking...' }] },
|
||||
{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'Result' }] },
|
||||
],
|
||||
}
|
||||
const result = openaiResponsesToAnthropic(res, 'gpt-4o')
|
||||
expect(result.content).toHaveLength(2)
|
||||
expect(result.content[0].type).toBe('thinking')
|
||||
if (result.content[0].type === 'thinking') {
|
||||
expect(result.content[0].thinking).toBe('Thinking...')
|
||||
}
|
||||
expect(result.content[1].type).toBe('text')
|
||||
})
|
||||
|
||||
test('status incomplete → max_tokens', () => {
|
||||
const res: OpenAIResponsesResponse = {
|
||||
id: 'resp_4',
|
||||
object: 'response',
|
||||
created_at: 0,
|
||||
model: 'gpt-4o',
|
||||
status: 'incomplete',
|
||||
output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'partial' }] }],
|
||||
}
|
||||
const result = openaiResponsesToAnthropic(res, 'gpt-4o')
|
||||
expect(result.stop_reason).toBe('max_tokens')
|
||||
})
|
||||
|
||||
test('empty output', () => {
|
||||
const res: OpenAIResponsesResponse = {
|
||||
id: 'resp_5',
|
||||
object: 'response',
|
||||
created_at: 0,
|
||||
model: 'gpt-4o',
|
||||
status: 'completed',
|
||||
output: [],
|
||||
}
|
||||
const result = openaiResponsesToAnthropic(res, 'gpt-4o')
|
||||
expect(result.content).toEqual([{ type: 'text', text: '' }])
|
||||
})
|
||||
})
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@ -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: '',
|
||||
|
||||
@ -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(
|
||||
|
||||
28
src/server/proxy/LICENSE-cc-switch.md
Normal file
28
src/server/proxy/LICENSE-cc-switch.md
Normal file
@ -0,0 +1,28 @@
|
||||
# CC Switch License Attribution
|
||||
|
||||
The protocol transformation logic in this directory is derived from
|
||||
[cc-switch](https://github.com/farion1231/cc-switch) by Jason Young.
|
||||
|
||||
Original work licensed under the MIT License:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Jason Young
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
195
src/server/proxy/handler.ts
Normal file
195
src/server/proxy/handler.ts
Normal file
@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Proxy Handler — protocol-translating reverse proxy for OpenAI-compatible APIs.
|
||||
*
|
||||
* Receives Anthropic Messages API requests from the CLI, transforms them to
|
||||
* OpenAI Chat Completions or Responses API format, forwards to the upstream
|
||||
* provider, and transforms the response back to Anthropic format.
|
||||
*
|
||||
* Derived from cc-switch (https://github.com/farion1231/cc-switch)
|
||||
* Original work by Jason Young, MIT License
|
||||
*/
|
||||
|
||||
import { ProviderService } from '../services/providerService.js'
|
||||
import { anthropicToOpenaiChat } from './transform/anthropicToOpenaiChat.js'
|
||||
import { anthropicToOpenaiResponses } from './transform/anthropicToOpenaiResponses.js'
|
||||
import { openaiChatToAnthropic } from './transform/openaiChatToAnthropic.js'
|
||||
import { openaiResponsesToAnthropic } from './transform/openaiResponsesToAnthropic.js'
|
||||
import { openaiChatStreamToAnthropic } from './streaming/openaiChatStreamToAnthropic.js'
|
||||
import { openaiResponsesStreamToAnthropic } from './streaming/openaiResponsesStreamToAnthropic.js'
|
||||
import type { AnthropicRequest } from './transform/types.js'
|
||||
|
||||
const providerService = new ProviderService()
|
||||
|
||||
export async function handleProxyRequest(req: Request, url: URL): Promise<Response> {
|
||||
// Only handle POST /proxy/v1/messages
|
||||
if (req.method !== 'POST' || url.pathname !== '/proxy/v1/messages') {
|
||||
return Response.json(
|
||||
{ error: 'Not Found', message: 'Proxy only handles POST /proxy/v1/messages' },
|
||||
{ status: 404 },
|
||||
)
|
||||
}
|
||||
|
||||
// Read active provider config
|
||||
const config = await providerService.getActiveProviderForProxy()
|
||||
if (!config) {
|
||||
return Response.json(
|
||||
{ type: 'error', error: { type: 'invalid_request_error', message: 'No active provider configured for proxy' } },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
if (config.apiFormat === 'anthropic') {
|
||||
return Response.json(
|
||||
{ type: 'error', error: { type: 'invalid_request_error', message: 'Active provider uses anthropic format — proxy not needed' } },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
let body: AnthropicRequest
|
||||
try {
|
||||
body = (await req.json()) as AnthropicRequest
|
||||
} catch {
|
||||
return Response.json(
|
||||
{ type: 'error', error: { type: 'invalid_request_error', message: 'Invalid JSON in request body' } },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const isStream = body.stream === true
|
||||
const baseUrl = config.baseUrl.replace(/\/+$/, '')
|
||||
|
||||
try {
|
||||
if (config.apiFormat === 'openai_chat') {
|
||||
return await handleOpenaiChat(body, baseUrl, config.apiKey, isStream)
|
||||
} else {
|
||||
return await handleOpenaiResponses(body, baseUrl, config.apiKey, isStream)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Proxy] Upstream request failed:', err)
|
||||
return Response.json(
|
||||
{
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'api_error',
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
},
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOpenaiChat(
|
||||
body: AnthropicRequest,
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
isStream: boolean,
|
||||
): Promise<Response> {
|
||||
const transformed = anthropicToOpenaiChat(body)
|
||||
const url = `${baseUrl}/v1/chat/completions`
|
||||
|
||||
const upstream = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(transformed),
|
||||
signal: isStream ? AbortSignal.timeout(30_000) : AbortSignal.timeout(300_000),
|
||||
})
|
||||
|
||||
if (!upstream.ok) {
|
||||
const errText = await upstream.text().catch(() => '')
|
||||
return Response.json(
|
||||
{
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'api_error',
|
||||
message: `Upstream returned HTTP ${upstream.status}: ${errText.slice(0, 500)}`,
|
||||
},
|
||||
},
|
||||
{ status: upstream.status },
|
||||
)
|
||||
}
|
||||
|
||||
if (isStream) {
|
||||
if (!upstream.body) {
|
||||
return Response.json(
|
||||
{ type: 'error', error: { type: 'api_error', message: 'Upstream returned no body for stream' } },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
const anthropicStream = openaiChatStreamToAnthropic(upstream.body, body.model)
|
||||
return new Response(anthropicStream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Non-streaming
|
||||
const responseBody = await upstream.json()
|
||||
const anthropicResponse = openaiChatToAnthropic(responseBody, body.model)
|
||||
return Response.json(anthropicResponse)
|
||||
}
|
||||
|
||||
async function handleOpenaiResponses(
|
||||
body: AnthropicRequest,
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
isStream: boolean,
|
||||
): Promise<Response> {
|
||||
const transformed = anthropicToOpenaiResponses(body)
|
||||
const url = `${baseUrl}/v1/responses`
|
||||
|
||||
const upstream = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(transformed),
|
||||
signal: isStream ? AbortSignal.timeout(30_000) : AbortSignal.timeout(300_000),
|
||||
})
|
||||
|
||||
if (!upstream.ok) {
|
||||
const errText = await upstream.text().catch(() => '')
|
||||
return Response.json(
|
||||
{
|
||||
type: 'error',
|
||||
error: {
|
||||
type: 'api_error',
|
||||
message: `Upstream returned HTTP ${upstream.status}: ${errText.slice(0, 500)}`,
|
||||
},
|
||||
},
|
||||
{ status: upstream.status },
|
||||
)
|
||||
}
|
||||
|
||||
if (isStream) {
|
||||
if (!upstream.body) {
|
||||
return Response.json(
|
||||
{ type: 'error', error: { type: 'api_error', message: 'Upstream returned no body for stream' } },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
const anthropicStream = openaiResponsesStreamToAnthropic(upstream.body, body.model)
|
||||
return new Response(anthropicStream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Non-streaming
|
||||
const responseBody = await upstream.json()
|
||||
const anthropicResponse = openaiResponsesToAnthropic(responseBody, body.model)
|
||||
return Response.json(anthropicResponse)
|
||||
}
|
||||
542
src/server/proxy/streaming/openaiChatStreamToAnthropic.ts
Normal file
542
src/server/proxy/streaming/openaiChatStreamToAnthropic.ts
Normal file
@ -0,0 +1,542 @@
|
||||
/**
|
||||
* Streaming SSE transformation: OpenAI Chat Completions → Anthropic Messages
|
||||
*
|
||||
* Converts an OpenAI-compatible streaming response into Anthropic Messages
|
||||
* streaming format. Follows the patterns established by LiteLLM's
|
||||
* AnthropicStreamWrapper for correctness across many providers.
|
||||
*
|
||||
* Anthropic event order:
|
||||
* message_start
|
||||
* → (content_block_start → content_block_delta* → content_block_stop)*
|
||||
* → message_delta
|
||||
* → message_stop
|
||||
*
|
||||
* Derived from cc-switch (https://github.com/farion1231/cc-switch)
|
||||
* Original work by Jason Young, MIT License
|
||||
*
|
||||
* Provider-specific reasoning formats handled:
|
||||
* - delta.reasoning_content (DeepSeek, OpenRouter, XAI, Perplexity, …)
|
||||
* - delta.thinking_blocks (OpenAI o-series)
|
||||
* - delta.reasoning (GLM-5, Cerebras, Groq — mapped to reasoning_content)
|
||||
*/
|
||||
|
||||
import type { OpenAIChatStreamChunk } from '../transform/types.js'
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────
|
||||
|
||||
type ContentBlockType = 'text' | 'thinking' | 'tool_use'
|
||||
|
||||
type ToolBlockState = {
|
||||
id: string
|
||||
name: string
|
||||
argsBuffer: string
|
||||
started: boolean
|
||||
anthropicIndex: number
|
||||
}
|
||||
|
||||
type SseEvent = { event: string; data: unknown }
|
||||
|
||||
type StreamState = {
|
||||
// Event queue — guarantees correct multi-event ordering
|
||||
queue: SseEvent[]
|
||||
|
||||
// Content block tracking (mirrors LiteLLM's state machine)
|
||||
currentBlockType: ContentBlockType
|
||||
currentBlockIndex: number
|
||||
nextContentIndex: number
|
||||
blockStartSent: boolean // content_block_start emitted for current block?
|
||||
blockStopSent: boolean // content_block_stop emitted for current block?
|
||||
|
||||
// Tool call tracking
|
||||
toolBlocks: Map<number, ToolBlockState>
|
||||
|
||||
// Message lifecycle
|
||||
model: string
|
||||
messageStartSent: boolean
|
||||
messageDeltaSent: boolean
|
||||
messageStopSent: boolean
|
||||
|
||||
// Holding pattern: hold message_delta until usage arrives
|
||||
// (some providers send finish_reason and usage in separate chunks)
|
||||
heldMessageDelta: SseEvent | null
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────
|
||||
|
||||
function formatSse(event: string, data: unknown): string {
|
||||
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
|
||||
}
|
||||
|
||||
function createState(model: string): StreamState {
|
||||
return {
|
||||
queue: [],
|
||||
currentBlockType: 'text',
|
||||
currentBlockIndex: -1,
|
||||
nextContentIndex: 0,
|
||||
blockStartSent: false,
|
||||
blockStopSent: false,
|
||||
toolBlocks: new Map(),
|
||||
model,
|
||||
messageStartSent: false,
|
||||
messageDeltaSent: false,
|
||||
messageStopSent: false,
|
||||
heldMessageDelta: null,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Public entry point ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Transform an OpenAI Chat Completions SSE stream into an Anthropic Messages SSE stream.
|
||||
*/
|
||||
export function openaiChatStreamToAnthropic(
|
||||
upstream: ReadableStream<Uint8Array>,
|
||||
model: string,
|
||||
): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
const state = createState(model)
|
||||
|
||||
return new ReadableStream({
|
||||
async start(controller) {
|
||||
const reader = upstream.getReader()
|
||||
let errored = false
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith(':')) continue
|
||||
|
||||
if (trimmed === 'data: [DONE]') {
|
||||
finalizeStream(state)
|
||||
flushQueue(state, controller, encoder)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!trimmed.startsWith('data: ')) continue
|
||||
const jsonStr = trimmed.slice(6)
|
||||
|
||||
let chunk: OpenAIChatStreamChunk
|
||||
try {
|
||||
chunk = JSON.parse(jsonStr)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
processChunk(chunk, state)
|
||||
flushQueue(state, controller, encoder)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
errored = true
|
||||
controller.error(err)
|
||||
} finally {
|
||||
if (!errored) {
|
||||
finalizeStream(state)
|
||||
flushQueue(state, controller, encoder)
|
||||
controller.close()
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Queue management ──────────────────────────────────────
|
||||
|
||||
function enqueue(state: StreamState, event: string, data: unknown): void {
|
||||
state.queue.push({ event, data })
|
||||
}
|
||||
|
||||
function flushQueue(
|
||||
state: StreamState,
|
||||
controller: ReadableStreamDefaultController,
|
||||
encoder: TextEncoder,
|
||||
): void {
|
||||
for (const item of state.queue) {
|
||||
controller.enqueue(encoder.encode(formatSse(item.event, item.data)))
|
||||
}
|
||||
state.queue.length = 0
|
||||
}
|
||||
|
||||
// ─── Message lifecycle events ──────────────────────────────
|
||||
|
||||
function ensureMessageStart(state: StreamState, chunkId?: string): void {
|
||||
if (state.messageStartSent) return
|
||||
state.messageStartSent = true
|
||||
enqueue(state, 'message_start', {
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id: chunkId || `msg_${Date.now()}`,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [],
|
||||
model: state.model,
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 0, output_tokens: 0 },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Content block lifecycle ───────────────────────────────
|
||||
|
||||
function openBlock(state: StreamState, blockType: ContentBlockType, block: Record<string, unknown>): number {
|
||||
const index = state.nextContentIndex++
|
||||
state.currentBlockType = blockType
|
||||
state.currentBlockIndex = index
|
||||
state.blockStartSent = true
|
||||
state.blockStopSent = false
|
||||
enqueue(state, 'content_block_start', {
|
||||
type: 'content_block_start',
|
||||
index,
|
||||
content_block: block,
|
||||
})
|
||||
return index
|
||||
}
|
||||
|
||||
function emitDelta(state: StreamState, index: number, delta: Record<string, unknown>): void {
|
||||
enqueue(state, 'content_block_delta', {
|
||||
type: 'content_block_delta',
|
||||
index,
|
||||
delta,
|
||||
})
|
||||
}
|
||||
|
||||
function closeCurrentBlock(state: StreamState): void {
|
||||
if (!state.blockStartSent || state.blockStopSent) return
|
||||
state.blockStopSent = true
|
||||
enqueue(state, 'content_block_stop', {
|
||||
type: 'content_block_stop',
|
||||
index: state.currentBlockIndex,
|
||||
})
|
||||
}
|
||||
|
||||
function closeAllToolBlocks(state: StreamState): void {
|
||||
for (const [, block] of state.toolBlocks) {
|
||||
if (block.started) {
|
||||
enqueue(state, 'content_block_stop', {
|
||||
type: 'content_block_stop',
|
||||
index: block.anthropicIndex,
|
||||
})
|
||||
}
|
||||
}
|
||||
state.toolBlocks.clear()
|
||||
}
|
||||
|
||||
function closeAllOpenBlocks(state: StreamState): void {
|
||||
// Close current text/thinking block
|
||||
closeCurrentBlock(state)
|
||||
// Close all tool blocks
|
||||
closeAllToolBlocks(state)
|
||||
}
|
||||
|
||||
// ─── Block type detection (follows LiteLLM priority) ───────
|
||||
|
||||
type DeltaEx = Record<string, unknown> & {
|
||||
content?: string | null
|
||||
tool_calls?: Array<{
|
||||
index: number
|
||||
id?: string
|
||||
type?: string
|
||||
function?: { name?: string; arguments?: string }
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract reasoning/thinking content from delta regardless of provider format.
|
||||
*
|
||||
* Handles:
|
||||
* delta.reasoning_content — DeepSeek, OpenRouter, XAI, Perplexity
|
||||
* delta.reasoning — GLM-5, Cerebras, Groq
|
||||
* delta.thinking_blocks — OpenAI o-series
|
||||
*/
|
||||
function extractReasoning(delta: DeltaEx): { thinking: string; signature: string } | null {
|
||||
// Format 1: reasoning_content (most common)
|
||||
if (typeof delta.reasoning_content === 'string' && delta.reasoning_content) {
|
||||
return { thinking: delta.reasoning_content, signature: '' }
|
||||
}
|
||||
|
||||
// Format 2: reasoning (GLM-5, Cerebras, Groq)
|
||||
if (typeof delta.reasoning === 'string' && delta.reasoning) {
|
||||
return { thinking: delta.reasoning, signature: '' }
|
||||
}
|
||||
|
||||
// Format 3: thinking_blocks (OpenAI o-series)
|
||||
const thinkingBlocks = delta.thinking_blocks as Array<Record<string, unknown>> | undefined
|
||||
if (Array.isArray(thinkingBlocks) && thinkingBlocks.length > 0) {
|
||||
const block = thinkingBlocks[0]
|
||||
if (block.type === 'thinking') {
|
||||
const thinking = (block.thinking as string) || ''
|
||||
const signature = (block.signature as string) || ''
|
||||
if (thinking || signature) {
|
||||
return { thinking, signature }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine what block type this chunk carries and whether it's a new block.
|
||||
* Priority (matches LiteLLM): tool_calls > text > reasoning > ignore
|
||||
*/
|
||||
function detectBlockTransition(
|
||||
delta: DeltaEx,
|
||||
state: StreamState,
|
||||
): { type: ContentBlockType; isNew: boolean } | null {
|
||||
// Priority 1: Tool calls
|
||||
if (delta.tool_calls && delta.tool_calls.length > 0) {
|
||||
const tc = delta.tool_calls[0]
|
||||
// A tool call with function.name signals a NEW tool block
|
||||
const isNew = state.currentBlockType !== 'tool_use' || !!(tc.function?.name)
|
||||
return { type: 'tool_use', isNew }
|
||||
}
|
||||
|
||||
// Priority 2: Text content
|
||||
if (delta.content != null && delta.content !== '') {
|
||||
const isNew = state.currentBlockType !== 'text' || !state.blockStartSent
|
||||
return { type: 'text', isNew }
|
||||
}
|
||||
|
||||
// Priority 3: Reasoning/thinking
|
||||
const reasoning = extractReasoning(delta)
|
||||
if (reasoning) {
|
||||
const isNew = state.currentBlockType !== 'thinking' || !state.blockStartSent
|
||||
return { type: 'thinking', isNew }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ─── Main chunk processing ─────────────────────────────────
|
||||
|
||||
function processChunk(chunk: OpenAIChatStreamChunk, state: StreamState): void {
|
||||
const choice = chunk.choices?.[0]
|
||||
|
||||
// Handle chunks with empty/missing choices (some providers send these)
|
||||
if (!choice) {
|
||||
// Check if this is a usage-only chunk (no choices but has usage)
|
||||
if (chunk.usage && state.heldMessageDelta) {
|
||||
mergeUsageIntoHeldDelta(state, chunk.usage)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Update model from first chunk
|
||||
state.model = chunk.model || state.model
|
||||
ensureMessageStart(state, chunk.id)
|
||||
|
||||
const delta = choice.delta as DeltaEx
|
||||
|
||||
// Detect what this chunk carries
|
||||
const transition = detectBlockTransition(delta, state)
|
||||
|
||||
if (transition) {
|
||||
// Handle block transition: close previous block if type changed
|
||||
if (transition.isNew && state.blockStartSent && !state.blockStopSent) {
|
||||
if (transition.type !== 'tool_use') {
|
||||
// For text/thinking, close the current block
|
||||
closeCurrentBlock(state)
|
||||
} else if (state.currentBlockType !== 'tool_use') {
|
||||
// Switching TO tool_use from text/thinking: close current
|
||||
closeCurrentBlock(state)
|
||||
}
|
||||
}
|
||||
|
||||
switch (transition.type) {
|
||||
case 'thinking':
|
||||
handleThinking(delta, state)
|
||||
break
|
||||
case 'text':
|
||||
handleText(delta, state)
|
||||
break
|
||||
case 'tool_use':
|
||||
handleToolCalls(delta, state)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Handle finish_reason
|
||||
if (choice.finish_reason) {
|
||||
handleFinishReason(choice.finish_reason, chunk, state)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Content handlers ──────────────────────────────────────
|
||||
|
||||
function handleThinking(delta: DeltaEx, state: StreamState): void {
|
||||
const reasoning = extractReasoning(delta)
|
||||
if (!reasoning) return
|
||||
|
||||
if (state.currentBlockType !== 'thinking' || !state.blockStartSent) {
|
||||
openBlock(state, 'thinking', { type: 'thinking', thinking: '' })
|
||||
}
|
||||
|
||||
if (reasoning.thinking) {
|
||||
emitDelta(state, state.currentBlockIndex, {
|
||||
type: 'thinking_delta', thinking: reasoning.thinking,
|
||||
})
|
||||
}
|
||||
if (reasoning.signature) {
|
||||
emitDelta(state, state.currentBlockIndex, {
|
||||
type: 'signature_delta', signature: reasoning.signature,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleText(delta: DeltaEx, state: StreamState): void {
|
||||
if (delta.content == null || delta.content === '') return
|
||||
|
||||
if (state.currentBlockType !== 'text' || !state.blockStartSent) {
|
||||
openBlock(state, 'text', { type: 'text', text: '' })
|
||||
}
|
||||
|
||||
emitDelta(state, state.currentBlockIndex, {
|
||||
type: 'text_delta', text: delta.content,
|
||||
})
|
||||
}
|
||||
|
||||
function handleToolCalls(delta: DeltaEx, state: StreamState): void {
|
||||
if (!delta.tool_calls) return
|
||||
|
||||
for (const tc of delta.tool_calls) {
|
||||
const tcIndex = tc.index
|
||||
|
||||
if (!state.toolBlocks.has(tcIndex)) {
|
||||
state.toolBlocks.set(tcIndex, {
|
||||
id: '', name: '', argsBuffer: '', started: false, anthropicIndex: -1,
|
||||
})
|
||||
}
|
||||
|
||||
const block = state.toolBlocks.get(tcIndex)!
|
||||
if (tc.id) block.id = tc.id
|
||||
if (tc.function?.name) block.name += tc.function.name
|
||||
if (tc.function?.arguments) block.argsBuffer += tc.function.arguments
|
||||
|
||||
// Start tool block once we have id + name
|
||||
if (!block.started && block.id && block.name) {
|
||||
block.started = true
|
||||
block.anthropicIndex = state.nextContentIndex++
|
||||
state.currentBlockType = 'tool_use'
|
||||
state.blockStartSent = true
|
||||
state.blockStopSent = false
|
||||
|
||||
enqueue(state, 'content_block_start', {
|
||||
type: 'content_block_start',
|
||||
index: block.anthropicIndex,
|
||||
content_block: { type: 'tool_use', id: block.id, name: block.name, input: {} },
|
||||
})
|
||||
|
||||
// Flush buffered arguments
|
||||
if (block.argsBuffer) {
|
||||
emitDelta(state, block.anthropicIndex, {
|
||||
type: 'input_json_delta', partial_json: block.argsBuffer,
|
||||
})
|
||||
}
|
||||
} else if (block.started && tc.function?.arguments) {
|
||||
emitDelta(state, block.anthropicIndex, {
|
||||
type: 'input_json_delta', partial_json: tc.function.arguments,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Finish & usage handling ───────────────────────────────
|
||||
|
||||
function handleFinishReason(
|
||||
finishReason: string,
|
||||
chunk: OpenAIChatStreamChunk,
|
||||
state: StreamState,
|
||||
): void {
|
||||
if (state.messageDeltaSent) return
|
||||
|
||||
// CRITICAL: close ALL content blocks BEFORE message_delta
|
||||
closeAllOpenBlocks(state)
|
||||
|
||||
const stopReason = mapFinishReason(finishReason)
|
||||
const usage = chunk.usage
|
||||
? { output_tokens: chunk.usage.completion_tokens || 0 }
|
||||
: { output_tokens: 0 }
|
||||
|
||||
const messageDelta: SseEvent = {
|
||||
event: 'message_delta',
|
||||
data: {
|
||||
type: 'message_delta',
|
||||
delta: { stop_reason: stopReason, stop_sequence: null },
|
||||
usage,
|
||||
},
|
||||
}
|
||||
|
||||
// If usage is available in the same chunk, emit immediately
|
||||
if (chunk.usage) {
|
||||
state.messageDeltaSent = true
|
||||
state.queue.push(messageDelta)
|
||||
} else {
|
||||
// Hold message_delta, wait for usage chunk
|
||||
state.heldMessageDelta = messageDelta
|
||||
}
|
||||
}
|
||||
|
||||
function mergeUsageIntoHeldDelta(
|
||||
state: StreamState,
|
||||
usage: NonNullable<OpenAIChatStreamChunk['usage']>,
|
||||
): void {
|
||||
if (!state.heldMessageDelta) return
|
||||
|
||||
const data = state.heldMessageDelta.data as Record<string, unknown>
|
||||
data.usage = { output_tokens: usage.completion_tokens || 0 }
|
||||
state.messageDeltaSent = true
|
||||
state.queue.push(state.heldMessageDelta)
|
||||
state.heldMessageDelta = null
|
||||
}
|
||||
|
||||
function finalizeStream(state: StreamState): void {
|
||||
if (state.messageStopSent) return
|
||||
state.messageStopSent = true
|
||||
|
||||
ensureMessageStart(state)
|
||||
|
||||
// Close any remaining open blocks
|
||||
closeAllOpenBlocks(state)
|
||||
|
||||
// Flush held message_delta if still waiting for usage
|
||||
if (state.heldMessageDelta && !state.messageDeltaSent) {
|
||||
state.messageDeltaSent = true
|
||||
state.queue.push(state.heldMessageDelta)
|
||||
state.heldMessageDelta = null
|
||||
}
|
||||
|
||||
// Emit message_delta if never sent (e.g., stream ended without finish_reason)
|
||||
if (!state.messageDeltaSent) {
|
||||
state.messageDeltaSent = true
|
||||
enqueue(state, 'message_delta', {
|
||||
type: 'message_delta',
|
||||
delta: { stop_reason: 'end_turn', stop_sequence: null },
|
||||
usage: { output_tokens: 0 },
|
||||
})
|
||||
}
|
||||
|
||||
enqueue(state, 'message_stop', { type: 'message_stop' })
|
||||
}
|
||||
|
||||
// ─── Utilities ─────────────────────────────────────────────
|
||||
|
||||
function mapFinishReason(reason: string): string {
|
||||
switch (reason) {
|
||||
case 'stop': return 'end_turn'
|
||||
case 'tool_calls': return 'tool_use'
|
||||
case 'length': return 'max_tokens'
|
||||
case 'content_filter': return 'end_turn'
|
||||
default: return 'end_turn'
|
||||
}
|
||||
}
|
||||
277
src/server/proxy/streaming/openaiResponsesStreamToAnthropic.ts
Normal file
277
src/server/proxy/streaming/openaiResponsesStreamToAnthropic.ts
Normal file
@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Streaming SSE transformation: OpenAI Responses API → Anthropic Messages
|
||||
* Derived from cc-switch (https://github.com/farion1231/cc-switch)
|
||||
* Original work by Jason Young, MIT License
|
||||
*/
|
||||
|
||||
type StreamState = {
|
||||
nextContentIndex: number
|
||||
indexByKey: Map<string, number> // content part key → Anthropic index
|
||||
toolIndexByItemId: Map<string, number> // tool item ID → Anthropic index
|
||||
model: string
|
||||
messageStarted: boolean
|
||||
messageStopped: boolean
|
||||
}
|
||||
|
||||
function formatSse(event: string, data: unknown): string {
|
||||
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform an OpenAI Responses API SSE stream into an Anthropic Messages SSE stream.
|
||||
*/
|
||||
export function openaiResponsesStreamToAnthropic(
|
||||
upstream: ReadableStream<Uint8Array>,
|
||||
model: string,
|
||||
): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
const state: StreamState = {
|
||||
nextContentIndex: 0,
|
||||
indexByKey: new Map(),
|
||||
toolIndexByItemId: new Map(),
|
||||
model,
|
||||
messageStarted: false,
|
||||
messageStopped: false,
|
||||
}
|
||||
|
||||
return new ReadableStream({
|
||||
async start(controller) {
|
||||
const reader = upstream.getReader()
|
||||
let currentEvent = ''
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
|
||||
if (trimmed.startsWith('event: ')) {
|
||||
currentEvent = trimmed.slice(7).trim()
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('data: ')) {
|
||||
const jsonStr = trimmed.slice(6)
|
||||
if (jsonStr === '[DONE]') {
|
||||
if (!state.messageStopped) {
|
||||
state.messageStopped = true
|
||||
if (!state.messageStarted) {
|
||||
emitMessageStart(state, controller, encoder, model)
|
||||
}
|
||||
controller.enqueue(encoder.encode(formatSse('message_stop', { type: 'message_stop' })))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
let data: Record<string, unknown>
|
||||
try {
|
||||
data = JSON.parse(jsonStr)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
processEvent(currentEvent, data, state, controller, encoder)
|
||||
currentEvent = ''
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmed === '') {
|
||||
currentEvent = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
controller.error(err)
|
||||
return // don't call close() after error()
|
||||
}
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function emitMessageStart(
|
||||
state: StreamState,
|
||||
controller: ReadableStreamDefaultController,
|
||||
encoder: TextEncoder,
|
||||
model: string,
|
||||
): void {
|
||||
state.messageStarted = true
|
||||
controller.enqueue(encoder.encode(formatSse('message_start', {
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id: `msg_${Date.now()}`,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [],
|
||||
model,
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 0, output_tokens: 0 },
|
||||
},
|
||||
})))
|
||||
}
|
||||
|
||||
function processEvent(
|
||||
event: string,
|
||||
data: Record<string, unknown>,
|
||||
state: StreamState,
|
||||
controller: ReadableStreamDefaultController,
|
||||
encoder: TextEncoder,
|
||||
): void {
|
||||
switch (event) {
|
||||
case 'response.created': {
|
||||
const response = data as Record<string, unknown>
|
||||
state.model = (response.model as string) || state.model
|
||||
emitMessageStart(state, controller, encoder, state.model)
|
||||
break
|
||||
}
|
||||
|
||||
case 'response.output_item.added': {
|
||||
if (!state.messageStarted) emitMessageStart(state, controller, encoder, state.model)
|
||||
const item = data.item as Record<string, unknown> | undefined
|
||||
if (!item) break
|
||||
|
||||
if (item.type === 'function_call') {
|
||||
const index = state.nextContentIndex++
|
||||
const callId = (item.call_id as string) || (item.id as string) || ''
|
||||
const name = (item.name as string) || ''
|
||||
state.toolIndexByItemId.set(item.id as string || callId, index)
|
||||
|
||||
controller.enqueue(encoder.encode(formatSse('content_block_start', {
|
||||
type: 'content_block_start',
|
||||
index,
|
||||
content_block: {
|
||||
type: 'tool_use',
|
||||
id: callId,
|
||||
name,
|
||||
input: {},
|
||||
},
|
||||
})))
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'response.content_part.added': {
|
||||
if (!state.messageStarted) emitMessageStart(state, controller, encoder, state.model)
|
||||
const part = data.part as Record<string, unknown> | undefined
|
||||
if (!part) break
|
||||
|
||||
const contentIndex = (data.content_index as number) ?? 0
|
||||
const outputIndex = (data.output_index as number) ?? 0
|
||||
const key = `${outputIndex}:${contentIndex}`
|
||||
const index = state.nextContentIndex++
|
||||
state.indexByKey.set(key, index)
|
||||
|
||||
controller.enqueue(encoder.encode(formatSse('content_block_start', {
|
||||
type: 'content_block_start',
|
||||
index,
|
||||
content_block: { type: 'text', text: '' },
|
||||
})))
|
||||
break
|
||||
}
|
||||
|
||||
case 'response.output_text.delta': {
|
||||
const contentIndex = (data.content_index as number) ?? 0
|
||||
const outputIndex = (data.output_index as number) ?? 0
|
||||
const key = `${outputIndex}:${contentIndex}`
|
||||
const index = state.indexByKey.get(key)
|
||||
if (index === undefined) break
|
||||
|
||||
const delta = (data.delta as string) || ''
|
||||
controller.enqueue(encoder.encode(formatSse('content_block_delta', {
|
||||
type: 'content_block_delta',
|
||||
index,
|
||||
delta: { type: 'text_delta', text: delta },
|
||||
})))
|
||||
break
|
||||
}
|
||||
|
||||
case 'response.refusal.delta': {
|
||||
const contentIndex = (data.content_index as number) ?? 0
|
||||
const outputIndex = (data.output_index as number) ?? 0
|
||||
const key = `${outputIndex}:${contentIndex}`
|
||||
const index = state.indexByKey.get(key)
|
||||
if (index === undefined) break
|
||||
|
||||
const delta = (data.delta as string) || ''
|
||||
controller.enqueue(encoder.encode(formatSse('content_block_delta', {
|
||||
type: 'content_block_delta',
|
||||
index,
|
||||
delta: { type: 'text_delta', text: delta },
|
||||
})))
|
||||
break
|
||||
}
|
||||
|
||||
case 'response.function_call_arguments.delta': {
|
||||
const itemId = (data.item_id as string) || ''
|
||||
const index = state.toolIndexByItemId.get(itemId)
|
||||
if (index === undefined) break
|
||||
|
||||
const delta = (data.delta as string) || ''
|
||||
controller.enqueue(encoder.encode(formatSse('content_block_delta', {
|
||||
type: 'content_block_delta',
|
||||
index,
|
||||
delta: { type: 'input_json_delta', partial_json: delta },
|
||||
})))
|
||||
break
|
||||
}
|
||||
|
||||
case 'response.output_text.done':
|
||||
case 'response.refusal.done': {
|
||||
const contentIndex = (data.content_index as number) ?? 0
|
||||
const outputIndex = (data.output_index as number) ?? 0
|
||||
const key = `${outputIndex}:${contentIndex}`
|
||||
const index = state.indexByKey.get(key)
|
||||
if (index === undefined) break
|
||||
|
||||
controller.enqueue(encoder.encode(formatSse('content_block_stop', {
|
||||
type: 'content_block_stop',
|
||||
index,
|
||||
})))
|
||||
break
|
||||
}
|
||||
|
||||
case 'response.function_call_arguments.done': {
|
||||
const itemId = (data.item_id as string) || ''
|
||||
const index = state.toolIndexByItemId.get(itemId)
|
||||
if (index === undefined) break
|
||||
|
||||
controller.enqueue(encoder.encode(formatSse('content_block_stop', {
|
||||
type: 'content_block_stop',
|
||||
index,
|
||||
})))
|
||||
break
|
||||
}
|
||||
|
||||
case 'response.completed': {
|
||||
const response = data.response as Record<string, unknown> | undefined
|
||||
const status = (response?.status as string) || 'completed'
|
||||
const usage = response?.usage as Record<string, number> | undefined
|
||||
const hasToolUse = state.toolIndexByItemId.size > 0
|
||||
|
||||
const stopReason = status === 'completed'
|
||||
? (hasToolUse ? 'tool_use' : 'end_turn')
|
||||
: status === 'incomplete' ? 'max_tokens' : 'end_turn'
|
||||
|
||||
controller.enqueue(encoder.encode(formatSse('message_delta', {
|
||||
type: 'message_delta',
|
||||
delta: { stop_reason: stopReason, stop_sequence: null },
|
||||
usage: { output_tokens: usage?.output_tokens ?? 0 },
|
||||
})))
|
||||
if (!state.messageStopped) {
|
||||
state.messageStopped = true
|
||||
controller.enqueue(encoder.encode(formatSse('message_stop', { type: 'message_stop' })))
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
194
src/server/proxy/transform/anthropicToOpenaiChat.ts
Normal file
194
src/server/proxy/transform/anthropicToOpenaiChat.ts
Normal file
@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Request transformation: Anthropic Messages → OpenAI Chat Completions
|
||||
* Derived from cc-switch (https://github.com/farion1231/cc-switch)
|
||||
* Original work by Jason Young, MIT License
|
||||
*/
|
||||
|
||||
import type {
|
||||
AnthropicRequest,
|
||||
AnthropicContentBlock,
|
||||
AnthropicMessage,
|
||||
OpenAIChatRequest,
|
||||
OpenAIChatMessage,
|
||||
OpenAIChatContentPart,
|
||||
OpenAIToolCall,
|
||||
OpenAITool,
|
||||
} from './types.js'
|
||||
|
||||
/**
|
||||
* Convert Anthropic Messages request to OpenAI Chat Completions request.
|
||||
*/
|
||||
export function anthropicToOpenaiChat(body: AnthropicRequest): OpenAIChatRequest {
|
||||
const messages: OpenAIChatMessage[] = []
|
||||
|
||||
// Convert system prompt
|
||||
if (body.system) {
|
||||
if (typeof body.system === 'string') {
|
||||
messages.push({ role: 'system', content: body.system })
|
||||
} else if (Array.isArray(body.system)) {
|
||||
const text = body.system.map((b) => b.text).join('\n')
|
||||
messages.push({ role: 'system', content: text })
|
||||
}
|
||||
}
|
||||
|
||||
// Convert messages
|
||||
for (const msg of body.messages) {
|
||||
convertMessage(msg, messages)
|
||||
}
|
||||
|
||||
// Build request
|
||||
const result: OpenAIChatRequest = {
|
||||
model: body.model,
|
||||
messages,
|
||||
stream: body.stream,
|
||||
}
|
||||
|
||||
// max_tokens — omit to let upstream provider use its own default/max.
|
||||
// Claude Code sends very large values (e.g. 128K) that exceed many
|
||||
// providers' limits (DeepSeek: 8192, etc.).
|
||||
|
||||
// temperature & top_p
|
||||
if (body.temperature !== undefined) result.temperature = body.temperature
|
||||
if (body.top_p !== undefined) result.top_p = body.top_p
|
||||
|
||||
// stop_sequences → stop
|
||||
if (body.stop_sequences && body.stop_sequences.length > 0) {
|
||||
result.stop = body.stop_sequences
|
||||
}
|
||||
|
||||
// tools
|
||||
if (body.tools && body.tools.length > 0) {
|
||||
result.tools = body.tools
|
||||
.filter((t) => t.name !== 'BatchTool')
|
||||
.map((t): OpenAITool => ({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: t.input_schema,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
// tool_choice
|
||||
if (body.tool_choice !== undefined) {
|
||||
result.tool_choice = convertToolChoice(body.tool_choice)
|
||||
}
|
||||
|
||||
// thinking → reasoning_effort
|
||||
if (body.thinking) {
|
||||
const budget = body.thinking.budget_tokens
|
||||
if (budget !== undefined) {
|
||||
if (budget <= 1024) result.reasoning_effort = 'low'
|
||||
else if (budget <= 8192) result.reasoning_effort = 'medium'
|
||||
else result.reasoning_effort = 'high'
|
||||
} else if (body.thinking.type === 'enabled') {
|
||||
result.reasoning_effort = 'high'
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function convertMessage(msg: AnthropicMessage, output: OpenAIChatMessage[]): void {
|
||||
const content = msg.content
|
||||
|
||||
// Simple string content
|
||||
if (typeof content === 'string') {
|
||||
output.push({ role: msg.role, content })
|
||||
return
|
||||
}
|
||||
|
||||
// Array content blocks
|
||||
if (!Array.isArray(content) || content.length === 0) {
|
||||
output.push({ role: msg.role, content: '' })
|
||||
return
|
||||
}
|
||||
|
||||
if (msg.role === 'user') {
|
||||
convertUserMessage(content, output)
|
||||
} else {
|
||||
convertAssistantMessage(content, output)
|
||||
}
|
||||
}
|
||||
|
||||
function convertUserMessage(blocks: AnthropicContentBlock[], output: OpenAIChatMessage[]): void {
|
||||
// Separate tool_result blocks from other content
|
||||
const contentParts: OpenAIChatContentPart[] = []
|
||||
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'text') {
|
||||
contentParts.push({ type: 'text', text: block.text })
|
||||
} else if (block.type === 'image') {
|
||||
const url = `data:${block.source.media_type};base64,${block.source.data}`
|
||||
contentParts.push({ type: 'image_url', image_url: { url } })
|
||||
} else if (block.type === 'tool_result') {
|
||||
// tool_result → separate tool message
|
||||
const resultContent = typeof block.content === 'string'
|
||||
? block.content
|
||||
: Array.isArray(block.content)
|
||||
? block.content.filter((b): b is Extract<AnthropicContentBlock, { type: 'text' }> => b.type === 'text').map((b) => b.text).join('\n')
|
||||
: ''
|
||||
output.push({
|
||||
role: 'tool',
|
||||
tool_call_id: block.tool_use_id,
|
||||
content: resultContent,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (contentParts.length > 0) {
|
||||
output.push({
|
||||
role: 'user',
|
||||
content: contentParts.length === 1 && contentParts[0].type === 'text'
|
||||
? contentParts[0].text
|
||||
: contentParts,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function convertAssistantMessage(blocks: AnthropicContentBlock[], output: OpenAIChatMessage[]): void {
|
||||
let textContent = ''
|
||||
const toolCalls: OpenAIToolCall[] = []
|
||||
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'text') {
|
||||
textContent += block.text
|
||||
} else if (block.type === 'tool_use') {
|
||||
toolCalls.push({
|
||||
id: block.id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: block.name,
|
||||
arguments: typeof block.input === 'string' ? block.input : JSON.stringify(block.input),
|
||||
},
|
||||
})
|
||||
}
|
||||
// Skip thinking blocks — no OpenAI equivalent
|
||||
}
|
||||
|
||||
const msg: OpenAIChatMessage = {
|
||||
role: 'assistant',
|
||||
content: textContent || null,
|
||||
}
|
||||
|
||||
if (toolCalls.length > 0) {
|
||||
msg.tool_calls = toolCalls
|
||||
}
|
||||
|
||||
output.push(msg)
|
||||
}
|
||||
|
||||
function convertToolChoice(choice: unknown): unknown {
|
||||
if (typeof choice === 'string') return choice
|
||||
if (typeof choice === 'object' && choice !== null) {
|
||||
const c = choice as Record<string, unknown>
|
||||
if (c.type === 'auto') return 'auto'
|
||||
if (c.type === 'any') return 'required'
|
||||
if (c.type === 'none') return 'none'
|
||||
if (c.type === 'tool' && typeof c.name === 'string') {
|
||||
return { type: 'function', function: { name: c.name } }
|
||||
}
|
||||
}
|
||||
return 'auto'
|
||||
}
|
||||
168
src/server/proxy/transform/anthropicToOpenaiResponses.ts
Normal file
168
src/server/proxy/transform/anthropicToOpenaiResponses.ts
Normal file
@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Request transformation: Anthropic Messages → OpenAI Responses API
|
||||
* Derived from cc-switch (https://github.com/farion1231/cc-switch)
|
||||
* Original work by Jason Young, MIT License
|
||||
*/
|
||||
|
||||
import type {
|
||||
AnthropicRequest,
|
||||
AnthropicContentBlock,
|
||||
AnthropicMessage,
|
||||
OpenAIResponsesRequest,
|
||||
OpenAIResponsesInputItem,
|
||||
OpenAITool,
|
||||
OpenAIChatContentPart,
|
||||
} from './types.js'
|
||||
|
||||
/**
|
||||
* Convert Anthropic Messages request to OpenAI Responses API request.
|
||||
*/
|
||||
export function anthropicToOpenaiResponses(body: AnthropicRequest): OpenAIResponsesRequest {
|
||||
const input: OpenAIResponsesInputItem[] = []
|
||||
|
||||
// Convert messages to input items
|
||||
for (const msg of body.messages) {
|
||||
convertMessageToInputItems(msg, input)
|
||||
}
|
||||
|
||||
const result: OpenAIResponsesRequest = {
|
||||
model: body.model,
|
||||
input,
|
||||
stream: body.stream,
|
||||
}
|
||||
|
||||
// system → instructions
|
||||
if (body.system) {
|
||||
if (typeof body.system === 'string') {
|
||||
result.instructions = body.system
|
||||
} else if (Array.isArray(body.system)) {
|
||||
result.instructions = body.system.map((b) => b.text).join('\n')
|
||||
}
|
||||
}
|
||||
|
||||
// max_tokens — omit to let upstream provider use its own default/max.
|
||||
// Claude Code sends very large values that exceed many providers' limits.
|
||||
|
||||
// temperature & top_p
|
||||
if (body.temperature !== undefined) result.temperature = body.temperature
|
||||
if (body.top_p !== undefined) result.top_p = body.top_p
|
||||
|
||||
// tools
|
||||
if (body.tools && body.tools.length > 0) {
|
||||
result.tools = body.tools
|
||||
.filter((t) => t.name !== 'BatchTool')
|
||||
.map((t): OpenAITool => ({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: t.input_schema,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
// tool_choice
|
||||
if (body.tool_choice !== undefined) {
|
||||
result.tool_choice = convertToolChoice(body.tool_choice)
|
||||
}
|
||||
|
||||
// thinking → reasoning
|
||||
if (body.thinking) {
|
||||
const budget = body.thinking.budget_tokens
|
||||
if (budget !== undefined) {
|
||||
if (budget <= 1024) result.reasoning = { effort: 'low' }
|
||||
else if (budget <= 8192) result.reasoning = { effort: 'medium' }
|
||||
else result.reasoning = { effort: 'high' }
|
||||
} else if (body.thinking.type === 'enabled') {
|
||||
result.reasoning = { effort: 'high' }
|
||||
}
|
||||
}
|
||||
|
||||
// stop_sequences not supported in Responses API, dropped
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function convertMessageToInputItems(msg: AnthropicMessage, output: OpenAIResponsesInputItem[]): void {
|
||||
const content = msg.content
|
||||
|
||||
// Simple string content
|
||||
if (typeof content === 'string') {
|
||||
output.push({ type: 'message', role: msg.role, content })
|
||||
return
|
||||
}
|
||||
|
||||
if (!Array.isArray(content) || content.length === 0) {
|
||||
output.push({ type: 'message', role: msg.role, content: '' })
|
||||
return
|
||||
}
|
||||
|
||||
// Collect text/image parts and handle tool blocks separately
|
||||
const contentParts: (string | OpenAIChatContentPart)[] = []
|
||||
|
||||
for (const block of content) {
|
||||
if (block.type === 'text') {
|
||||
contentParts.push(block.text)
|
||||
} else if (block.type === 'image') {
|
||||
contentParts.push({
|
||||
type: 'image_url',
|
||||
image_url: { url: `data:${block.source.media_type};base64,${block.source.data}` },
|
||||
})
|
||||
} else if (block.type === 'tool_use') {
|
||||
// Flush any accumulated content first
|
||||
if (contentParts.length > 0) {
|
||||
const flatContent = contentParts.length === 1 && typeof contentParts[0] === 'string'
|
||||
? contentParts[0]
|
||||
: contentParts.map((p) => typeof p === 'string' ? p : '').join('')
|
||||
if (flatContent) {
|
||||
output.push({ type: 'message', role: msg.role, content: flatContent })
|
||||
}
|
||||
contentParts.length = 0
|
||||
}
|
||||
// Lift to function_call item
|
||||
output.push({
|
||||
type: 'function_call',
|
||||
call_id: block.id,
|
||||
name: block.name,
|
||||
arguments: typeof block.input === 'string' ? block.input : JSON.stringify(block.input),
|
||||
})
|
||||
} else if (block.type === 'tool_result') {
|
||||
// Lift to function_call_output item
|
||||
const resultContent = typeof block.content === 'string'
|
||||
? block.content
|
||||
: Array.isArray(block.content)
|
||||
? block.content.filter((b): b is Extract<AnthropicContentBlock, { type: 'text' }> => b.type === 'text').map((b) => b.text).join('\n')
|
||||
: ''
|
||||
output.push({
|
||||
type: 'function_call_output',
|
||||
call_id: block.tool_use_id,
|
||||
output: resultContent,
|
||||
})
|
||||
}
|
||||
// Skip thinking blocks
|
||||
}
|
||||
|
||||
// Flush remaining content
|
||||
if (contentParts.length > 0) {
|
||||
const flatContent = contentParts.length === 1 && typeof contentParts[0] === 'string'
|
||||
? contentParts[0]
|
||||
: contentParts.map((p) => typeof p === 'string' ? p : '').join('')
|
||||
if (flatContent) {
|
||||
output.push({ type: 'message', role: msg.role, content: flatContent })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function convertToolChoice(choice: unknown): unknown {
|
||||
if (typeof choice === 'string') return choice
|
||||
if (typeof choice === 'object' && choice !== null) {
|
||||
const c = choice as Record<string, unknown>
|
||||
if (c.type === 'auto') return 'auto'
|
||||
if (c.type === 'any') return 'required'
|
||||
if (c.type === 'none') return 'none'
|
||||
if (c.type === 'tool' && typeof c.name === 'string') {
|
||||
return { type: 'function', function: { name: c.name } }
|
||||
}
|
||||
}
|
||||
return 'auto'
|
||||
}
|
||||
116
src/server/proxy/transform/openaiChatToAnthropic.ts
Normal file
116
src/server/proxy/transform/openaiChatToAnthropic.ts
Normal file
@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Response transformation: OpenAI Chat Completions → Anthropic Messages
|
||||
* Derived from cc-switch (https://github.com/farion1231/cc-switch)
|
||||
* Original work by Jason Young, MIT License
|
||||
*/
|
||||
|
||||
import type {
|
||||
OpenAIChatResponse,
|
||||
AnthropicResponse,
|
||||
AnthropicContentBlock,
|
||||
} from './types.js'
|
||||
|
||||
/**
|
||||
* Convert OpenAI Chat Completions response to Anthropic Messages response.
|
||||
*/
|
||||
export function openaiChatToAnthropic(response: OpenAIChatResponse, model: string): AnthropicResponse {
|
||||
const choice = response.choices?.[0]
|
||||
if (!choice) {
|
||||
return createEmptyResponse(response, model)
|
||||
}
|
||||
|
||||
const content: AnthropicContentBlock[] = []
|
||||
|
||||
// Convert reasoning/thinking content (all provider formats)
|
||||
const msg = choice.message as Record<string, unknown>
|
||||
|
||||
// Format 1: reasoning_content (DeepSeek, OpenRouter, XAI, Perplexity)
|
||||
if (typeof msg.reasoning_content === 'string' && msg.reasoning_content) {
|
||||
content.push({ type: 'thinking', thinking: msg.reasoning_content })
|
||||
}
|
||||
// Format 2: reasoning (GLM-5, Cerebras, Groq)
|
||||
else if (typeof msg.reasoning === 'string' && msg.reasoning) {
|
||||
content.push({ type: 'thinking', thinking: msg.reasoning })
|
||||
}
|
||||
// Format 3: thinking_blocks (OpenAI o-series)
|
||||
else if (Array.isArray(msg.thinking_blocks)) {
|
||||
for (const tb of msg.thinking_blocks as Array<Record<string, unknown>>) {
|
||||
if (tb.type === 'thinking' && typeof tb.thinking === 'string') {
|
||||
content.push({ type: 'thinking', thinking: tb.thinking, signature: tb.signature as string | undefined })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert text content
|
||||
if (choice.message.content) {
|
||||
content.push({ type: 'text', text: choice.message.content })
|
||||
}
|
||||
|
||||
// Convert tool calls
|
||||
if (choice.message.tool_calls) {
|
||||
for (const tc of choice.message.tool_calls) {
|
||||
let input: Record<string, unknown> = {}
|
||||
try {
|
||||
input = JSON.parse(tc.function.arguments)
|
||||
} catch {
|
||||
input = { raw: tc.function.arguments }
|
||||
}
|
||||
content.push({
|
||||
type: 'tool_use',
|
||||
id: tc.id,
|
||||
name: tc.function.name,
|
||||
input,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// If no content at all, add empty text
|
||||
if (content.length === 0) {
|
||||
content.push({ type: 'text', text: '' })
|
||||
}
|
||||
|
||||
return {
|
||||
id: response.id || `msg_${Date.now()}`,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content,
|
||||
model: response.model || model,
|
||||
stop_reason: mapFinishReason(choice.finish_reason),
|
||||
stop_sequence: null,
|
||||
usage: mapUsage(response.usage),
|
||||
}
|
||||
}
|
||||
|
||||
function mapFinishReason(reason: string | null): string {
|
||||
switch (reason) {
|
||||
case 'stop': return 'end_turn'
|
||||
case 'tool_calls': return 'tool_use'
|
||||
case 'length': return 'max_tokens'
|
||||
case 'content_filter': return 'end_turn'
|
||||
default: return 'end_turn'
|
||||
}
|
||||
}
|
||||
|
||||
function mapUsage(usage?: OpenAIChatResponse['usage']): AnthropicResponse['usage'] {
|
||||
if (!usage) {
|
||||
return { input_tokens: 0, output_tokens: 0 }
|
||||
}
|
||||
return {
|
||||
input_tokens: usage.prompt_tokens || 0,
|
||||
output_tokens: usage.completion_tokens || 0,
|
||||
cache_read_input_tokens: usage.prompt_tokens_details?.cached_tokens || 0,
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyResponse(response: OpenAIChatResponse, model: string): AnthropicResponse {
|
||||
return {
|
||||
id: response.id || `msg_${Date.now()}`,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: '' }],
|
||||
model: response.model || model,
|
||||
stop_reason: 'end_turn',
|
||||
stop_sequence: null,
|
||||
usage: mapUsage(response.usage),
|
||||
}
|
||||
}
|
||||
97
src/server/proxy/transform/openaiResponsesToAnthropic.ts
Normal file
97
src/server/proxy/transform/openaiResponsesToAnthropic.ts
Normal file
@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Response transformation: OpenAI Responses API → Anthropic Messages
|
||||
* Derived from cc-switch (https://github.com/farion1231/cc-switch)
|
||||
* Original work by Jason Young, MIT License
|
||||
*/
|
||||
|
||||
import type {
|
||||
OpenAIResponsesResponse,
|
||||
OpenAIResponsesOutputItem,
|
||||
AnthropicResponse,
|
||||
AnthropicContentBlock,
|
||||
} from './types.js'
|
||||
|
||||
/**
|
||||
* Convert OpenAI Responses API response to Anthropic Messages response.
|
||||
*/
|
||||
export function openaiResponsesToAnthropic(response: OpenAIResponsesResponse, model: string): AnthropicResponse {
|
||||
const content: AnthropicContentBlock[] = []
|
||||
let hasToolUse = false
|
||||
|
||||
for (const item of response.output || []) {
|
||||
convertOutputItem(item, content)
|
||||
if (item.type === 'function_call') hasToolUse = true
|
||||
}
|
||||
|
||||
// If no content, add empty text
|
||||
if (content.length === 0) {
|
||||
content.push({ type: 'text', text: '' })
|
||||
}
|
||||
|
||||
return {
|
||||
id: response.id || `msg_${Date.now()}`,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content,
|
||||
model: response.model || model,
|
||||
stop_reason: mapStatus(response.status, hasToolUse),
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: response.usage?.input_tokens || 0,
|
||||
output_tokens: response.usage?.output_tokens || 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function convertOutputItem(item: OpenAIResponsesOutputItem, content: AnthropicContentBlock[]): void {
|
||||
switch (item.type) {
|
||||
case 'message': {
|
||||
for (const part of item.content || []) {
|
||||
if (part.type === 'output_text' || part.type === 'text') {
|
||||
content.push({ type: 'text', text: part.text || '' })
|
||||
} else if (part.type === 'refusal') {
|
||||
content.push({ type: 'text', text: part.refusal || '[Refusal]' })
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'function_call': {
|
||||
let input: Record<string, unknown> = {}
|
||||
try {
|
||||
input = JSON.parse(item.arguments)
|
||||
} catch {
|
||||
input = { raw: item.arguments }
|
||||
}
|
||||
content.push({
|
||||
type: 'tool_use',
|
||||
id: item.call_id,
|
||||
name: item.name,
|
||||
input,
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'reasoning': {
|
||||
if (item.summary) {
|
||||
for (const s of item.summary) {
|
||||
if (s.text) {
|
||||
content.push({
|
||||
type: 'thinking',
|
||||
thinking: s.text,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mapStatus(status: string, hasToolUse: boolean): string {
|
||||
switch (status) {
|
||||
case 'completed': return hasToolUse ? 'tool_use' : 'end_turn'
|
||||
case 'failed': return 'end_turn'
|
||||
case 'cancelled': return 'end_turn'
|
||||
case 'incomplete': return 'max_tokens'
|
||||
default: return 'end_turn'
|
||||
}
|
||||
}
|
||||
191
src/server/proxy/transform/types.ts
Normal file
191
src/server/proxy/transform/types.ts
Normal file
@ -0,0 +1,191 @@
|
||||
/**
|
||||
* OpenAI API type definitions for protocol transformation.
|
||||
* Derived from cc-switch (https://github.com/farion1231/cc-switch)
|
||||
* Original work by Jason Young, MIT License
|
||||
*/
|
||||
|
||||
// ─── OpenAI Chat Completions ────────────────────────────────
|
||||
|
||||
export type OpenAIChatMessage = {
|
||||
role: 'system' | 'user' | 'assistant' | 'tool'
|
||||
content?: string | OpenAIChatContentPart[] | null
|
||||
name?: string
|
||||
tool_calls?: OpenAIToolCall[]
|
||||
tool_call_id?: string
|
||||
}
|
||||
|
||||
export type OpenAIChatContentPart =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: 'image_url'; image_url: { url: string; detail?: string } }
|
||||
|
||||
export type OpenAIToolCall = {
|
||||
id: string
|
||||
type: 'function'
|
||||
function: {
|
||||
name: string
|
||||
arguments: string
|
||||
}
|
||||
}
|
||||
|
||||
export type OpenAITool = {
|
||||
type: 'function'
|
||||
function: {
|
||||
name: string
|
||||
description?: string
|
||||
parameters?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export type OpenAIChatRequest = {
|
||||
model: string
|
||||
messages: OpenAIChatMessage[]
|
||||
max_tokens?: number
|
||||
max_completion_tokens?: number
|
||||
temperature?: number
|
||||
top_p?: number
|
||||
stop?: string | string[]
|
||||
stream?: boolean
|
||||
tools?: OpenAITool[]
|
||||
tool_choice?: unknown
|
||||
reasoning_effort?: 'low' | 'medium' | 'high'
|
||||
}
|
||||
|
||||
export type OpenAIChatResponse = {
|
||||
id: string
|
||||
object: string
|
||||
created: number
|
||||
model: string
|
||||
choices: Array<{
|
||||
index: number
|
||||
message: {
|
||||
role: string
|
||||
content: string | null
|
||||
tool_calls?: OpenAIToolCall[]
|
||||
}
|
||||
finish_reason: string | null
|
||||
}>
|
||||
usage?: {
|
||||
prompt_tokens: number
|
||||
completion_tokens: number
|
||||
total_tokens: number
|
||||
prompt_tokens_details?: {
|
||||
cached_tokens?: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type OpenAIChatStreamChunk = {
|
||||
id: string
|
||||
object: string
|
||||
created: number
|
||||
model: string
|
||||
choices: Array<{
|
||||
index: number
|
||||
delta: {
|
||||
role?: string
|
||||
content?: string | null
|
||||
tool_calls?: Array<{
|
||||
index: number
|
||||
id?: string
|
||||
type?: string
|
||||
function?: {
|
||||
name?: string
|
||||
arguments?: string
|
||||
}
|
||||
}>
|
||||
}
|
||||
finish_reason: string | null
|
||||
}>
|
||||
usage?: OpenAIChatResponse['usage']
|
||||
}
|
||||
|
||||
// ─── OpenAI Responses API ───────────────────────────────────
|
||||
|
||||
export type OpenAIResponsesInputItem =
|
||||
| { type: 'message'; role: 'user' | 'assistant' | 'system'; content: string | OpenAIChatContentPart[] }
|
||||
| { type: 'function_call'; call_id: string; name: string; arguments: string }
|
||||
| { type: 'function_call_output'; call_id: string; output: string }
|
||||
|
||||
export type OpenAIResponsesRequest = {
|
||||
model: string
|
||||
input: OpenAIResponsesInputItem[]
|
||||
instructions?: string
|
||||
max_output_tokens?: number
|
||||
temperature?: number
|
||||
top_p?: number
|
||||
stream?: boolean
|
||||
tools?: OpenAITool[]
|
||||
tool_choice?: unknown
|
||||
reasoning?: { effort?: 'low' | 'medium' | 'high' }
|
||||
}
|
||||
|
||||
export type OpenAIResponsesOutputItem =
|
||||
| { type: 'message'; role: string; content: Array<{ type: string; text?: string; refusal?: string }> }
|
||||
| { type: 'function_call'; id: string; call_id: string; name: string; arguments: string }
|
||||
| { type: 'reasoning'; id: string; summary?: Array<{ type: string; text: string }> }
|
||||
|
||||
export type OpenAIResponsesResponse = {
|
||||
id: string
|
||||
object: string
|
||||
created_at: number
|
||||
model: string
|
||||
status: string
|
||||
output: OpenAIResponsesOutputItem[]
|
||||
usage?: {
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
total_tokens: number
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Anthropic Types (subset used by transforms) ───────────
|
||||
|
||||
export type AnthropicContentBlock =
|
||||
| { type: 'text'; text: string; cache_control?: unknown }
|
||||
| { type: 'image'; source: { type: 'base64'; media_type: string; data: string }; cache_control?: unknown }
|
||||
| { type: 'tool_use'; id: string; name: string; input: Record<string, unknown>; cache_control?: unknown }
|
||||
| { type: 'tool_result'; tool_use_id: string; content: string | AnthropicContentBlock[]; is_error?: boolean; cache_control?: unknown }
|
||||
| { type: 'thinking'; thinking: string; signature?: string }
|
||||
|
||||
export type AnthropicMessage = {
|
||||
role: 'user' | 'assistant'
|
||||
content: string | AnthropicContentBlock[]
|
||||
}
|
||||
|
||||
export type AnthropicRequest = {
|
||||
model: string
|
||||
system?: string | Array<{ type: 'text'; text: string; cache_control?: unknown }>
|
||||
messages: AnthropicMessage[]
|
||||
max_tokens: number
|
||||
temperature?: number
|
||||
top_p?: number
|
||||
stop_sequences?: string[]
|
||||
stream?: boolean
|
||||
tools?: Array<{
|
||||
name: string
|
||||
description?: string
|
||||
input_schema: Record<string, unknown>
|
||||
cache_control?: unknown
|
||||
}>
|
||||
tool_choice?: unknown
|
||||
thinking?: {
|
||||
type: string
|
||||
budget_tokens?: number
|
||||
}
|
||||
}
|
||||
|
||||
export type AnthropicResponse = {
|
||||
id: string
|
||||
type: 'message'
|
||||
role: 'assistant'
|
||||
content: AnthropicContentBlock[]
|
||||
model: string
|
||||
stop_reason: string | null
|
||||
stop_sequence: string | null
|
||||
usage: {
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
cache_read_input_tokens?: number
|
||||
cache_creation_input_tokens?: number
|
||||
}
|
||||
}
|
||||
@ -9,6 +9,11 @@ import * as fs from 'fs/promises'
|
||||
import * as path from 'path'
|
||||
import * as os from 'os'
|
||||
import { ApiError } from '../middleware/errorHandler.js'
|
||||
import { anthropicToOpenaiChat } from '../proxy/transform/anthropicToOpenaiChat.js'
|
||||
import { anthropicToOpenaiResponses } from '../proxy/transform/anthropicToOpenaiResponses.js'
|
||||
import { openaiChatToAnthropic } from '../proxy/transform/openaiChatToAnthropic.js'
|
||||
import { openaiResponsesToAnthropic } from '../proxy/transform/openaiResponsesToAnthropic.js'
|
||||
import type { AnthropicRequest, AnthropicResponse } from '../proxy/transform/types.js'
|
||||
import type {
|
||||
SavedProvider,
|
||||
ProvidersIndex,
|
||||
@ -16,6 +21,8 @@ import type {
|
||||
UpdateProviderInput,
|
||||
TestProviderInput,
|
||||
ProviderTestResult,
|
||||
ProviderTestStepResult,
|
||||
ApiFormat,
|
||||
} from '../types/provider.js'
|
||||
|
||||
const MANAGED_ENV_KEYS = [
|
||||
@ -30,6 +37,15 @@ const MANAGED_ENV_KEYS = [
|
||||
const DEFAULT_INDEX: ProvidersIndex = { activeId: null, providers: [] }
|
||||
|
||||
export class ProviderService {
|
||||
private static serverPort = 3456
|
||||
|
||||
static setServerPort(port: number): void {
|
||||
ProviderService.serverPort = port
|
||||
}
|
||||
|
||||
static getServerPort(): number {
|
||||
return ProviderService.serverPort
|
||||
}
|
||||
private getConfigDir(): string {
|
||||
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
||||
}
|
||||
@ -121,6 +137,7 @@ export class ProviderService {
|
||||
name: input.name,
|
||||
apiKey: input.apiKey,
|
||||
baseUrl: input.baseUrl,
|
||||
apiFormat: input.apiFormat ?? 'anthropic',
|
||||
models: input.models,
|
||||
...(input.notes !== undefined && { notes: input.notes }),
|
||||
}
|
||||
@ -141,6 +158,7 @@ export class ProviderService {
|
||||
...(input.name !== undefined && { name: input.name }),
|
||||
...(input.apiKey !== undefined && { apiKey: input.apiKey }),
|
||||
...(input.baseUrl !== undefined && { baseUrl: input.baseUrl }),
|
||||
...(input.apiFormat !== undefined && { apiFormat: input.apiFormat }),
|
||||
...(input.models !== undefined && { models: input.models }),
|
||||
...(input.notes !== undefined && { notes: input.notes }),
|
||||
}
|
||||
@ -198,10 +216,15 @@ export class ProviderService {
|
||||
const settings = await this.readSettings()
|
||||
const existingEnv = (settings.env as Record<string, string>) || {}
|
||||
|
||||
const needsProxy = provider.apiFormat != null && provider.apiFormat !== 'anthropic'
|
||||
const baseUrl = needsProxy
|
||||
? `http://127.0.0.1:${ProviderService.serverPort}/proxy`
|
||||
: provider.baseUrl
|
||||
|
||||
settings.env = {
|
||||
...existingEnv,
|
||||
ANTHROPIC_BASE_URL: provider.baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: provider.apiKey,
|
||||
ANTHROPIC_BASE_URL: baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: needsProxy ? 'proxy-managed' : provider.apiKey,
|
||||
ANTHROPIC_MODEL: provider.models.main,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: provider.models.haiku,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: provider.models.sonnet,
|
||||
@ -227,65 +250,239 @@ export class ProviderService {
|
||||
await this.writeSettings(settings)
|
||||
}
|
||||
|
||||
// --- Test ---
|
||||
// --- Proxy support ---
|
||||
|
||||
async testProvider(id: string): Promise<ProviderTestResult> {
|
||||
const provider = await this.getProvider(id)
|
||||
if (!provider.baseUrl || !provider.apiKey) {
|
||||
return { success: false, latencyMs: 0, error: 'Missing baseUrl or apiKey' }
|
||||
}
|
||||
return this.testProviderConfig({
|
||||
async getActiveProviderForProxy(): Promise<{
|
||||
baseUrl: string
|
||||
apiKey: string
|
||||
apiFormat: ApiFormat
|
||||
} | null> {
|
||||
const index = await this.readIndex()
|
||||
if (!index.activeId) return null
|
||||
const provider = index.providers.find((p) => p.id === index.activeId)
|
||||
if (!provider) return null
|
||||
return {
|
||||
baseUrl: provider.baseUrl,
|
||||
apiKey: provider.apiKey,
|
||||
modelId: provider.models.main,
|
||||
apiFormat: provider.apiFormat ?? 'anthropic',
|
||||
}
|
||||
}
|
||||
|
||||
// --- Test ---
|
||||
|
||||
async testProvider(
|
||||
id: string,
|
||||
overrides?: { baseUrl?: string; modelId?: string; apiFormat?: ApiFormat },
|
||||
): Promise<ProviderTestResult> {
|
||||
const provider = await this.getProvider(id)
|
||||
const baseUrl = overrides?.baseUrl || provider.baseUrl
|
||||
const modelId = overrides?.modelId || provider.models.main
|
||||
const apiFormat = overrides?.apiFormat ?? provider.apiFormat ?? 'anthropic'
|
||||
|
||||
if (!baseUrl || !provider.apiKey) {
|
||||
return { connectivity: { success: false, latencyMs: 0, error: 'Missing baseUrl or apiKey' } }
|
||||
}
|
||||
return this.testProviderConfig({
|
||||
baseUrl,
|
||||
apiKey: provider.apiKey,
|
||||
modelId,
|
||||
apiFormat,
|
||||
})
|
||||
}
|
||||
|
||||
async testProviderConfig(input: TestProviderInput): Promise<ProviderTestResult> {
|
||||
const url = `${input.baseUrl.replace(/\/+$/, '')}/v1/messages`
|
||||
const start = Date.now()
|
||||
const format: ApiFormat = input.apiFormat ?? 'anthropic'
|
||||
const base = input.baseUrl.replace(/\/+$/, '')
|
||||
|
||||
// ── Step 1: Basic connectivity ───────────────────────────
|
||||
// Directly call the upstream API to verify URL, key, and model.
|
||||
const step1 = await this.testConnectivity(base, input.apiKey, input.modelId, format)
|
||||
|
||||
// If connectivity failed, no point running step 2
|
||||
if (!step1.success) {
|
||||
return { connectivity: step1 }
|
||||
}
|
||||
|
||||
// For native Anthropic format, no proxy pipeline to test
|
||||
if (format === 'anthropic') {
|
||||
return { connectivity: step1 }
|
||||
}
|
||||
|
||||
// ── Step 2: Full proxy pipeline ──────────────────────────
|
||||
// Anthropic request → transform → upstream → transform back → validate
|
||||
const step2 = await this.testProxyPipeline(base, input.apiKey, input.modelId, format)
|
||||
|
||||
return { connectivity: step1, proxy: step2 }
|
||||
}
|
||||
|
||||
/** Step 1: Direct upstream call to verify connectivity, auth, and model. */
|
||||
private async testConnectivity(
|
||||
base: string,
|
||||
apiKey: string,
|
||||
modelId: string,
|
||||
format: ApiFormat,
|
||||
): Promise<ProviderTestStepResult> {
|
||||
const start = Date.now()
|
||||
try {
|
||||
const { url, headers, body } = buildDirectTestRequest(base, apiKey, modelId, format)
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': input.apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: input.modelId,
|
||||
max_tokens: 1,
|
||||
messages: [{ role: 'user', content: 'Hi' }],
|
||||
}),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
})
|
||||
|
||||
const latencyMs = Date.now() - start
|
||||
const resBody = await response.json().catch(() => null) as Record<string, unknown> | null
|
||||
|
||||
if (response.ok) {
|
||||
return { success: true, latencyMs, modelUsed: input.modelId, httpStatus: response.status }
|
||||
}
|
||||
|
||||
let errorMessage = `HTTP ${response.status}`
|
||||
try {
|
||||
const body = (await response.json()) as Record<string, unknown>
|
||||
if (body.error && typeof body.error === 'object') {
|
||||
errorMessage = ((body.error as Record<string, unknown>).message as string) || errorMessage
|
||||
} else if (typeof body.message === 'string') {
|
||||
errorMessage = body.message
|
||||
if (!response.ok) {
|
||||
let error = `HTTP ${response.status}`
|
||||
if (resBody?.error && typeof resBody.error === 'object') {
|
||||
error = ((resBody.error as Record<string, unknown>).message as string) || error
|
||||
}
|
||||
} catch {
|
||||
errorMessage = `HTTP ${response.status} ${response.statusText}`
|
||||
return { success: false, latencyMs, error, modelUsed: modelId, httpStatus: response.status }
|
||||
}
|
||||
|
||||
return { success: false, latencyMs, error: errorMessage, modelUsed: input.modelId, httpStatus: response.status }
|
||||
// Validate response structure
|
||||
const valid = validateResponseBody(resBody, format)
|
||||
if (!valid.ok) {
|
||||
return { success: false, latencyMs, error: valid.error, modelUsed: modelId, httpStatus: response.status }
|
||||
}
|
||||
|
||||
return { success: true, latencyMs, modelUsed: valid.model || modelId, httpStatus: response.status }
|
||||
} catch (err: unknown) {
|
||||
const latencyMs = Date.now() - start
|
||||
if (err instanceof DOMException && err.name === 'TimeoutError') {
|
||||
return { success: false, latencyMs, error: 'Request timed out after 15 seconds', modelUsed: input.modelId }
|
||||
return { success: false, latencyMs, error: 'Request timed out (30s)', modelUsed: modelId }
|
||||
}
|
||||
return { success: false, latencyMs, error: err instanceof Error ? err.message : String(err), modelUsed: input.modelId }
|
||||
return { success: false, latencyMs, error: err instanceof Error ? err.message : String(err), modelUsed: modelId }
|
||||
}
|
||||
}
|
||||
|
||||
/** Step 2: Full proxy pipeline — Anthropic → transform → upstream → transform back → validate. */
|
||||
private async testProxyPipeline(
|
||||
base: string,
|
||||
apiKey: string,
|
||||
modelId: string,
|
||||
format: 'openai_chat' | 'openai_responses',
|
||||
): Promise<ProviderTestStepResult> {
|
||||
const start = Date.now()
|
||||
try {
|
||||
// Build an Anthropic Messages API request (same shape as what CLI sends)
|
||||
const anthropicReq: AnthropicRequest = {
|
||||
model: modelId,
|
||||
max_tokens: 64,
|
||||
messages: [{ role: 'user', content: 'Say "ok" and nothing else.' }],
|
||||
}
|
||||
|
||||
// Transform to OpenAI format
|
||||
let upstreamUrl: string
|
||||
let transformedBody: unknown
|
||||
if (format === 'openai_chat') {
|
||||
transformedBody = anthropicToOpenaiChat(anthropicReq)
|
||||
upstreamUrl = `${base}/v1/chat/completions`
|
||||
} else {
|
||||
transformedBody = anthropicToOpenaiResponses(anthropicReq)
|
||||
upstreamUrl = `${base}/v1/responses`
|
||||
}
|
||||
|
||||
// Call upstream with transformed request
|
||||
const response = await fetch(upstreamUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||
body: JSON.stringify(transformedBody),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const latencyMs = Date.now() - start
|
||||
const errText = await response.text().catch(() => '')
|
||||
return { success: false, latencyMs, modelUsed: modelId, httpStatus: response.status,
|
||||
error: `Upstream HTTP ${response.status}: ${errText.slice(0, 200)}` }
|
||||
}
|
||||
|
||||
// Transform response back to Anthropic format
|
||||
const responseBody = await response.json()
|
||||
const anthropicRes = format === 'openai_chat'
|
||||
? openaiChatToAnthropic(responseBody, modelId)
|
||||
: openaiResponsesToAnthropic(responseBody, modelId)
|
||||
|
||||
const latencyMs = Date.now() - start
|
||||
|
||||
// Validate the final Anthropic response
|
||||
if (anthropicRes.type !== 'message' || !Array.isArray(anthropicRes.content)) {
|
||||
return { success: false, latencyMs, modelUsed: modelId,
|
||||
error: 'Proxy transform produced invalid Anthropic response' }
|
||||
}
|
||||
|
||||
return { success: true, latencyMs, modelUsed: anthropicRes.model || modelId, httpStatus: response.status }
|
||||
} catch (err: unknown) {
|
||||
const latencyMs = Date.now() - start
|
||||
if (err instanceof DOMException && err.name === 'TimeoutError') {
|
||||
return { success: false, latencyMs, error: 'Proxy pipeline timed out (30s)', modelUsed: modelId }
|
||||
}
|
||||
return { success: false, latencyMs, error: err instanceof Error ? err.message : String(err), modelUsed: modelId }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────
|
||||
|
||||
function buildDirectTestRequest(
|
||||
base: string,
|
||||
apiKey: string,
|
||||
modelId: string,
|
||||
format: ApiFormat,
|
||||
): { url: string; headers: Record<string, string>; body: Record<string, unknown> } {
|
||||
const prompt = 'Say "ok" and nothing else.'
|
||||
|
||||
if (format === 'openai_chat') {
|
||||
return {
|
||||
url: `${base}/v1/chat/completions`,
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||
body: { model: modelId, max_tokens: 16, messages: [{ role: 'user', content: prompt }] },
|
||||
}
|
||||
}
|
||||
if (format === 'openai_responses') {
|
||||
return {
|
||||
url: `${base}/v1/responses`,
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||
body: { model: modelId, max_output_tokens: 16, input: [{ type: 'message', role: 'user', content: prompt }] },
|
||||
}
|
||||
}
|
||||
// anthropic
|
||||
return {
|
||||
url: `${base}/v1/messages`,
|
||||
headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
|
||||
body: { model: modelId, max_tokens: 16, messages: [{ role: 'user', content: prompt }] },
|
||||
}
|
||||
}
|
||||
|
||||
function validateResponseBody(
|
||||
body: Record<string, unknown> | null,
|
||||
format: ApiFormat,
|
||||
): { ok: true; model?: string } | { ok: false; error: string } {
|
||||
if (!body) return { ok: false, error: 'Empty response — not a valid API endpoint' }
|
||||
if (body.error && typeof body.error === 'object') {
|
||||
return { ok: false, error: ((body.error as Record<string, unknown>).message as string) || 'Error in response body' }
|
||||
}
|
||||
|
||||
if (format === 'openai_chat') {
|
||||
if (!Array.isArray(body.choices) || body.choices.length === 0) {
|
||||
return { ok: false, error: 'Response missing choices — not a valid Chat Completions endpoint' }
|
||||
}
|
||||
return { ok: true, model: (body.model as string) || undefined }
|
||||
}
|
||||
if (format === 'openai_responses') {
|
||||
if (!Array.isArray(body.output)) {
|
||||
return { ok: false, error: 'Response missing output — not a valid Responses API endpoint' }
|
||||
}
|
||||
return { ok: true, model: (body.model as string) || undefined }
|
||||
}
|
||||
// anthropic
|
||||
if (body.type !== 'message' || !Array.isArray(body.content)) {
|
||||
return { ok: false, error: 'Not a valid Anthropic Messages endpoint' }
|
||||
}
|
||||
return { ok: true, model: (body.model as string) || undefined }
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,13 @@
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
export const ApiFormatSchema = z.enum([
|
||||
'anthropic', // Native Anthropic Messages API (passthrough, no proxy)
|
||||
'openai_chat', // OpenAI Chat Completions /v1/chat/completions
|
||||
'openai_responses', // OpenAI Responses API /v1/responses
|
||||
])
|
||||
export type ApiFormat = z.infer<typeof ApiFormatSchema>
|
||||
|
||||
export const ModelMappingSchema = z.object({
|
||||
main: z.string(),
|
||||
haiku: z.string(),
|
||||
@ -20,6 +27,7 @@ export const SavedProviderSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
apiKey: z.string(),
|
||||
baseUrl: z.string(),
|
||||
apiFormat: ApiFormatSchema.default('anthropic'),
|
||||
models: ModelMappingSchema,
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
@ -34,6 +42,7 @@ export const CreateProviderSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
apiKey: z.string(),
|
||||
baseUrl: z.string(),
|
||||
apiFormat: ApiFormatSchema.default('anthropic'),
|
||||
models: ModelMappingSchema,
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
@ -42,6 +51,7 @@ export const UpdateProviderSchema = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
apiKey: z.string().optional(),
|
||||
baseUrl: z.string().optional(),
|
||||
apiFormat: ApiFormatSchema.optional(),
|
||||
models: ModelMappingSchema.optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
@ -50,6 +60,7 @@ export const TestProviderSchema = z.object({
|
||||
baseUrl: z.string().url(),
|
||||
apiKey: z.string().min(1),
|
||||
modelId: z.string().min(1),
|
||||
apiFormat: ApiFormatSchema.default('anthropic'),
|
||||
})
|
||||
|
||||
// TypeScript types
|
||||
@ -60,10 +71,17 @@ export type CreateProviderInput = z.infer<typeof CreateProviderSchema>
|
||||
export type UpdateProviderInput = z.infer<typeof UpdateProviderSchema>
|
||||
export type TestProviderInput = z.infer<typeof TestProviderSchema>
|
||||
|
||||
export interface ProviderTestResult {
|
||||
export interface ProviderTestStepResult {
|
||||
success: boolean
|
||||
latencyMs: number
|
||||
error?: string
|
||||
modelUsed?: string
|
||||
httpStatus?: number
|
||||
}
|
||||
|
||||
export interface ProviderTestResult {
|
||||
/** Step 1: Basic connectivity — API reachable, key valid, model exists */
|
||||
connectivity: ProviderTestStepResult
|
||||
/** Step 2: Proxy pipeline — full Anthropic→OpenAI→Anthropic round-trip (only for openai_* formats) */
|
||||
proxy?: ProviderTestStepResult
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user