Files
Hermes-ui/packages/server/src/services/claude-code-proxy.ts
T

996 lines
34 KiB
TypeScript
Raw Normal View History

import { randomBytes } from 'crypto'
import { Readable } from 'stream'
import type { Context } from 'koa'
import { config } from '../config'
export type ApiMode = 'chat_completions' | 'codex_responses' | 'anthropic_messages' | 'bedrock_converse' | 'codex_app_server'
export interface ClaudeCodeProxyTargetInput {
provider: string
model: string
baseUrl: string
apiKey: string
apiMode?: ApiMode
}
interface ClaudeCodeProxyTarget extends ClaudeCodeProxyTargetInput {
key: string
routeKey: string
token: string
updatedAt: number
}
const targets = new Map<string, ClaudeCodeProxyTarget>()
const CLAUDE_PROXY_VISIBLE_MODELS = [
'claude-haiku-4-5',
'claude-sonnet-4-6',
'claude-opus-4-7',
]
function targetKey(provider: string, model: string, apiMode: ApiMode, baseUrl: string): string {
return `${provider}\0${model}\0${apiMode}\0${baseUrl}`
}
function routeKeyFor(provider: string, model: string, apiMode: ApiMode, baseUrl: string): string {
return Buffer.from(targetKey(provider, model, apiMode, baseUrl), 'utf-8').toString('base64url')
}
function localProxyBaseUrl(routeKey: string): string {
return `http://127.0.0.1:${config.port}/api/claude-code-proxy/${routeKey}`
}
export function registerClaudeCodeProxyTarget(input: ClaudeCodeProxyTargetInput): { baseUrl: string; token: string; routeKey: string } {
const provider = input.provider.trim()
const model = input.model.trim()
const baseUrl = input.baseUrl.replace(/\/+$/, '')
const apiMode = input.apiMode || 'chat_completions'
const key = targetKey(provider, model, apiMode, baseUrl)
const existing = targets.get(key)
const routeKey = existing?.routeKey || routeKeyFor(provider, model, apiMode, baseUrl)
const token = existing?.token || `hwui_${randomBytes(24).toString('base64url')}`
targets.set(key, {
...input,
provider,
model,
baseUrl,
apiMode,
key,
routeKey,
token,
updatedAt: Date.now(),
})
return { baseUrl: localProxyBaseUrl(routeKey), token, routeKey }
}
function findTarget(routeKey: string): ClaudeCodeProxyTarget | null {
for (const target of targets.values()) {
if (target.routeKey === routeKey) return target
}
return null
}
function authToken(ctx: Context): string {
const apiKey = ctx.get('x-api-key').trim()
if (apiKey) return apiKey
const auth = ctx.get('authorization').trim()
const match = auth.match(/^Bearer\s+(.+)$/i)
return match?.[1]?.trim() || ''
}
function requireTarget(ctx: Context): ClaudeCodeProxyTarget | null {
const target = findTarget(String(ctx.params.key || ''))
if (!target) {
ctx.status = 404
ctx.body = { type: 'error', error: { type: 'not_found_error', message: 'Claude proxy target not found' } }
return null
}
if (authToken(ctx) !== target.token) {
ctx.status = 401
ctx.body = { type: 'error', error: { type: 'authentication_error', message: 'Invalid Claude proxy token' } }
return null
}
return target
}
function stringifyContent(value: unknown): string {
if (typeof value === 'string') return value
if (Array.isArray(value)) {
return value.map((item) => {
if (typeof item === 'string') return item
if (item && typeof item === 'object' && 'text' in item) return String((item as any).text || '')
return JSON.stringify(item)
}).filter(Boolean).join('\n')
}
if (value == null) return ''
return JSON.stringify(value)
}
function shouldPreserveReasoningContent(target: ClaudeCodeProxyTarget): boolean {
const identifier = `${target.provider} ${target.model} ${target.baseUrl}`.toLowerCase()
return [
'deepseek',
'moonshot',
'kimi',
'mimo',
'xiaomimimo',
].some(part => identifier.includes(part))
}
function anthropicContentToOpenAiMessages(message: any, preserveReasoningContent = false): any[] {
const content = message?.content
if (!Array.isArray(content)) {
return [{ role: message.role, content: stringifyContent(content) }]
}
if (message.role === 'assistant') {
const textParts: string[] = []
const reasoningParts: string[] = []
const toolCalls: any[] = []
for (const block of content) {
if (block?.type === 'text') textParts.push(String(block.text || ''))
if (block?.type === 'thinking' && block.thinking) reasoningParts.push(String(block.thinking))
if (block?.type === 'redacted_thinking' && preserveReasoningContent) reasoningParts.push('[redacted thinking]')
if (block?.type === 'tool_use') {
toolCalls.push({
id: String(block.id || `tool_${toolCalls.length}`),
type: 'function',
function: {
name: String(block.name || 'tool'),
arguments: JSON.stringify(block.input || {}),
},
})
}
}
const openAiMessage: any = {
role: 'assistant',
content: textParts.join('\n') || null,
...(toolCalls.length ? { tool_calls: toolCalls } : {}),
}
if (preserveReasoningContent && (reasoningParts.length || toolCalls.length)) {
openAiMessage.reasoning_content = reasoningParts.join('\n') || 'tool call'
}
return [openAiMessage]
}
const messages: any[] = []
const textParts: string[] = []
for (const block of content) {
if (block?.type === 'text') textParts.push(String(block.text || ''))
if (block?.type === 'tool_result') {
if (textParts.length) {
messages.push({ role: 'user', content: textParts.splice(0).join('\n') })
}
messages.push({
role: 'tool',
tool_call_id: String(block.tool_use_id || ''),
content: stringifyContent(block.content),
})
}
}
if (textParts.length) messages.push({ role: message.role || 'user', content: textParts.join('\n') })
return messages.length ? messages : [{ role: message.role || 'user', content: '' }]
}
function anthropicToOpenAiChat(body: any, target: ClaudeCodeProxyTarget, stream = false): any {
const messages: any[] = []
const preserveReasoningContent = shouldPreserveReasoningContent(target)
const system = body?.system
if (system) messages.push({ role: 'system', content: stringifyContent(system) })
for (const message of Array.isArray(body?.messages) ? body.messages : []) {
messages.push(...anthropicContentToOpenAiMessages(message, preserveReasoningContent))
}
const tools = Array.isArray(body?.tools)
? body.tools.map((tool: any) => ({
type: 'function',
function: {
name: String(tool.name || ''),
description: String(tool.description || ''),
parameters: tool.input_schema || { type: 'object', properties: {} },
},
})).filter((tool: any) => tool.function.name)
: undefined
return {
model: target.model,
messages,
...(typeof body?.max_tokens === 'number' ? { max_tokens: body.max_tokens } : {}),
...(typeof body?.temperature === 'number' ? { temperature: body.temperature } : {}),
...(tools?.length ? { tools } : {}),
stream,
}
}
function anthropicToOpenAiResponsesInput(message: any): any[] {
const content = Array.isArray(message?.content) ? message.content : [{ type: 'text', text: stringifyContent(message?.content) }]
if (message.role === 'assistant') {
const items: any[] = []
const textParts: string[] = []
for (const block of content) {
if (block?.type === 'text') textParts.push(String(block.text || ''))
if (block?.type === 'tool_use') {
if (textParts.length) {
items.push({ role: 'assistant', content: textParts.splice(0).join('\n') })
}
items.push({
type: 'function_call',
call_id: String(block.id || `tool_${items.length}`),
name: String(block.name || 'tool'),
arguments: JSON.stringify(block.input || {}),
})
}
}
if (textParts.length) items.push({ role: 'assistant', content: textParts.join('\n') })
return items
}
const items: any[] = []
const textParts: string[] = []
for (const block of content) {
if (block?.type === 'text') textParts.push(String(block.text || ''))
if (block?.type === 'tool_result') {
if (textParts.length) {
items.push({ role: 'user', content: textParts.splice(0).join('\n') })
}
items.push({
type: 'function_call_output',
call_id: String(block.tool_use_id || ''),
output: stringifyContent(block.content),
})
}
}
if (textParts.length) items.push({ role: message.role || 'user', content: textParts.join('\n') })
return items.length ? items : [{ role: message.role || 'user', content: '' }]
}
function anthropicToOpenAiResponses(body: any, target: ClaudeCodeProxyTarget, stream = false): any {
const input: any[] = []
for (const message of Array.isArray(body?.messages) ? body.messages : []) {
input.push(...anthropicToOpenAiResponsesInput(message))
}
const tools = Array.isArray(body?.tools)
? body.tools.map((tool: any) => ({
type: 'function',
name: String(tool.name || ''),
description: String(tool.description || ''),
parameters: tool.input_schema || { type: 'object', properties: {} },
})).filter((tool: any) => tool.name)
: undefined
return {
model: target.model,
input,
...(body?.system ? { instructions: stringifyContent(body.system) } : {}),
...(typeof body?.max_tokens === 'number' ? { max_output_tokens: body.max_tokens } : {}),
...(typeof body?.temperature === 'number' ? { temperature: body.temperature } : {}),
...(tools?.length ? { tools } : {}),
stream,
store: false,
}
}
function safeJsonParse(value: string): any {
try {
return JSON.parse(value)
} catch {
return {}
}
}
function mapStopReason(reason: string | null | undefined, hasTools: boolean): string {
if (hasTools) return 'tool_use'
if (reason === 'length') return 'max_tokens'
if (reason === 'content_filter') return 'stop_sequence'
return 'end_turn'
}
function openAiToAnthropicMessage(data: any, target: ClaudeCodeProxyTarget): any {
const choice = data?.choices?.[0] || {}
const message = choice.message || {}
const content: any[] = []
if (shouldPreserveReasoningContent(target) && message.reasoning_content) {
content.push({ type: 'thinking', thinking: String(message.reasoning_content) })
}
if (message.content) content.push({ type: 'text', text: String(message.content) })
for (const call of Array.isArray(message.tool_calls) ? message.tool_calls : []) {
content.push({
type: 'tool_use',
id: String(call.id || `toolu_${content.length}`),
name: String(call.function?.name || 'tool'),
input: safeJsonParse(String(call.function?.arguments || '{}')),
})
}
const hasTools = content.some(block => block.type === 'tool_use')
return {
id: String(data?.id || `msg_${Date.now()}`),
type: 'message',
role: 'assistant',
model: target.model,
content,
stop_reason: mapStopReason(choice.finish_reason, hasTools),
stop_sequence: null,
usage: {
input_tokens: Number(data?.usage?.prompt_tokens || 0),
output_tokens: Number(data?.usage?.completion_tokens || 0),
},
}
}
function sseEvent(event: string, data: any): string {
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
}
function anthropicMessageToSse(message: any): string {
let output = ''
output += sseEvent('message_start', {
type: 'message_start',
message: { ...message, content: [], stop_reason: null, usage: { input_tokens: message.usage.input_tokens, output_tokens: 0 } },
})
message.content.forEach((block: any, index: number) => {
if (block.type === 'text') {
output += sseEvent('content_block_start', { type: 'content_block_start', index, content_block: { type: 'text', text: '' } })
if (block.text) output += sseEvent('content_block_delta', { type: 'content_block_delta', index, delta: { type: 'text_delta', text: block.text } })
output += sseEvent('content_block_stop', { type: 'content_block_stop', index })
} else if (block.type === 'tool_use') {
output += sseEvent('content_block_start', {
type: 'content_block_start',
index,
content_block: { type: 'tool_use', id: block.id, name: block.name, input: {} },
})
output += sseEvent('content_block_delta', {
type: 'content_block_delta',
index,
delta: { type: 'input_json_delta', partial_json: JSON.stringify(block.input || {}) },
})
output += sseEvent('content_block_stop', { type: 'content_block_stop', index })
}
})
output += sseEvent('message_delta', {
type: 'message_delta',
delta: { stop_reason: message.stop_reason, stop_sequence: null },
usage: { output_tokens: message.usage.output_tokens },
})
output += sseEvent('message_stop', { type: 'message_stop' })
return output
}
function anthropicMessagesUrl(target: ClaudeCodeProxyTarget): string {
if (/\/v\d+$/i.test(target.baseUrl)) return `${target.baseUrl}/messages`
return `${target.baseUrl}/v1/messages`
}
async function readProviderJson(res: Response): Promise<any> {
const text = await res.text()
try {
return JSON.parse(text)
} catch {
return { error: { message: text || `Provider returned HTTP ${res.status}` } }
}
}
function throwProviderError(res: Response, data: any): never {
const err = new Error(data?.error?.message || `Provider returned HTTP ${res.status}`)
;(err as any).status = res.status
;(err as any).providerError = data
throw err
}
function anthropicRequestBody(body: any, target: ClaudeCodeProxyTarget): any {
return {
...body,
model: target.model,
}
}
async function callAnthropicMessages(target: ClaudeCodeProxyTarget, body: any): Promise<any> {
if (target.apiMode !== 'anthropic_messages') {
const err = new Error(`Claude proxy Anthropic adapter only supports anthropic_messages targets, got ${target.apiMode}`)
;(err as any).status = 501
throw err
}
const res = await fetch(anthropicMessagesUrl(target), {
method: 'POST',
headers: {
Authorization: `Bearer ${target.apiKey}`,
'x-api-key': target.apiKey,
'anthropic-version': '2023-06-01',
'Content-Type': 'application/json',
},
body: JSON.stringify(anthropicRequestBody(body, target)),
})
const data = await readProviderJson(res)
if (!res.ok) throwProviderError(res, data)
return data
}
async function callOpenAiChat(target: ClaudeCodeProxyTarget, body: any): Promise<any> {
if (target.apiMode !== 'chat_completions') {
const err = new Error(`Claude proxy MVP only supports chat_completions targets, got ${target.apiMode}`)
;(err as any).status = 501
throw err
}
const res = await fetch(`${target.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${target.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(anthropicToOpenAiChat(body, target)),
})
const data = await readProviderJson(res)
if (!res.ok) throwProviderError(res, data)
return data
}
async function callOpenAiResponses(target: ClaudeCodeProxyTarget, body: any): Promise<any> {
if (target.apiMode !== 'codex_responses') {
const err = new Error(`Claude proxy responses adapter only supports codex_responses targets, got ${target.apiMode}`)
;(err as any).status = 501
throw err
}
const res = await fetch(`${target.baseUrl}/responses`, {
method: 'POST',
headers: {
Authorization: `Bearer ${target.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(anthropicToOpenAiResponses(body, target)),
})
const data = await readProviderJson(res)
if (!res.ok) throwProviderError(res, data)
return data
}
function responseOutputText(item: any): string {
if (item?.type === 'output_text') return String(item.text || '')
if (item?.type === 'message' && Array.isArray(item.content)) {
return item.content
.map((part: any) => {
if (part?.type === 'output_text' || part?.type === 'text') return String(part.text || '')
return ''
})
.filter(Boolean)
.join('')
}
return ''
}
function openAiResponsesToAnthropicMessage(data: any, target: ClaudeCodeProxyTarget): any {
const content: any[] = []
const output = Array.isArray(data?.output) ? data.output : []
for (const item of output) {
const text = responseOutputText(item)
if (text) content.push({ type: 'text', text })
if (item?.type === 'function_call') {
content.push({
type: 'tool_use',
id: String(item.call_id || item.id || `toolu_${content.length}`),
name: String(item.name || 'tool'),
input: safeJsonParse(String(item.arguments || '{}')),
})
}
}
if (!content.length && data?.output_text) {
content.push({ type: 'text', text: String(data.output_text) })
}
const hasTools = content.some(block => block.type === 'tool_use')
return {
id: String(data?.id || `msg_${Date.now()}`),
type: 'message',
role: 'assistant',
model: target.model,
content,
stop_reason: hasTools ? 'tool_use' : (data?.status === 'incomplete' ? 'max_tokens' : 'end_turn'),
stop_sequence: null,
usage: {
input_tokens: Number(data?.usage?.input_tokens || 0),
output_tokens: Number(data?.usage?.output_tokens || 0),
},
}
}
function getReadableStream(res: Response): AsyncIterable<Uint8Array> {
const body = res.body
if (!body) throw new Error('Provider returned an empty stream')
return body as any
}
function parseOpenAiSse(buffer: string): { events: string[]; rest: string } {
const events: string[] = []
let cursor = 0
while (true) {
const index = buffer.indexOf('\n\n', cursor)
if (index < 0) break
events.push(buffer.slice(cursor, index))
cursor = index + 2
}
return { events, rest: buffer.slice(cursor) }
}
function extractSseData(event: string): string[] {
return event
.split(/\r?\n/)
.filter(line => line.startsWith('data:'))
.map(line => line.slice(5).trimStart())
}
function openAiFinishToAnthropic(finishReason: string | null | undefined, sawTool: boolean): string {
return mapStopReason(finishReason, sawTool)
}
async function openAiChatToAnthropicSseStream(target: ClaudeCodeProxyTarget, body: any): Promise<Readable> {
if (target.apiMode !== 'chat_completions') {
const err = new Error(`Claude proxy MVP only supports chat_completions targets, got ${target.apiMode}`)
;(err as any).status = 501
throw err
}
const res = await fetch(`${target.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${target.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(anthropicToOpenAiChat(body, target, true)),
})
if (!res.ok) {
let data: any
const text = await res.text()
try {
data = JSON.parse(text)
} catch {
data = { error: { message: text || `Provider returned HTTP ${res.status}` } }
}
const err = new Error(data?.error?.message || `Provider returned HTTP ${res.status}`)
;(err as any).status = res.status
;(err as any).providerError = data
throw err
}
const stream = getReadableStream(res)
const decoder = new TextDecoder()
async function* generate() {
const messageId = `msg_${Date.now()}`
let buffer = ''
let thinkingBlockIndex: number | null = null
let thinkingBlockStopped = false
let textBlockStarted = false
let textBlockStopped = false
let textBlockIndex: number | null = null
let nextIndex = 0
let stopReason: string | null = null
let outputTokens = 0
const toolBlocks = new Map<number, { blockIndex: number; id: string; name: string; started: boolean }>()
yield sseEvent('message_start', {
type: 'message_start',
message: {
id: messageId,
type: 'message',
role: 'assistant',
model: target.model,
content: [],
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 0, output_tokens: 0 },
},
})
const ensureThinkingBlock = function* () {
if (thinkingBlockIndex == null) {
thinkingBlockIndex = nextIndex++
yield sseEvent('content_block_start', {
type: 'content_block_start',
index: thinkingBlockIndex,
content_block: { type: 'thinking', thinking: '' },
})
}
return thinkingBlockIndex
}
const stopThinkingBlock = function* () {
if (thinkingBlockIndex != null && !thinkingBlockStopped) {
thinkingBlockStopped = true
yield sseEvent('content_block_stop', { type: 'content_block_stop', index: thinkingBlockIndex })
}
}
const ensureTextBlock = function* () {
if (!textBlockStarted) {
textBlockStarted = true
textBlockIndex = nextIndex
yield sseEvent('content_block_start', {
type: 'content_block_start',
index: textBlockIndex,
content_block: { type: 'text', text: '' },
})
nextIndex += 1
}
return textBlockIndex ?? 0
}
const ensureToolBlock = function* (toolIndex: number, id?: string, name?: string) {
let block = toolBlocks.get(toolIndex)
if (!block) {
block = {
blockIndex: nextIndex++,
id: id || `toolu_${toolIndex}`,
name: name || 'tool',
started: false,
}
toolBlocks.set(toolIndex, block)
} else {
if (id) block.id = id
if (name) block.name = name
}
if (!block.started && block.name) {
block.started = true
yield sseEvent('content_block_start', {
type: 'content_block_start',
index: block.blockIndex,
content_block: { type: 'tool_use', id: block.id, name: block.name, input: {} },
})
}
return block
}
for await (const chunk of stream) {
buffer += decoder.decode(chunk, { stream: true })
const parsed = parseOpenAiSse(buffer)
buffer = parsed.rest
for (const event of parsed.events) {
for (const dataLine of extractSseData(event)) {
if (!dataLine || dataLine === '[DONE]') continue
const data = safeJsonParse(dataLine)
const choice = data?.choices?.[0]
if (!choice) continue
const delta = choice.delta || {}
if (shouldPreserveReasoningContent(target) && typeof delta.reasoning_content === 'string' && delta.reasoning_content) {
const index = yield* ensureThinkingBlock()
yield sseEvent('content_block_delta', {
type: 'content_block_delta',
index,
delta: { type: 'thinking_delta', thinking: delta.reasoning_content },
})
}
if (typeof delta.content === 'string' && delta.content) {
yield* stopThinkingBlock()
const index = yield* ensureTextBlock()
yield sseEvent('content_block_delta', {
type: 'content_block_delta',
index,
delta: { type: 'text_delta', text: delta.content },
})
}
for (const toolCall of Array.isArray(delta.tool_calls) ? delta.tool_calls : []) {
yield* stopThinkingBlock()
if (textBlockStarted && !textBlockStopped) {
textBlockStopped = true
yield sseEvent('content_block_stop', { type: 'content_block_stop', index: textBlockIndex ?? 0 })
}
const toolIndex = Number(toolCall.index || 0)
const block = yield* ensureToolBlock(
toolIndex,
toolCall.id ? String(toolCall.id) : undefined,
toolCall.function?.name ? String(toolCall.function.name) : undefined,
)
const argsDelta = toolCall.function?.arguments
if (typeof argsDelta === 'string' && argsDelta) {
yield sseEvent('content_block_delta', {
type: 'content_block_delta',
index: block.blockIndex,
delta: { type: 'input_json_delta', partial_json: argsDelta },
})
}
}
if (choice.finish_reason) stopReason = String(choice.finish_reason)
if (data?.usage?.completion_tokens) outputTokens = Number(data.usage.completion_tokens)
}
}
}
yield* stopThinkingBlock()
if (textBlockStarted && !textBlockStopped) {
yield sseEvent('content_block_stop', { type: 'content_block_stop', index: textBlockIndex ?? 0 })
}
for (const block of toolBlocks.values()) {
if (block.started) {
yield sseEvent('content_block_stop', { type: 'content_block_stop', index: block.blockIndex })
}
}
yield sseEvent('message_delta', {
type: 'message_delta',
delta: { stop_reason: openAiFinishToAnthropic(stopReason, toolBlocks.size > 0), stop_sequence: null },
usage: { output_tokens: outputTokens },
})
yield sseEvent('message_stop', { type: 'message_stop' })
}
return Readable.from(generate())
}
async function anthropicMessagesSseStream(target: ClaudeCodeProxyTarget, body: any): Promise<Readable> {
if (target.apiMode !== 'anthropic_messages') {
const err = new Error(`Claude proxy Anthropic adapter only supports anthropic_messages targets, got ${target.apiMode}`)
;(err as any).status = 501
throw err
}
const res = await fetch(anthropicMessagesUrl(target), {
method: 'POST',
headers: {
Authorization: `Bearer ${target.apiKey}`,
'x-api-key': target.apiKey,
'anthropic-version': '2023-06-01',
'Content-Type': 'application/json',
},
body: JSON.stringify(anthropicRequestBody(body, target)),
})
if (!res.ok) {
const data = await readProviderJson(res)
throwProviderError(res, data)
}
return Readable.from(getReadableStream(res))
}
async function openAiResponsesToAnthropicSseStream(target: ClaudeCodeProxyTarget, body: any): Promise<Readable> {
if (target.apiMode !== 'codex_responses') {
const err = new Error(`Claude proxy responses adapter only supports codex_responses targets, got ${target.apiMode}`)
;(err as any).status = 501
throw err
}
const res = await fetch(`${target.baseUrl}/responses`, {
method: 'POST',
headers: {
Authorization: `Bearer ${target.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(anthropicToOpenAiResponses(body, target, true)),
})
if (!res.ok) {
let data: any
const text = await res.text()
try {
data = JSON.parse(text)
} catch {
data = { error: { message: text || `Provider returned HTTP ${res.status}` } }
}
const err = new Error(data?.error?.message || `Provider returned HTTP ${res.status}`)
;(err as any).status = res.status
;(err as any).providerError = data
throw err
}
const stream = getReadableStream(res)
const decoder = new TextDecoder()
async function* generate() {
let messageId = `msg_${Date.now()}`
let buffer = ''
let textBlockIndex: number | null = null
let textBlockStopped = false
let nextIndex = 0
let stopReason: string | null = null
let outputTokens = 0
const toolBlocks = new Map<string, { blockIndex: number; id: string; name: string; argsDeltaSeen: boolean; stopped: boolean }>()
yield sseEvent('message_start', {
type: 'message_start',
message: {
id: messageId,
type: 'message',
role: 'assistant',
model: target.model,
content: [],
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 0, output_tokens: 0 },
},
})
const ensureTextBlock = function* () {
if (textBlockIndex == null) {
textBlockIndex = nextIndex++
yield sseEvent('content_block_start', {
type: 'content_block_start',
index: textBlockIndex,
content_block: { type: 'text', text: '' },
})
}
return textBlockIndex
}
const ensureToolBlock = function* (key: string, id?: string, name?: string) {
let block = toolBlocks.get(key)
if (!block) {
block = {
blockIndex: nextIndex++,
id: id || key || `toolu_${toolBlocks.size}`,
name: name || 'tool',
argsDeltaSeen: false,
stopped: false,
}
toolBlocks.set(key, block)
yield sseEvent('content_block_start', {
type: 'content_block_start',
index: block.blockIndex,
content_block: { type: 'tool_use', id: block.id, name: block.name, input: {} },
})
} else {
if (id) block.id = id
if (name && block.name === 'tool') block.name = name
}
return block
}
for await (const chunk of stream) {
buffer += decoder.decode(chunk, { stream: true })
const parsed = parseOpenAiSse(buffer)
buffer = parsed.rest
for (const event of parsed.events) {
for (const dataLine of extractSseData(event)) {
if (!dataLine || dataLine === '[DONE]') continue
const data = safeJsonParse(dataLine)
const eventType = data?.type
if (eventType === 'response.created') {
messageId = String(data?.response?.id || messageId)
}
if (eventType === 'response.output_text.delta') {
const deltaText = String(data?.delta || data?.text || '')
if (deltaText) {
const index = yield* ensureTextBlock()
yield sseEvent('content_block_delta', {
type: 'content_block_delta',
index,
delta: { type: 'text_delta', text: deltaText },
})
}
}
if (eventType === 'response.output_text.done' && textBlockIndex != null && !textBlockStopped) {
textBlockStopped = true
yield sseEvent('content_block_stop', { type: 'content_block_stop', index: textBlockIndex })
}
if (eventType === 'response.output_item.added') {
const item = data?.item || data?.output_item
if (item?.type === 'function_call') {
const key = String(item.call_id || item.id || data.output_index || toolBlocks.size)
yield* ensureToolBlock(key, String(item.call_id || item.id || key), item.name ? String(item.name) : undefined)
}
}
if (eventType === 'response.function_call_arguments.delta') {
const key = String(data.call_id || data.item_id || data.output_index || toolBlocks.size)
const block = yield* ensureToolBlock(key)
const argsDelta = String(data.delta || '')
if (argsDelta) {
block.argsDeltaSeen = true
yield sseEvent('content_block_delta', {
type: 'content_block_delta',
index: block.blockIndex,
delta: { type: 'input_json_delta', partial_json: argsDelta },
})
}
}
if (eventType === 'response.output_item.done') {
const item = data?.item || data?.output_item
if (item?.type === 'function_call') {
const key = String(item.call_id || item.id || data.output_index || toolBlocks.size)
const block = yield* ensureToolBlock(key, String(item.call_id || item.id || key), item.name ? String(item.name) : undefined)
const args = String(item.arguments || '')
if (args && !block.argsDeltaSeen) {
yield sseEvent('content_block_delta', {
type: 'content_block_delta',
index: block.blockIndex,
delta: { type: 'input_json_delta', partial_json: args },
})
}
if (!block.stopped) {
block.stopped = true
yield sseEvent('content_block_stop', { type: 'content_block_stop', index: block.blockIndex })
}
}
}
if (eventType === 'response.completed') {
const response = data?.response || data
outputTokens = Number(response?.usage?.output_tokens || outputTokens)
stopReason = response?.status === 'incomplete' ? 'length' : 'stop'
}
}
}
}
if (textBlockIndex != null && !textBlockStopped) {
yield sseEvent('content_block_stop', { type: 'content_block_stop', index: textBlockIndex })
}
for (const block of toolBlocks.values()) {
if (!block.stopped) {
yield sseEvent('content_block_stop', { type: 'content_block_stop', index: block.blockIndex })
}
}
yield sseEvent('message_delta', {
type: 'message_delta',
delta: { stop_reason: openAiFinishToAnthropic(stopReason, toolBlocks.size > 0), stop_sequence: null },
usage: { output_tokens: outputTokens },
})
yield sseEvent('message_stop', { type: 'message_stop' })
}
return Readable.from(generate())
}
export async function claudeProxyModels(ctx: Context) {
const target = requireTarget(ctx)
if (!target) return
const ids = [...new Set([...CLAUDE_PROXY_VISIBLE_MODELS, target.model])]
ctx.body = {
data: ids.map(id => ({
type: 'model',
id,
display_name: id,
created_at: '2026-01-01T00:00:00Z',
})),
has_more: false,
first_id: ids[0],
last_id: ids[ids.length - 1],
}
}
export async function claudeProxyMessages(ctx: Context) {
const target = requireTarget(ctx)
if (!target) return
try {
const requestBody = ctx.request.body || {}
if ((requestBody as any).stream === true) {
const stream = target.apiMode === 'anthropic_messages'
? await anthropicMessagesSseStream(target, requestBody)
: target.apiMode === 'codex_responses'
? await openAiResponsesToAnthropicSseStream(target, requestBody)
: await openAiChatToAnthropicSseStream(target, requestBody)
ctx.set('Content-Type', 'text/event-stream; charset=utf-8')
ctx.set('Cache-Control', 'no-cache')
ctx.body = stream
} else {
const message = target.apiMode === 'anthropic_messages'
? await callAnthropicMessages(target, requestBody)
: target.apiMode === 'codex_responses'
? openAiResponsesToAnthropicMessage(await callOpenAiResponses(target, requestBody), target)
: openAiToAnthropicMessage(await callOpenAiChat(target, requestBody), target)
ctx.body = message
}
} catch (err: any) {
ctx.status = err.status || 502
ctx.body = {
type: 'error',
error: {
type: 'api_error',
message: err?.message || 'Claude proxy request failed',
provider_error: err?.providerError,
},
}
}
}