Skip to content

Commit ffd19ff

Browse files
authored
feat: add OpenAI Responses API endpoint (/responses and /v1/responses)
1 parent 0ea08fe commit ffd19ff

5 files changed

Lines changed: 246 additions & 0 deletions

File tree

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/routes/responses/handler.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import type { Context } from "hono"
2+
3+
import consola from "consola"
4+
import { streamSSE } from "hono/streaming"
5+
6+
import { awaitApproval } from "~/lib/approval"
7+
import { checkRateLimit } from "~/lib/rate-limit"
8+
import { state } from "~/lib/state"
9+
import { isNullish } from "~/lib/utils"
10+
import {
11+
createResponses,
12+
type ResponseObject,
13+
type ResponsesPayload,
14+
} from "~/services/copilot/create-responses"
15+
16+
export async function handleResponse(c: Context) {
17+
await checkRateLimit(state)
18+
19+
let payload = await c.req.json<ResponsesPayload>()
20+
consola.debug("Request payload:", JSON.stringify(payload).slice(-400))
21+
22+
const selectedModel = state.models?.data.find(
23+
(model) => model.id === payload.model,
24+
)
25+
26+
if (isNullish(payload.max_output_tokens)) {
27+
payload = {
28+
...payload,
29+
max_output_tokens: selectedModel?.capabilities.limits.max_output_tokens,
30+
}
31+
consola.debug(
32+
"Set max_output_tokens to:",
33+
JSON.stringify(payload.max_output_tokens),
34+
)
35+
}
36+
37+
if (state.manualApprove) await awaitApproval()
38+
39+
const response = await createResponses(payload)
40+
41+
if (isNonStreaming(response)) {
42+
consola.debug("Non-streaming response:", JSON.stringify(response))
43+
return c.json(response)
44+
}
45+
46+
consola.debug("Streaming response")
47+
return streamSSE(c, async (stream) => {
48+
for await (const chunk of response) {
49+
consola.debug("Streaming chunk:", JSON.stringify(chunk))
50+
if (!chunk.data) continue
51+
await stream.writeSSE({
52+
event: chunk.event,
53+
data: chunk.data,
54+
})
55+
}
56+
})
57+
}
58+
59+
const isNonStreaming = (
60+
response: Awaited<ReturnType<typeof createResponses>>,
61+
): response is ResponseObject => Object.hasOwn(response, "output")

src/routes/responses/route.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Hono } from "hono"
2+
3+
import { forwardError } from "~/lib/error"
4+
5+
import { handleResponse } from "./handler"
6+
7+
export const responsesRoutes = new Hono()
8+
9+
responsesRoutes.post("/", async (c) => {
10+
try {
11+
return await handleResponse(c)
12+
} catch (error) {
13+
return await forwardError(c, error)
14+
}
15+
})

src/server.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { completionRoutes } from "./routes/chat-completions/route"
66
import { embeddingRoutes } from "./routes/embeddings/route"
77
import { messageRoutes } from "./routes/messages/route"
88
import { modelRoutes } from "./routes/models/route"
9+
import { responsesRoutes } from "./routes/responses/route"
910
import { tokenRoute } from "./routes/token/route"
1011
import { usageRoute } from "./routes/usage/route"
1112

@@ -19,13 +20,15 @@ server.get("/", (c) => c.text("Server running"))
1920
server.route("/chat/completions", completionRoutes)
2021
server.route("/models", modelRoutes)
2122
server.route("/embeddings", embeddingRoutes)
23+
server.route("/responses", responsesRoutes)
2224
server.route("/usage", usageRoute)
2325
server.route("/token", tokenRoute)
2426

2527
// Compatibility with tools that expect v1/ prefix
2628
server.route("/v1/chat/completions", completionRoutes)
2729
server.route("/v1/models", modelRoutes)
2830
server.route("/v1/embeddings", embeddingRoutes)
31+
server.route("/v1/responses", responsesRoutes)
2932

3033
// Anthropic compatible endpoints
3134
server.route("/v1/messages", messageRoutes)
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import consola from "consola"
2+
import { events } from "fetch-event-stream"
3+
4+
import { copilotHeaders, copilotBaseUrl } from "~/lib/api-config"
5+
import { HTTPError } from "~/lib/error"
6+
import { state } from "~/lib/state"
7+
8+
export const createResponses = async (payload: ResponsesPayload) => {
9+
if (!state.copilotToken) throw new Error("Copilot token not found")
10+
11+
const enableVision =
12+
Array.isArray(payload.input)
13+
&& payload.input.some(
14+
(x) =>
15+
Array.isArray(x.content)
16+
&& x.content.some((part) => part.type === "input_image"),
17+
)
18+
19+
const isAgentCall =
20+
Array.isArray(payload.input)
21+
&& payload.input.some((msg) => ["assistant", "tool"].includes(msg.role))
22+
23+
const headers: Record<string, string> = {
24+
...copilotHeaders(state, enableVision),
25+
"X-Initiator": isAgentCall ? "agent" : "user",
26+
}
27+
28+
const response = await fetch(`${copilotBaseUrl(state)}/responses`, {
29+
method: "POST",
30+
headers,
31+
body: JSON.stringify(payload),
32+
})
33+
34+
if (!response.ok) {
35+
consola.error("Failed to create response", response)
36+
throw new HTTPError("Failed to create response", response)
37+
}
38+
39+
if (payload.stream) {
40+
return events(response)
41+
}
42+
43+
return (await response.json()) as ResponseObject
44+
}
45+
46+
// Payload types
47+
48+
export interface ResponsesPayload {
49+
model: string
50+
input: string | Array<InputMessage>
51+
stream?: boolean | null
52+
temperature?: number | null
53+
top_p?: number | null
54+
max_output_tokens?: number | null
55+
tools?: Array<ResponseTool> | null
56+
tool_choice?:
57+
| "auto"
58+
| "none"
59+
| "required"
60+
| { type: "function"; name: string }
61+
| null
62+
previous_response_id?: string | null
63+
instructions?: string | null
64+
reasoning?: { effort: "low" | "medium" | "high" } | null
65+
metadata?: Record<string, string> | null
66+
user?: string | null
67+
}
68+
69+
export interface InputMessage {
70+
role: "user" | "assistant" | "system" | "developer" | "tool"
71+
content: string | Array<InputContentPart>
72+
name?: string
73+
tool_call_id?: string
74+
}
75+
76+
export type InputContentPart = InputTextPart | InputImagePart | InputFilePart
77+
78+
export interface InputTextPart {
79+
type: "input_text"
80+
text: string
81+
}
82+
83+
export interface InputImagePart {
84+
type: "input_image"
85+
image_url?: { url: string; detail?: "low" | "high" | "auto" }
86+
file_id?: string
87+
}
88+
89+
export interface InputFilePart {
90+
type: "input_file"
91+
file_id?: string
92+
file_url?: string
93+
filename?: string
94+
}
95+
96+
export interface ResponseTool {
97+
type: "function"
98+
name: string
99+
description?: string
100+
parameters?: Record<string, unknown>
101+
strict?: boolean | null
102+
}
103+
104+
// Response types (non-streaming)
105+
106+
export interface ResponseObject {
107+
id: string
108+
object: "response"
109+
created_at: number
110+
model: string
111+
output: Array<OutputItem>
112+
status: "completed" | "incomplete" | "failed" | "cancelled"
113+
usage?: ResponseUsage
114+
instructions?: string | null
115+
error?: ResponseError | null
116+
metadata?: Record<string, string> | null
117+
}
118+
119+
export type OutputItem = MessageOutputItem | FunctionCallOutputItem
120+
121+
export interface MessageOutputItem {
122+
type: "message"
123+
id: string
124+
role: "assistant"
125+
content: Array<OutputContentPart>
126+
status: "completed" | "incomplete"
127+
}
128+
129+
export type OutputContentPart = OutputTextPart | RefusalPart
130+
131+
export interface OutputTextPart {
132+
type: "output_text"
133+
text: string
134+
annotations?: Array<unknown>
135+
}
136+
137+
export interface RefusalPart {
138+
type: "refusal"
139+
refusal: string
140+
}
141+
142+
export interface FunctionCallOutputItem {
143+
type: "function_call"
144+
id: string
145+
call_id: string
146+
name: string
147+
arguments: string
148+
status: "completed"
149+
}
150+
151+
export interface ResponseUsage {
152+
input_tokens: number
153+
output_tokens: number
154+
total_tokens: number
155+
input_tokens_details?: {
156+
cached_tokens: number
157+
}
158+
output_tokens_details?: {
159+
reasoning_tokens: number
160+
}
161+
}
162+
163+
export interface ResponseError {
164+
code: string
165+
message: string
166+
}

0 commit comments

Comments
 (0)