Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
291 changes: 181 additions & 110 deletions bun.lock

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,11 @@
"consola": "^3.4.2",
"fetch-event-stream": "^0.1.5",
"gpt-tokenizer": "^3.0.1",
"hono": "^4.9.9",
"hono": "^4.12.32",
"proxy-from-env": "^1.1.0",
"srvx": "^0.8.9",
"srvx": "^0.11.22",
"tiny-invariant": "^1.3.3",
"undici": "^7.16.0",
"undici": "^7.29.0",
"zod": "^4.1.11"
},
"devDependencies": {
Expand Down
76 changes: 71 additions & 5 deletions src/routes/messages/anthropic-types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// Anthropic API Types

import type { ChatCompletionChunk } from "~/services/copilot/create-chat-completions"

export interface AnthropicMessagesPayload {
model: string
messages: Array<AnthropicMessage>
Expand All @@ -19,9 +21,12 @@ export interface AnthropicMessagesPayload {
name?: string
}
thinking?: {
type: "enabled"
type: "adaptive" | "enabled"
budget_tokens?: number
}
output_config?: {
effort?: "none" | "low" | "medium" | "high" | "xhigh" | "max"
}
service_tier?: "auto" | "standard_only"
}

Expand All @@ -42,7 +47,7 @@ export interface AnthropicImageBlock {
export interface AnthropicToolResultBlock {
type: "tool_result"
tool_use_id: string
content: string
content: string | Array<AnthropicTextBlock | AnthropicImageBlock>
is_error?: boolean
}

Expand All @@ -56,6 +61,30 @@ export interface AnthropicToolUseBlock {
export interface AnthropicThinkingBlock {
type: "thinking"
thinking: string
signature?: string
}

export interface AnthropicServerToolUseBlock {
type: "server_tool_use"
id: string
name: "web_search"
input: {
query: string
}
}

export interface AnthropicWebSearchResult {
type: "web_search_result"
url: string
title: string
page_age: string | null
encrypted_content: string
}

export interface AnthropicWebSearchToolResultBlock {
type: "web_search_tool_result"
tool_use_id: string
content: Array<AnthropicWebSearchResult>
}

export type AnthropicUserContentBlock =
Expand All @@ -67,6 +96,8 @@ export type AnthropicAssistantContentBlock =
| AnthropicTextBlock
| AnthropicToolUseBlock
| AnthropicThinkingBlock
| AnthropicServerToolUseBlock
| AnthropicWebSearchToolResultBlock

export interface AnthropicUserMessage {
role: "user"
Expand All @@ -80,12 +111,30 @@ export interface AnthropicAssistantMessage {

export type AnthropicMessage = AnthropicUserMessage | AnthropicAssistantMessage

export interface AnthropicTool {
export interface AnthropicClientTool {
name: string
description?: string
input_schema: Record<string, unknown>
}

export interface AnthropicWebSearchTool {
type: `web_search_${string}`
name: "web_search"
max_uses?: number
allowed_domains?: Array<string>
blocked_domains?: Array<string>
user_location?: {
type: "approximate"
city?: string
region?: string
country?: string
timezone?: string
}
allowed_callers?: Array<"direct" | "code_execution_20250825">
}

export type AnthropicTool = AnthropicClientTool | AnthropicWebSearchTool

export interface AnthropicResponse {
id: string
type: "message"
Expand All @@ -106,6 +155,9 @@ export interface AnthropicResponse {
output_tokens: number
cache_creation_input_tokens?: number
cache_read_input_tokens?: number
server_tool_use?: {
web_search_requests?: number
}
service_tier?: "standard" | "priority" | "batch"
}
}
Expand Down Expand Up @@ -134,6 +186,10 @@ export interface AnthropicContentBlockStartEvent {
input: Record<string, unknown>
})
| { type: "thinking"; thinking: string }
| (Omit<AnthropicServerToolUseBlock, "input"> & {
input: Record<string, unknown>
})
| AnthropicWebSearchToolResultBlock
}

export interface AnthropicContentBlockDeltaEvent {
Expand Down Expand Up @@ -162,6 +218,9 @@ export interface AnthropicMessageDeltaEvent {
output_tokens: number
cache_creation_input_tokens?: number
cache_read_input_tokens?: number
server_tool_use?: {
web_search_requests?: number
}
}
}

Expand Down Expand Up @@ -194,8 +253,15 @@ export type AnthropicStreamEventData =
// State for streaming translation
export interface AnthropicStreamState {
messageStartSent: boolean
contentBlockIndex: number
contentBlockOpen: boolean
messageCompleted: boolean
nextBlockIndex: number
// Blocks that have had content_block_start but not content_block_stop.
openBlockIndices: Set<number>
// Open text block receiving content deltas, if any.
textBlockIndex?: number
stopReason?: "stop" | "length" | "tool_calls" | "content_filter"
// Latest usage seen on any chunk, including trailing usage-only chunks.
usage?: ChatCompletionChunk["usage"]
toolCalls: {
[openAIToolIndex: number]: {
id: string
Expand Down
16 changes: 16 additions & 0 deletions src/routes/messages/endpoint-selection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export type CopilotMessagesEndpoint =
| "/v1/messages"
| "/responses"
| "/chat/completions"

export function selectCopilotMessagesEndpoint(
supportedEndpoints: Array<string> | undefined,
): CopilotMessagesEndpoint | null {
if (!supportedEndpoints) return "/chat/completions"
if (supportedEndpoints.includes("/v1/messages")) return "/v1/messages"
if (supportedEndpoints.includes("/responses")) return "/responses"
if (supportedEndpoints.includes("/chat/completions")) {
return "/chat/completions"
}
return null
}
143 changes: 128 additions & 15 deletions src/routes/messages/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,38 +6,89 @@ import { streamSSE } from "hono/streaming"
import { awaitApproval } from "~/lib/approval"
import { checkRateLimit } from "~/lib/rate-limit"
import { state } from "~/lib/state"
import { createAnthropicMessages } from "~/services/copilot/create-anthropic-messages"
import {
createChatCompletions,
type ChatCompletionChunk,
type ChatCompletionResponse,
} from "~/services/copilot/create-chat-completions"

import {
type AnthropicMessagesPayload,
type AnthropicStreamState,
} from "./anthropic-types"
createResponses,
type ResponsesResult,
} from "~/services/copilot/create-responses"

import { type AnthropicMessagesPayload } from "./anthropic-types"
import { selectCopilotMessagesEndpoint } from "./endpoint-selection"
import {
translateToAnthropic,
translateToOpenAI,
} from "./non-stream-translation"
import { translateChunkToAnthropicEvents } from "./stream-translation"
import {
createResponsesStreamState,
translateResponsesStreamEvent,
} from "./responses-stream-translation"
import {
translateAnthropicToResponses,
translateResponsesToAnthropic,
} from "./responses-translation"
import {
createAnthropicStreamState,
finalizeAnthropicStream,
translateChunkToAnthropicEvents,
} from "./stream-translation"

export async function handleCompletion(c: Context) {
await checkRateLimit(state)

const anthropicPayload = await c.req.json<AnthropicMessagesPayload>()
consola.debug("Anthropic request payload:", JSON.stringify(anthropicPayload))

const openAIPayload = translateToOpenAI(anthropicPayload)
consola.debug(
"Translated OpenAI request payload:",
JSON.stringify(openAIPayload),
const selectedModel = state.models?.data.find(
(model) => model.id === anthropicPayload.model,
)
const endpoint = selectCopilotMessagesEndpoint(
selectedModel?.supported_endpoints,
)
if (!endpoint) {
return c.json(
{
error: {
message: `Model "${anthropicPayload.model}" does not support the Copilot Messages, Responses, or Chat Completions endpoints`,
type: "invalid_request_error",
},
},
400,
)
}

if (state.manualApprove) {
await awaitApproval()
}

if (endpoint === "/v1/messages") {
const forwardedHeaders: Record<string, string> = {}
for (const [name, value] of c.req.raw.headers) {
if (name.toLowerCase().startsWith("anthropic-")) {
forwardedHeaders[name] = value
}
}
return await createAnthropicMessages(
anthropicPayload,
forwardedHeaders,
new URL(c.req.url).search,
)
}

if (endpoint === "/responses") {
return await handleResponses(c, anthropicPayload)
}

const openAIPayload = translateToOpenAI(anthropicPayload)
consola.debug(
"Translated OpenAI request payload:",
JSON.stringify(openAIPayload),
)

const response = await createChatCompletions(openAIPayload)

if (isNonStreaming(response)) {
Expand All @@ -55,12 +106,7 @@ export async function handleCompletion(c: Context) {

consola.debug("Streaming response from Copilot")
return streamSSE(c, async (stream) => {
const streamState: AnthropicStreamState = {
messageStartSent: false,
contentBlockIndex: 0,
contentBlockOpen: false,
toolCalls: {},
}
const streamState = createAnthropicStreamState()

for await (const rawEvent of response) {
consola.debug("Copilot raw stream event:", JSON.stringify(rawEvent))
Expand All @@ -83,9 +129,76 @@ export async function handleCompletion(c: Context) {
})
}
}

for (const event of finalizeAnthropicStream(streamState)) {
consola.debug("Translated Anthropic event:", JSON.stringify(event))
await stream.writeSSE({
event: event.type,
data: JSON.stringify(event),
})
}
})
}

async function handleResponses(
c: Context,
anthropicPayload: AnthropicMessagesPayload,
) {
const responsesPayload = translateAnthropicToResponses(anthropicPayload)
consola.debug(
"Translated Responses request payload:",
JSON.stringify(responsesPayload),
)
const response = await createResponses(
responsesPayload,
isAgentRequest(anthropicPayload) ? "agent" : "user",
)

if (isResponsesResult(response)) {
return c.json(translateResponsesToAnthropic(response))
}

return streamSSE(c, async (stream) => {
const streamState = createResponsesStreamState()
for await (const rawEvent of response) {
if (rawEvent.data === "[DONE]") break
if (!rawEvent.data) continue

const event: unknown = JSON.parse(rawEvent.data)
if (typeof event !== "object" || event === null) continue

for (const translated of translateResponsesStreamEvent(
event as Record<string, unknown>,
streamState,
)) {
consola.debug(
"Translated Anthropic Responses event:",
JSON.stringify(translated),
)
await stream.writeSSE({
event: translated.type,
data: JSON.stringify(translated),
})
}
}
})
}

function isAgentRequest(payload: AnthropicMessagesPayload): boolean {
return payload.messages.some(
(message) =>
message.role === "assistant"
|| (Array.isArray(message.content)
&& message.content.some((block) => block.type === "tool_result")),
)
}

function isResponsesResult(
response: Awaited<ReturnType<typeof createResponses>>,
): response is ResponsesResult {
return Object.hasOwn(response, "output")
}

const isNonStreaming = (
response: Awaited<ReturnType<typeof createChatCompletions>>,
): response is ChatCompletionResponse => Object.hasOwn(response, "choices")
Loading