Skip to content

Commit 9a1c9ae

Browse files
authored
test(app): route prompt e2e through mock llm (#20383)
1 parent a3a6cf1 commit 9a1c9ae

1 file changed

Lines changed: 302 additions & 24 deletions

File tree

packages/opencode/test/lib/llm-server.ts

Lines changed: 302 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ export type Usage = { input: number; output: number }
88

99
type Line = Record<string, unknown>
1010

11+
type Flow =
12+
| { type: "text"; text: string }
13+
| { type: "reason"; text: string }
14+
| { type: "tool-start"; id: string; name: string }
15+
| { type: "tool-args"; text: string }
16+
| { type: "usage"; usage: Usage }
17+
1118
type Hit = {
1219
url: URL
1320
body: Record<string, unknown>
@@ -119,6 +126,276 @@ function bytes(input: Iterable<unknown>) {
119126
return Stream.fromIterable([...input].map(line)).pipe(Stream.encodeText)
120127
}
121128

129+
function responseCreated(model: string) {
130+
return {
131+
type: "response.created",
132+
sequence_number: 1,
133+
response: {
134+
id: "resp_test",
135+
created_at: Math.floor(Date.now() / 1000),
136+
model,
137+
service_tier: null,
138+
},
139+
}
140+
}
141+
142+
function responseCompleted(input: { seq: number; usage?: Usage }) {
143+
return {
144+
type: "response.completed",
145+
sequence_number: input.seq,
146+
response: {
147+
incomplete_details: null,
148+
service_tier: null,
149+
usage: {
150+
input_tokens: input.usage?.input ?? 0,
151+
input_tokens_details: { cached_tokens: null },
152+
output_tokens: input.usage?.output ?? 0,
153+
output_tokens_details: { reasoning_tokens: null },
154+
},
155+
},
156+
}
157+
}
158+
159+
function responseMessage(id: string, seq: number) {
160+
return {
161+
type: "response.output_item.added",
162+
sequence_number: seq,
163+
output_index: 0,
164+
item: { type: "message", id },
165+
}
166+
}
167+
168+
function responseText(id: string, text: string, seq: number) {
169+
return {
170+
type: "response.output_text.delta",
171+
sequence_number: seq,
172+
item_id: id,
173+
delta: text,
174+
logprobs: null,
175+
}
176+
}
177+
178+
function responseMessageDone(id: string, seq: number) {
179+
return {
180+
type: "response.output_item.done",
181+
sequence_number: seq,
182+
output_index: 0,
183+
item: { type: "message", id },
184+
}
185+
}
186+
187+
function responseReason(id: string, seq: number) {
188+
return {
189+
type: "response.output_item.added",
190+
sequence_number: seq,
191+
output_index: 0,
192+
item: { type: "reasoning", id, encrypted_content: null },
193+
}
194+
}
195+
196+
function responseReasonPart(id: string, seq: number) {
197+
return {
198+
type: "response.reasoning_summary_part.added",
199+
sequence_number: seq,
200+
item_id: id,
201+
summary_index: 0,
202+
}
203+
}
204+
205+
function responseReasonText(id: string, text: string, seq: number) {
206+
return {
207+
type: "response.reasoning_summary_text.delta",
208+
sequence_number: seq,
209+
item_id: id,
210+
summary_index: 0,
211+
delta: text,
212+
}
213+
}
214+
215+
function responseReasonDone(id: string, seq: number) {
216+
return {
217+
type: "response.output_item.done",
218+
sequence_number: seq,
219+
output_index: 0,
220+
item: { type: "reasoning", id, encrypted_content: null },
221+
}
222+
}
223+
224+
function responseTool(id: string, item: string, name: string, seq: number) {
225+
return {
226+
type: "response.output_item.added",
227+
sequence_number: seq,
228+
output_index: 0,
229+
item: {
230+
type: "function_call",
231+
id: item,
232+
call_id: id,
233+
name,
234+
arguments: "",
235+
status: "in_progress",
236+
},
237+
}
238+
}
239+
240+
function responseToolArgs(id: string, text: string, seq: number) {
241+
return {
242+
type: "response.function_call_arguments.delta",
243+
sequence_number: seq,
244+
output_index: 0,
245+
item_id: id,
246+
delta: text,
247+
}
248+
}
249+
250+
function responseToolDone(tool: { id: string; item: string; name: string; args: string }, seq: number) {
251+
return {
252+
type: "response.output_item.done",
253+
sequence_number: seq,
254+
output_index: 0,
255+
item: {
256+
type: "function_call",
257+
id: tool.item,
258+
call_id: tool.id,
259+
name: tool.name,
260+
arguments: tool.args,
261+
status: "completed",
262+
},
263+
}
264+
}
265+
266+
function choices(part: unknown) {
267+
if (!part || typeof part !== "object") return
268+
if (!("choices" in part) || !Array.isArray(part.choices)) return
269+
const choice = part.choices[0]
270+
if (!choice || typeof choice !== "object") return
271+
return choice
272+
}
273+
274+
function flow(item: Sse) {
275+
const out: Flow[] = []
276+
for (const part of [...item.head, ...item.tail]) {
277+
const choice = choices(part)
278+
const delta =
279+
choice && "delta" in choice && choice.delta && typeof choice.delta === "object" ? choice.delta : undefined
280+
281+
if (delta && "content" in delta && typeof delta.content === "string") {
282+
out.push({ type: "text", text: delta.content })
283+
}
284+
285+
if (delta && "reasoning_content" in delta && typeof delta.reasoning_content === "string") {
286+
out.push({ type: "reason", text: delta.reasoning_content })
287+
}
288+
289+
if (delta && "tool_calls" in delta && Array.isArray(delta.tool_calls)) {
290+
for (const tool of delta.tool_calls) {
291+
if (!tool || typeof tool !== "object") continue
292+
const fn = "function" in tool && tool.function && typeof tool.function === "object" ? tool.function : undefined
293+
if ("id" in tool && typeof tool.id === "string" && fn && "name" in fn && typeof fn.name === "string") {
294+
out.push({ type: "tool-start", id: tool.id, name: fn.name })
295+
}
296+
if (fn && "arguments" in fn && typeof fn.arguments === "string" && fn.arguments) {
297+
out.push({ type: "tool-args", text: fn.arguments })
298+
}
299+
}
300+
}
301+
302+
if (part && typeof part === "object" && "usage" in part && part.usage && typeof part.usage === "object") {
303+
const raw = part.usage as Record<string, unknown>
304+
if (typeof raw.prompt_tokens === "number" && typeof raw.completion_tokens === "number") {
305+
out.push({
306+
type: "usage",
307+
usage: { input: raw.prompt_tokens, output: raw.completion_tokens },
308+
})
309+
}
310+
}
311+
}
312+
return out
313+
}
314+
315+
function responses(item: Sse, model: string) {
316+
let seq = 1
317+
let msg: string | undefined
318+
let reason: string | undefined
319+
let hasMsg = false
320+
let hasReason = false
321+
let call:
322+
| {
323+
id: string
324+
item: string
325+
name: string
326+
args: string
327+
}
328+
| undefined
329+
let usage: Usage | undefined
330+
const lines: unknown[] = [responseCreated(model)]
331+
332+
for (const part of flow(item)) {
333+
if (part.type === "text") {
334+
msg ??= "msg_1"
335+
if (!hasMsg) {
336+
hasMsg = true
337+
seq += 1
338+
lines.push(responseMessage(msg, seq))
339+
}
340+
seq += 1
341+
lines.push(responseText(msg, part.text, seq))
342+
continue
343+
}
344+
345+
if (part.type === "reason") {
346+
reason ||= "rs_1"
347+
if (!hasReason) {
348+
hasReason = true
349+
seq += 1
350+
lines.push(responseReason(reason, seq))
351+
seq += 1
352+
lines.push(responseReasonPart(reason, seq))
353+
}
354+
seq += 1
355+
lines.push(responseReasonText(reason, part.text, seq))
356+
continue
357+
}
358+
359+
if (part.type === "tool-start") {
360+
call ||= { id: part.id, item: "fc_1", name: part.name, args: "" }
361+
seq += 1
362+
lines.push(responseTool(call.id, call.item, call.name, seq))
363+
continue
364+
}
365+
366+
if (part.type === "tool-args") {
367+
if (!call) continue
368+
call.args += part.text
369+
seq += 1
370+
lines.push(responseToolArgs(call.item, part.text, seq))
371+
continue
372+
}
373+
374+
usage = part.usage
375+
}
376+
377+
if (msg) {
378+
seq += 1
379+
lines.push(responseMessageDone(msg, seq))
380+
}
381+
if (reason) {
382+
seq += 1
383+
lines.push(responseReasonDone(reason, seq))
384+
}
385+
if (call && !item.hang && !item.error) {
386+
seq += 1
387+
lines.push(responseToolDone(call, seq))
388+
}
389+
if (!item.hang && !item.error) lines.push(responseCompleted({ seq: seq + 1, usage }))
390+
return { ...item, head: lines, tail: [] } satisfies Sse
391+
}
392+
393+
function modelFrom(body: unknown) {
394+
if (!body || typeof body !== "object") return "test-model"
395+
if (!("model" in body) || typeof body.model !== "string") return "test-model"
396+
return body.model
397+
}
398+
122399
function send(item: Sse) {
123400
const head = bytes(item.head)
124401
const tail = bytes([...item.tail, ...(item.hang || item.error ? [] : [done])])
@@ -293,6 +570,13 @@ function item(input: Item | Reply) {
293570
return input instanceof Reply ? input.item() : input
294571
}
295572

573+
function hit(url: string, body: unknown) {
574+
return {
575+
url: new URL(url, "http://localhost"),
576+
body: body && typeof body === "object" ? (body as Record<string, unknown>) : {},
577+
} satisfies Hit
578+
}
579+
296580
namespace TestLLMServer {
297581
export interface Service {
298582
readonly url: string
@@ -342,30 +626,24 @@ export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServ
342626
return first
343627
}
344628

345-
yield* router.add(
346-
"POST",
347-
"/v1/chat/completions",
348-
Effect.gen(function* () {
349-
const req = yield* HttpServerRequest.HttpServerRequest
350-
const next = pull()
351-
if (!next) return HttpServerResponse.text("unexpected request", { status: 500 })
352-
const body = yield* req.json.pipe(Effect.orElseSucceed(() => ({})))
353-
hits = [
354-
...hits,
355-
{
356-
url: new URL(req.originalUrl, "http://localhost"),
357-
body: body && typeof body === "object" ? (body as Record<string, unknown>) : {},
358-
},
359-
]
360-
yield* notify()
361-
if (next.type === "sse" && next.reset) {
362-
yield* reset(next)
363-
return HttpServerResponse.empty()
364-
}
365-
if (next.type === "sse") return send(next)
366-
return fail(next)
367-
}),
368-
)
629+
const handle = Effect.fn("TestLLMServer.handle")(function* (mode: "chat" | "responses") {
630+
const req = yield* HttpServerRequest.HttpServerRequest
631+
const next = pull()
632+
if (!next) return HttpServerResponse.text("unexpected request", { status: 500 })
633+
const body = yield* req.json.pipe(Effect.orElseSucceed(() => ({})))
634+
hits = [...hits, hit(req.originalUrl, body)]
635+
yield* notify()
636+
if (next.type !== "sse") return fail(next)
637+
if (mode === "responses") return send(responses(next, modelFrom(body)))
638+
if (next.reset) {
639+
yield* reset(next)
640+
return HttpServerResponse.empty()
641+
}
642+
return send(next)
643+
})
644+
645+
yield* router.add("POST", "/v1/chat/completions", handle("chat"))
646+
yield* router.add("POST", "/v1/responses", handle("responses"))
369647

370648
yield* server.serve(router.asHttpEffect())
371649

0 commit comments

Comments
 (0)