diff --git a/src/routes/responses/handler.ts b/src/routes/responses/handler.ts new file mode 100644 index 000000000..7a12c0a7c --- /dev/null +++ b/src/routes/responses/handler.ts @@ -0,0 +1,86 @@ +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 { createResponse } from "~/services/copilot/create-response" + +// Copilot only supports "function" tools — strip built-in OpenAI tools +// like web_search, file_search, code_interpreter, computer_use_preview, etc. +function sanitizePayload( + payload: Record, +): Record { + if (!Array.isArray(payload.tools)) return payload + + const supportedTools = (payload.tools as Array<{ type?: string }>).filter( + (tool) => tool.type === "function", + ) + + const stripped = payload.tools.length - supportedTools.length + if (stripped > 0) { + consola.debug(`Stripped ${stripped} unsupported tool(s) from request`) + } + + return { + ...payload, + tools: supportedTools.length > 0 ? supportedTools : undefined, + } +} + +export async function handleResponses(c: Context) { + await checkRateLimit(state) + + const rawPayload = await c.req.json>() + consola.debug( + "Responses API request payload:", + JSON.stringify(rawPayload).slice(-400), + ) + + const payload = sanitizePayload(rawPayload) + + if (state.manualApprove) { + await awaitApproval() + } + + const response = await createResponse(payload) + + // Non-streaming: forward the JSON response directly + if (response instanceof Response) { + const body = (await response.json()) as Record + consola.debug( + "Non-streaming response from Copilot /responses:", + JSON.stringify(body).slice(-400), + ) + return c.json(body) + } + + // Streaming: forward SSE events directly + consola.debug("Streaming response from Copilot /responses") + return streamSSE(c, async (stream) => { + for await (const rawEvent of response) { + if (rawEvent.data === "[DONE]") { + break + } + + if (!rawEvent.data) { + continue + } + + consola.debug( + "Copilot /responses stream event:", + rawEvent.data.slice(-300), + ) + + const parsed = JSON.parse(rawEvent.data) as { type?: string } + const eventType = parsed.type ?? "unknown" + + await stream.writeSSE({ + event: eventType, + data: rawEvent.data, + }) + } + }) +} 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..24478cd40 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" @@ -29,3 +30,7 @@ server.route("/v1/embeddings", embeddingRoutes) // Anthropic compatible endpoints server.route("/v1/messages", messageRoutes) + +// OpenAI Responses API compatible endpoints +server.route("/responses", responsesRoutes) +server.route("/v1/responses", responsesRoutes) diff --git a/src/services/copilot/create-response.ts b/src/services/copilot/create-response.ts new file mode 100644 index 000000000..3594bdb9b --- /dev/null +++ b/src/services/copilot/create-response.ts @@ -0,0 +1,39 @@ +import consola from "consola" +import { events } from "fetch-event-stream" + +import { copilotBaseUrl, copilotHeaders } from "~/lib/api-config" +import { HTTPError } from "~/lib/error" +import { state } from "~/lib/state" + +export const createResponse = async ( + payload: Record, +): Promise> => { + if (!state.copilotToken) throw new Error("Copilot token not found") + + const headers: Record = { + ...copilotHeaders(state), + "X-Initiator": "user", + } + + consola.debug( + "Forwarding to Copilot /responses:", + JSON.stringify(payload).slice(-400), + ) + + 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 response +}