From ee3fcd3ee039d73ca41d93fd9afccf8235e12363 Mon Sep 17 00:00:00 2001 From: kyua805 Date: Mon, 30 Mar 2026 20:29:18 +0800 Subject: [PATCH] feat: add OpenAI Responses API endpoint support Add /v1/responses and /responses endpoints that forward requests directly to GitHub Copilot's responses API. This enables support for responses-only models like gpt-5.4 and gpt-5.3-codex. --- bun.lock | 1 + src/routes/responses/handler.ts | 81 ++++++++++++++++++++++++ src/routes/responses/route.ts | 15 +++++ src/server.ts | 5 ++ src/services/copilot/create-responses.ts | 75 ++++++++++++++++++++++ 5 files changed, 177 insertions(+) create mode 100644 src/routes/responses/handler.ts create mode 100644 src/routes/responses/route.ts create mode 100644 src/services/copilot/create-responses.ts diff --git a/bun.lock b/bun.lock index 20e895e7f..9ece87578 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "copilot-api", diff --git a/src/routes/responses/handler.ts b/src/routes/responses/handler.ts new file mode 100644 index 000000000..5670b4ae6 --- /dev/null +++ b/src/routes/responses/handler.ts @@ -0,0 +1,81 @@ +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 ResponsesPayload, +} from "~/services/copilot/create-responses" + +export async function handleResponses(c: Context) { + await checkRateLimit(state) + + const payload = await c.req.json() + consola.debug( + "Responses API request payload:", + JSON.stringify(payload).slice(-400), + ) + + // Set default max_output_tokens from model capabilities + const selectedModel = state.models?.data.find( + (model) => model.id === payload.model, + ) + + if (state.manualApprove) await awaitApproval() + + if (isNullish(payload.max_output_tokens)) { + payload.max_output_tokens = + selectedModel?.capabilities.limits.max_output_tokens + consola.debug("Set max_output_tokens to:", payload.max_output_tokens) + } + + const response = await createResponses(payload) + + // Non-streaming: response is a JSON object with "object" field + if (isNonStreaming(response)) { + consola.debug( + "Non-streaming responses result:", + JSON.stringify(response).slice(-400), + ) + return c.json(response) + } + + // Streaming: forward SSE events directly from Copilot + consola.debug("Streaming responses result") + return streamSSE(c, async (stream) => { + for await (const rawEvent of response) { + consola.debug("Responses stream event:", JSON.stringify(rawEvent)) + + if (rawEvent.data === "[DONE]") { + break + } + + if (!rawEvent.data) { + continue + } + + // Extract event type from the data for the SSE event field + try { + const parsed = JSON.parse(rawEvent.data) as { type?: string } + await stream.writeSSE({ + event: parsed.type ?? rawEvent.event ?? "message", + data: rawEvent.data, + }) + } catch { + await stream.writeSSE({ + event: rawEvent.event ?? "message", + data: rawEvent.data, + }) + } + } + }) +} + +const isNonStreaming = ( + response: Awaited>, +): response is Record => Object.hasOwn(response, "object") diff --git a/src/routes/responses/route.ts b/src/routes/responses/route.ts new file mode 100644 index 000000000..af2423427 --- /dev/null +++ b/src/routes/responses/route.ts @@ -0,0 +1,15 @@ +import { Hono } from "hono" + +import { forwardError } from "~/lib/error" + +import { handleResponses } from "./handler" + +export const responsesRoutes = new Hono() + +responsesRoutes.post("/", async (c) => { + try { + return await handleResponses(c) + } catch (error) { + return await forwardError(c, error) + } +}) diff --git a/src/server.ts b/src/server.ts index 462a278f3..5305a4e5b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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" @@ -27,5 +28,9 @@ server.route("/v1/chat/completions", completionRoutes) server.route("/v1/models", modelRoutes) server.route("/v1/embeddings", embeddingRoutes) +// OpenAI Responses API endpoints +server.route("/v1/responses", responsesRoutes) +server.route("/responses", responsesRoutes) + // Anthropic compatible endpoints server.route("/v1/messages", messageRoutes) diff --git a/src/services/copilot/create-responses.ts b/src/services/copilot/create-responses.ts new file mode 100644 index 000000000..38418ef46 --- /dev/null +++ b/src/services/copilot/create-responses.ts @@ -0,0 +1,75 @@ +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 interface ResponsesPayload { + model: string + input: string | Array + instructions?: string | null + stream?: boolean | null + temperature?: number | null + top_p?: number | null + max_output_tokens?: number | null + [key: string]: unknown +} + +type ResponseInputItem = { + type?: string + role?: string + content?: string | Array<{ type: string; [key: string]: unknown }> + [key: string]: unknown +} + +export const createResponses = async (payload: ResponsesPayload) => { + if (!state.copilotToken) throw new Error("Copilot token not found") + + const enableVision = hasVisionContent(payload) + const isAgentCall = hasAgentMessages(payload) + + const headers: Record = { + ...copilotHeaders(state, enableVision), + "X-Initiator": isAgentCall ? "agent" : "user", + } + + const response = await fetch(`${copilotBaseUrl(state)}/responses`, { + method: "POST", + headers, + body: JSON.stringify(payload), + }) + + if (!response.ok) { + consola.error("Failed to create responses", response) + throw new HTTPError("Failed to create responses", response) + } + + if (payload.stream) { + return events(response) + } + + return (await response.json()) as Record +} + +function hasVisionContent(payload: ResponsesPayload): boolean { + if (typeof payload.input === "string") return false + + return payload.input.some((item) => { + if (Array.isArray(item.content)) { + return item.content.some((part) => part.type === "input_image") + } + return false + }) +} + +function hasAgentMessages(payload: ResponsesPayload): boolean { + if (typeof payload.input === "string") return false + + return payload.input.some((item) => { + if (item.role === "assistant") return true + if (item.type === "function_call_output") return true + if (item.type === "function_call") return true + return false + }) +}