Skip to content
Merged
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
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

61 changes: 61 additions & 0 deletions src/routes/responses/handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { Context } from "hono"

import consola from "consola"
import { streamSSE } from "hono/streaming"

import { awaitApproval } from "~/lib/approval"
import { checkRateLimit } from "~/lib/rate-limit"
import { state } from "~/lib/state"
import { isNullish } from "~/lib/utils"
import {
createResponses,
type ResponseObject,
type ResponsesPayload,
} from "~/services/copilot/create-responses"

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

let payload = await c.req.json<ResponsesPayload>()
consola.debug("Request payload:", JSON.stringify(payload).slice(-400))
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verbose debug logging prints portions of the raw request payload, which can include user prompts, tool arguments, or image/file URLs. Consider redacting sensitive fields (e.g., input, instructions, metadata) or logging only high-level metadata (model, stream flag, token limits) to avoid accidental disclosure when --verbose is enabled.

Suggested change
consola.debug("Request payload:", JSON.stringify(payload).slice(-400))
consola.debug("Request metadata:", {
model: payload.model,
stream: payload.stream,
max_output_tokens: payload.max_output_tokens,
})

Copilot uses AI. Check for mistakes.

const selectedModel = state.models?.data.find(
(model) => model.id === payload.model,
)

if (isNullish(payload.max_output_tokens)) {
payload = {
...payload,
max_output_tokens: selectedModel?.capabilities.limits.max_output_tokens,
}
consola.debug(
"Set max_output_tokens to:",
JSON.stringify(payload.max_output_tokens),
)
}

if (state.manualApprove) await awaitApproval()

const response = await createResponses(payload)

if (isNonStreaming(response)) {
consola.debug("Non-streaming response:", JSON.stringify(response))
return c.json(response)
}

consola.debug("Streaming response")
return streamSSE(c, async (stream) => {
for await (const chunk of response) {
consola.debug("Streaming chunk:", JSON.stringify(chunk))
if (!chunk.data) continue
await stream.writeSSE({
event: chunk.event,
data: chunk.data,
})
}
Comment on lines +48 to +55
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The streaming path logs every SSE chunk (consola.debug("Streaming chunk"...)). For long responses this can generate very large logs and add significant overhead in verbose mode. Consider sampling, truncating, or logging only event types/byte sizes instead of the full chunk payload.

Copilot uses AI. Check for mistakes.
})
}

const isNonStreaming = (
response: Awaited<ReturnType<typeof createResponses>>,
): response is ResponseObject => Object.hasOwn(response, "output")
15 changes: 15 additions & 0 deletions src/routes/responses/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Hono } from "hono"

import { forwardError } from "~/lib/error"

import { handleResponse } from "./handler"

export const responsesRoutes = new Hono()

responsesRoutes.post("/", async (c) => {
try {
return await handleResponse(c)
} catch (error) {
return await forwardError(c, error)
}
})
3 changes: 3 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { completionRoutes } from "./routes/chat-completions/route"
import { embeddingRoutes } from "./routes/embeddings/route"
import { messageRoutes } from "./routes/messages/route"
import { modelRoutes } from "./routes/models/route"
import { responsesRoutes } from "./routes/responses/route"
import { tokenRoute } from "./routes/token/route"
import { usageRoute } from "./routes/usage/route"

Expand All @@ -19,13 +20,15 @@ server.get("/", (c) => c.text("Server running"))
server.route("/chat/completions", completionRoutes)
server.route("/models", modelRoutes)
server.route("/embeddings", embeddingRoutes)
server.route("/responses", responsesRoutes)
server.route("/usage", usageRoute)
server.route("/token", tokenRoute)

// Compatibility with tools that expect v1/ prefix
server.route("/v1/chat/completions", completionRoutes)
server.route("/v1/models", modelRoutes)
server.route("/v1/embeddings", embeddingRoutes)
server.route("/v1/responses", responsesRoutes)

// Anthropic compatible endpoints
server.route("/v1/messages", messageRoutes)
166 changes: 166 additions & 0 deletions src/services/copilot/create-responses.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import consola from "consola"
import { events } from "fetch-event-stream"

import { copilotHeaders, copilotBaseUrl } from "~/lib/api-config"
import { HTTPError } from "~/lib/error"
import { state } from "~/lib/state"

export const createResponses = async (payload: ResponsesPayload) => {
if (!state.copilotToken) throw new Error("Copilot token not found")

const enableVision =
Array.isArray(payload.input)
&& payload.input.some(
(x) =>
Array.isArray(x.content)
&& x.content.some((part) => part.type === "input_image"),
)

const isAgentCall =
Array.isArray(payload.input)
&& payload.input.some((msg) => ["assistant", "tool"].includes(msg.role))

const headers: Record<string, string> = {
...copilotHeaders(state, enableVision),
"X-Initiator": isAgentCall ? "agent" : "user",
}

Comment on lines +8 to +27
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new /responses service adds logic for X-Initiator and vision header selection, but there are no tests covering these behaviors (unlike create-chat-completions, which has tests/create-chat-completions.test.ts). Add unit tests for createResponses to verify X-Initiator switches to agent when input contains assistant/tool, and that copilot-vision-request is set when input includes an input_image part.

Suggested change
export const createResponses = async (payload: ResponsesPayload) => {
if (!state.copilotToken) throw new Error("Copilot token not found")
const enableVision =
Array.isArray(payload.input)
&& payload.input.some(
(x) =>
Array.isArray(x.content)
&& x.content.some((part) => part.type === "input_image"),
)
const isAgentCall =
Array.isArray(payload.input)
&& payload.input.some((msg) => ["assistant", "tool"].includes(msg.role))
const headers: Record<string, string> = {
...copilotHeaders(state, enableVision),
"X-Initiator": isAgentCall ? "agent" : "user",
}
export const responsesPayloadHasImageInput = (payload: ResponsesPayload) =>
Array.isArray(payload.input)
&& payload.input.some(
(message) =>
Array.isArray(message.content)
&& message.content.some((part) => part.type === "input_image"),
)
export const responsesPayloadIsAgentCall = (payload: ResponsesPayload) =>
Array.isArray(payload.input)
&& payload.input.some((message) => ["assistant", "tool"].includes(message.role))
export const createResponsesHeaders = (payload: ResponsesPayload) => {
const enableVision = responsesPayloadHasImageInput(payload)
const isAgentCall = responsesPayloadIsAgentCall(payload)
return {
...copilotHeaders(state, enableVision),
"X-Initiator": isAgentCall ? "agent" : "user",
}
}
export const createResponses = async (payload: ResponsesPayload) => {
if (!state.copilotToken) throw new Error("Copilot token not found")
const headers = createResponsesHeaders(payload)

Copilot uses AI. Check for mistakes.
const response = await fetch(`${copilotBaseUrl(state)}/responses`, {
method: "POST",
headers,
body: JSON.stringify(payload),
})

if (!response.ok) {
consola.error("Failed to create response", response)
throw new HTTPError("Failed to create response", response)
}

if (payload.stream) {
return events(response)
}

return (await response.json()) as ResponseObject
}

// Payload types

export interface ResponsesPayload {
model: string
input: string | Array<InputMessage>
stream?: boolean | null
temperature?: number | null
top_p?: number | null
max_output_tokens?: number | null
tools?: Array<ResponseTool> | null
tool_choice?:
| "auto"
| "none"
| "required"
| { type: "function"; name: string }
| null
previous_response_id?: string | null
instructions?: string | null
reasoning?: { effort: "low" | "medium" | "high" } | null
metadata?: Record<string, string> | null
user?: string | null
}

export interface InputMessage {
role: "user" | "assistant" | "system" | "developer" | "tool"
content: string | Array<InputContentPart>
name?: string
tool_call_id?: string
}

export type InputContentPart = InputTextPart | InputImagePart | InputFilePart

export interface InputTextPart {
type: "input_text"
text: string
}

export interface InputImagePart {
type: "input_image"
image_url?: { url: string; detail?: "low" | "high" | "auto" }
file_id?: string
}

export interface InputFilePart {
type: "input_file"
file_id?: string
file_url?: string
filename?: string
}

export interface ResponseTool {
type: "function"
name: string
description?: string
parameters?: Record<string, unknown>
strict?: boolean | null
}

// Response types (non-streaming)

export interface ResponseObject {
id: string
object: "response"
created_at: number
model: string
output: Array<OutputItem>
status: "completed" | "incomplete" | "failed" | "cancelled"
usage?: ResponseUsage
instructions?: string | null
error?: ResponseError | null
metadata?: Record<string, string> | null
}

export type OutputItem = MessageOutputItem | FunctionCallOutputItem

export interface MessageOutputItem {
type: "message"
id: string
role: "assistant"
content: Array<OutputContentPart>
status: "completed" | "incomplete"
}

export type OutputContentPart = OutputTextPart | RefusalPart

export interface OutputTextPart {
type: "output_text"
text: string
annotations?: Array<unknown>
}

export interface RefusalPart {
type: "refusal"
refusal: string
}

export interface FunctionCallOutputItem {
type: "function_call"
id: string
call_id: string
name: string
arguments: string
status: "completed"
}

export interface ResponseUsage {
input_tokens: number
output_tokens: number
total_tokens: number
input_tokens_details?: {
cached_tokens: number
}
output_tokens_details?: {
reasoning_tokens: number
}
}

export interface ResponseError {
code: string
message: string
}