imspsycho's picture
Initial upload from Google Colab
98c9143 verified
Raw
History Blame Contribute Delete
17.4 kB
import type { Context, Env } from "hono"
import { events } from "fetch-event-stream"
import { streamSSE } from "hono/streaming"
import type {
AnthropicMessagesPayload,
AnthropicResponse,
AnthropicStreamEventData,
AnthropicStreamState,
} from "~/routes/messages/anthropic-types"
import type {
ChatCompletionChunk,
ChatCompletionResponse,
ChatCompletionsPayload,
ContentPart,
Message,
} from "~/services/copilot/create-chat-completions"
import {
getProviderConfig,
type ModelConfig,
type ResolvedProviderConfig,
} from "~/lib/config"
import { HTTPError } from "~/lib/error"
import { createHandlerLogger, debugJson, debugLazy } from "~/lib/logger"
import {
createProviderTokenUsageRecorder,
mergeAnthropicUsage,
normalizeAnthropicUsage,
normalizeOpenAIUsage,
type UsageTokens,
} from "~/lib/token-usage"
import { parseUserIdMetadata } from "~/lib/utils"
import {
translateToAnthropic,
translateToOpenAI,
} from "~/routes/messages/non-stream-translation"
import {
flushPendingAnthropicStreamEvents,
translateChunkToAnthropicEvents,
} from "~/routes/messages/stream-translation"
import {
forwardProviderChatCompletions,
forwardProviderMessages,
} from "~/services/providers/anthropic-proxy"
const logger = createHandlerLogger("provider-messages-handler")
const OPENAI_COMPATIBLE_CONTEXT_CACHE_MARKER_LIMIT = 4
const OPENAI_COMPATIBLE_CONTEXT_CACHE_CONTROL = {
type: "ephemeral",
} as const
const OPENAI_COMPATIBLE_CONTEXT_CACHE_ROLES = new Set<Message["role"]>([
"system",
"user",
"assistant",
"tool",
])
export async function handleProviderMessages(
c: Context<Env, "/:provider">,
): Promise<Response> {
const provider = c.req.param("provider")
const payload = await c.req.json<AnthropicMessagesPayload>()
return await handleProviderMessagesForProvider(c, { payload, provider })
}
export async function handleProviderMessagesForProvider(
c: Context,
options: {
payload: AnthropicMessagesPayload
provider: string
},
): Promise<Response> {
const { payload, provider } = options
const providerConfig = getProviderConfig(provider)
if (!providerConfig) {
return c.json(
{
error: {
message: `Provider '${provider}' not found or disabled`,
type: "invalid_request_error",
},
},
404,
)
}
try {
const modelConfig = providerConfig.models?.[payload.model]
applyModelDefaults(payload, modelConfig)
debugJson(logger, "provider.messages.request", { payload, provider })
if (providerConfig.type === "openai-compatible") {
return await handleOpenAICompatibleProviderMessages(c, {
modelConfig,
payload,
provider,
providerConfig,
})
}
applyMissingExtraBody(payload as unknown as Record<string, unknown>, {
extraBody: modelConfig?.extraBody,
})
const upstreamResponse = await forwardProviderMessages(
providerConfig,
payload,
c.req.raw.headers,
)
if (!upstreamResponse.ok) {
logger.error("Failed to create responses", upstreamResponse)
throw new HTTPError("Failed to create responses", upstreamResponse)
}
const contentType = upstreamResponse.headers.get("content-type") ?? ""
const isStreamingResponse =
Boolean(payload.stream) && contentType.includes("text/event-stream")
if (isStreamingResponse) {
return streamProviderMessages({
c,
payload,
provider,
providerConfig,
upstreamResponse,
})
}
const jsonBody = (await upstreamResponse.json()) as AnthropicResponse
return respondProviderMessagesJson(c, {
body: jsonBody,
payload,
provider,
providerConfig,
})
} catch (error) {
logger.error("provider.messages.error", {
provider,
error,
})
throw error
}
}
const applyModelDefaults = (
payload: AnthropicMessagesPayload,
modelConfig: ModelConfig | undefined,
): void => {
payload.temperature ??= modelConfig?.temperature
payload.top_p ??= modelConfig?.topP
payload.top_k ??= modelConfig?.topK
}
const applyMissingExtraBody = (
payload: Record<string, unknown>,
options: { extraBody: Record<string, unknown> | undefined },
): void => {
for (const [key, value] of Object.entries(options.extraBody ?? {})) {
if (!Object.hasOwn(payload, key)) {
payload[key] = value
}
}
}
const getRequestThinkingBudget = (
payload: AnthropicMessagesPayload,
): number | undefined => {
const budget = payload.thinking?.budget_tokens
if (typeof budget !== "number" || !Number.isFinite(budget)) {
return undefined
}
return budget
}
const applyOpenAICompatibleThinkingBudget = (
payload: ChatCompletionsPayload,
source: AnthropicMessagesPayload,
): void => {
const thinkingBudget = getRequestThinkingBudget(source)
if (thinkingBudget !== undefined) {
payload.thinking_budget = thinkingBudget
return
}
if (payload.thinking_budget === undefined) {
delete payload.thinking_budget
}
}
const applyOpenAICompatibleExtraBodyThinkingBudget = (
payload: ChatCompletionsPayload,
options: { extraBody: Record<string, unknown> | undefined },
): void => {
const { extraBody } = options
if (!extraBody || !Object.hasOwn(extraBody, "thinking_budget")) {
return
}
const rawPayload = payload as Record<string, unknown>
rawPayload.thinking_budget = extraBody.thinking_budget
}
const handleOpenAICompatibleProviderMessages = async (
c: Context,
options: {
modelConfig: ModelConfig | undefined
payload: AnthropicMessagesPayload
provider: string
providerConfig: ResolvedProviderConfig
},
): Promise<Response> => {
const { modelConfig, payload, provider, providerConfig } = options
const openAIPayload = createOpenAICompatiblePayload(payload, modelConfig)
debugJson(logger, "provider.messages.openai_compatible.request", {
payload: openAIPayload,
provider,
})
const upstreamResponse = await forwardProviderChatCompletions(
providerConfig,
openAIPayload,
c.req.raw.headers,
)
if (!upstreamResponse.ok) {
logger.error(
"Failed to create openai-compatible responses",
upstreamResponse,
)
throw new HTTPError(
"Failed to create openai-compatible responses",
upstreamResponse,
)
}
const contentType = upstreamResponse.headers.get("content-type") ?? ""
const isStreamingResponse =
Boolean(openAIPayload.stream) && contentType.includes("text/event-stream")
if (isStreamingResponse) {
return streamOpenAICompatibleProviderMessages({
c,
payload,
provider,
upstreamResponse,
})
}
const jsonBody = (await upstreamResponse.json()) as ChatCompletionResponse
return respondOpenAICompatibleProviderMessagesJson(c, {
body: jsonBody,
payload,
provider,
})
}
const createOpenAICompatiblePayload = (
payload: AnthropicMessagesPayload,
modelConfig: ModelConfig | undefined,
): ChatCompletionsPayload => {
const openAIPayload = translateToOpenAI(payload, {
supportPdf: modelConfig?.supportPdf,
toolContentSupportType: modelConfig?.toolContentSupportType ?? [],
})
applyOpenAICompatibleThinkingBudget(openAIPayload, payload)
if (payload.top_k !== undefined) {
openAIPayload.top_k = payload.top_k
}
if (openAIPayload.stream) {
openAIPayload.stream_options = {
include_usage: true,
}
}
normalizeOpenAICompatibleReasoningContent(openAIPayload)
applyOpenAICompatibleRequestOverrides(openAIPayload, {
extraBody: modelConfig?.extraBody,
source: payload as unknown as Record<string, unknown>,
})
applyMissingExtraBody(openAIPayload, {
extraBody: modelConfig?.extraBody,
})
applyOpenAICompatibleExtraBodyThinkingBudget(openAIPayload, {
extraBody: modelConfig?.extraBody,
})
if (!Object.hasOwn(openAIPayload, "parallel_tool_calls")) {
openAIPayload.parallel_tool_calls = true
}
if (modelConfig?.contextCache !== false) {
applyOpenAICompatibleContextCache(openAIPayload)
}
return openAIPayload
}
const normalizeOpenAICompatibleReasoningContent = (
payload: ChatCompletionsPayload,
): void => {
for (const message of payload.messages) {
if (message.role !== "assistant") {
continue
}
if (
message.reasoning_content === undefined
&& message.reasoning_text !== undefined
) {
message.reasoning_content = message.reasoning_text
}
delete message.reasoning_text
delete message.reasoning_opaque
}
}
const applyOpenAICompatibleRequestOverrides = (
payload: ChatCompletionsPayload,
options: {
extraBody: Record<string, unknown> | undefined
source: Record<string, unknown>
},
): void => {
const allowedKeys = new Set(Object.keys(options.extraBody ?? {}))
for (const key of allowedKeys) {
if (Object.hasOwn(options.source, key)) {
payload[key] = options.source[key]
}
}
}
const applyOpenAICompatibleContextCache = (
payload: ChatCompletionsPayload,
): void => {
const messageIndexes = selectContextCacheMessageIndexes(payload.messages)
for (const messageIndex of messageIndexes) {
applyContextCacheControl(payload.messages[messageIndex])
}
}
const selectContextCacheMessageIndexes = (
messages: Array<Message>,
): Array<number> => {
const cacheableIndexes = messages.flatMap((message, index) =>
isContextCacheMarkerEligible(message) ? [index] : [],
)
const systemIndexes = cacheableIndexes
.filter((index) => messages[index]?.role === "system")
.slice(0, 2)
const finalIndexes = cacheableIndexes
.filter((index) => messages[index]?.role !== "system")
.slice(-2)
return uniqueIndexes([...systemIndexes, ...finalIndexes]).sort(
(a, b) => a - b,
)
}
const uniqueIndexes = (indexes: Array<number>): Array<number> =>
[...new Set(indexes)].slice(0, OPENAI_COMPATIBLE_CONTEXT_CACHE_MARKER_LIMIT)
const isContextCacheMarkerEligible = (message: Message): boolean => {
if (!OPENAI_COMPATIBLE_CONTEXT_CACHE_ROLES.has(message.role)) {
return false
}
if (typeof message.content === "string") {
return message.content.length > 0
}
return Array.isArray(message.content) && message.content.length > 0
}
const applyContextCacheControl = (message: Message | undefined): void => {
if (!message) {
return
}
if (typeof message.content === "string") {
message.content = [
{
type: "text",
text: message.content,
cache_control: { ...OPENAI_COMPATIBLE_CONTEXT_CACHE_CONTROL },
},
]
return
}
if (!Array.isArray(message.content)) {
return
}
const lastPart = message.content.at(-1)
if (!lastPart) {
return
}
setContextCacheControl(lastPart)
}
const setContextCacheControl = (part: ContentPart): void => {
part.cache_control = { ...OPENAI_COMPATIBLE_CONTEXT_CACHE_CONTROL }
}
const streamProviderMessages = ({
c,
payload,
provider,
providerConfig,
upstreamResponse,
}: {
c: Context
payload: AnthropicMessagesPayload
provider: string
providerConfig: ResolvedProviderConfig
upstreamResponse: Response
}): Response => {
logger.debug("provider.messages.streaming")
const recordUsage = createProviderMessagesUsageRecorder(payload, provider)
return streamSSE(c, async (stream) => {
let usage: UsageTokens = {}
for await (const chunk of events(upstreamResponse)) {
logger.debug("provider.messages.raw_stream_event:", chunk.data)
const eventName = chunk.event
if (eventName === "ping") {
await stream.writeSSE({ event: "ping", data: '{"type":"ping"}' })
continue
}
let data = chunk.data
if (!data) {
continue
}
if (chunk.data === "[DONE]") {
break
}
const parsed = parseProviderStreamEvent(data, providerConfig)
if (parsed) {
usage = mergeAnthropicUsage(usage, parsed.usage)
data = parsed.data
}
await stream.writeSSE({
event: eventName,
data,
})
}
recordUsage(usage)
})
}
const streamOpenAICompatibleProviderMessages = ({
c,
payload,
provider,
upstreamResponse,
}: {
c: Context
payload: AnthropicMessagesPayload
provider: string
upstreamResponse: Response
}): Response => {
logger.debug("provider.messages.openai_compatible.streaming")
const recordUsage = createProviderMessagesUsageRecorder(payload, provider)
return streamSSE(c, async (stream) => {
let usage: UsageTokens = {}
const streamState: AnthropicStreamState = {
messageStartSent: false,
contentBlockIndex: 0,
contentBlockOpen: false,
toolCalls: {},
thinkingBlockOpen: false,
}
for await (const chunk of events(upstreamResponse)) {
logger.debug(
"provider.messages.openai_compatible.raw_stream_event:",
chunk.data,
)
const eventName = chunk.event
if (eventName === "ping") {
await stream.writeSSE({ event: "ping", data: '{"type":"ping"}' })
continue
}
if (!chunk.data || chunk.data === "[DONE]") {
if (chunk.data === "[DONE]") {
break
}
continue
}
const parsed = parseOpenAICompatibleStreamChunk(chunk.data)
if (!parsed) {
continue
}
if (parsed.usage) {
usage = normalizeOpenAIUsage(parsed.usage)
}
const events = translateChunkToAnthropicEvents(parsed, streamState)
for (const event of events) {
const eventData = JSON.stringify(event)
debugLazy(logger, () => [
"provider.messages.openai_compatible.translated_event:",
eventData,
])
await stream.writeSSE({
event: event.type,
data: eventData,
})
}
}
for (const event of flushPendingAnthropicStreamEvents(streamState)) {
const eventData = JSON.stringify(event)
debugLazy(logger, () => [
"provider.messages.openai_compatible.translated_event:",
eventData,
])
await stream.writeSSE({
event: event.type,
data: eventData,
})
}
recordUsage(usage)
})
}
const parseOpenAICompatibleStreamChunk = (
data: string,
): ChatCompletionChunk | null => {
try {
return JSON.parse(data) as ChatCompletionChunk
} catch (error) {
logger.error("provider.messages.openai_compatible.parse_chunk_error", {
data,
error,
})
return null
}
}
const parseProviderStreamEvent = (
data: string,
providerConfig: ResolvedProviderConfig,
): { data: string; model?: string; usage: UsageTokens } | null => {
try {
const parsed = JSON.parse(data) as AnthropicStreamEventData
if (parsed.type === "message_start") {
adjustInputTokens(providerConfig, parsed.message.usage)
return {
data: JSON.stringify(parsed),
model: parsed.message.model,
usage: normalizeAnthropicUsage(parsed.message.usage),
}
}
if (parsed.type === "message_delta") {
adjustInputTokens(providerConfig, parsed.usage)
return {
data: JSON.stringify(parsed),
usage: normalizeAnthropicUsage(parsed.usage),
}
}
return { data: JSON.stringify(parsed), usage: {} }
} catch (error) {
logger.error("provider.messages.streaming.adjust_tokens_error", {
error,
originalData: data,
})
return null
}
}
const respondProviderMessagesJson = (
c: Context,
options: {
body: AnthropicResponse
payload: AnthropicMessagesPayload
provider: string
providerConfig: ResolvedProviderConfig
},
): Response => {
const { body, payload, provider, providerConfig } = options
const recordUsage = createProviderMessagesUsageRecorder(payload, provider)
adjustInputTokens(providerConfig, body.usage)
recordUsage(normalizeAnthropicUsage(body.usage))
debugJson(logger, "provider.messages.no_stream result:", body)
return c.json(body)
}
const respondOpenAICompatibleProviderMessagesJson = (
c: Context,
options: {
body: ChatCompletionResponse
payload: AnthropicMessagesPayload
provider: string
},
): Response => {
const { body, payload, provider } = options
const recordUsage = createProviderMessagesUsageRecorder(payload, provider)
recordUsage(normalizeOpenAIUsage(body.usage))
const anthropicResponse = translateToAnthropic(body)
debugJson(
logger,
"provider.messages.openai_compatible.no_stream result:",
anthropicResponse,
)
return c.json(anthropicResponse)
}
const createProviderMessagesUsageRecorder = (
payload: AnthropicMessagesPayload,
provider: string,
) =>
createProviderTokenUsageRecorder({
endpoint: "provider_messages",
model: payload.model,
providerName: provider,
sessionId: parseUserIdMetadata(payload.metadata?.user_id).sessionId,
})
const adjustInputTokens = (
providerConfig: ResolvedProviderConfig,
usage?: {
input_tokens?: number
cache_read_input_tokens?: number
cache_creation_input_tokens?: number
},
): void => {
if (!providerConfig.adjustInputTokens || !usage) {
return
}
const adjustedInput = Math.max(
0,
(usage.input_tokens ?? 0)
- (usage.cache_read_input_tokens ?? 0)
- (usage.cache_creation_input_tokens ?? 0),
)
usage.input_tokens = adjustedInput
debugJson(logger, "provider.messages.adjusted_usage:", usage)
}