auto-model-router 0.4.6 → 0.4.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.omp-plugin/marketplace.json +2 -2
- package/README.md +45 -14
- package/hermes-plugin/native/__init__.py +3 -1
- package/opencode-plugin/auto-model-router.ts +151 -0
- package/opencode-plugin/opencode-plugin.d.ts +39 -0
- package/package.json +1 -1
- package/src/server/digest.ts +14 -1
- package/src/server/http.ts +22 -7
- package/src/util/sse.ts +1 -1
- package/src/wire/openai/responses.ts +365 -0
- package/src/wire/openai/sink.ts +19 -2
- package/src/wire/types.ts +1 -1
- package/test/digest.test.ts +3 -0
- package/test/fixtures/harness/aider.json +47 -0
- package/test/fixtures/harness/codex-responses.json +507 -0
- package/test/fixtures/harness/opencode.json +338 -0
- package/test/harness-requests.test.ts +55 -24
- package/test/wire-responses.test.ts +174 -0
- package/test/wire-sink.test.ts +5 -0
- package/tools/capture-proxy.ts +79 -0
- package/tsconfig.all.json +1 -1
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI Responses API front end (`POST /v1/responses`), the wire Codex CLI
|
|
3
|
+
* speaks (it dropped chat completions in 0.150). Two halves:
|
|
4
|
+
*
|
|
5
|
+
* - Request: a Responses body is translated into the chat-completions shape
|
|
6
|
+
* the rest of the router already understands, then parsed by the existing
|
|
7
|
+
* parser. `instructions` becomes the system message, `input` items become
|
|
8
|
+
* messages (function_call → an assistant tool call, function_call_output →
|
|
9
|
+
* a tool message), flat function tools become chat tools. Fields that only
|
|
10
|
+
* mean something to OpenAI's stateful store are dropped.
|
|
11
|
+
* - Response: the upstream chat stream is re-rendered as Responses SSE
|
|
12
|
+
* events (response.created … response.completed) or, for non-streaming
|
|
13
|
+
* callers, one Response object. The routing summary rides on the final
|
|
14
|
+
* event as `x_auto_model_router`, as the chat wire does.
|
|
15
|
+
*
|
|
16
|
+
* Stateless only: `previous_response_id` is rejected, because the router keeps
|
|
17
|
+
* no response store. Codex sends `store: false` and the full input each turn.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { encoder, sseDataFrame } from "../../util/sse.ts";
|
|
21
|
+
import type { NormRequest, ResponseSink, TurnSummary, UpstreamChunk, WireError } from "../types.ts";
|
|
22
|
+
import { invalidRequest, renderErrorEnvelope } from "./errors.ts";
|
|
23
|
+
import { parseChatRequest } from "./request.ts";
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Request translation
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
/** Responses-only fields with no chat-completions meaning; never forwarded. */
|
|
30
|
+
export const RESPONSES_ONLY_PARAMS: readonly string[] = ["instructions", "input", "include", "store", "prompt_cache_key", "client_metadata", "text", "truncation", "metadata", "previous_response_id", "max_output_tokens", "max_tool_calls", "background", "conversation", "safety_identifier", "service_tier"];
|
|
31
|
+
|
|
32
|
+
type Rec = Record<string, unknown>;
|
|
33
|
+
const isRec = (v: unknown): v is Rec => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
34
|
+
|
|
35
|
+
/** One Responses content part → one chat content part (text or image). */
|
|
36
|
+
function contentPart(part: unknown): Rec | null {
|
|
37
|
+
if (typeof part === "string") return { type: "text", text: part };
|
|
38
|
+
if (!isRec(part)) return null;
|
|
39
|
+
const type = typeof part.type === "string" ? part.type : "";
|
|
40
|
+
if (type === "input_text" || type === "output_text" || type === "text") return { type: "text", text: typeof part.text === "string" ? part.text : "" };
|
|
41
|
+
if (type === "input_image") {
|
|
42
|
+
const url = typeof part.image_url === "string" ? part.image_url : isRec(part.image_url) && typeof part.image_url.url === "string" ? part.image_url.url : "";
|
|
43
|
+
return url === "" ? null : { type: "image_url", image_url: { url } };
|
|
44
|
+
}
|
|
45
|
+
if (type === "refusal") return { type: "text", text: typeof part.refusal === "string" ? part.refusal : "" };
|
|
46
|
+
// input_file and unknown parts: keep a placeholder so the turn still classifies.
|
|
47
|
+
return { type: "text", text: `[${type || "unknown"} part]` };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function messageContent(raw: unknown): string | Rec[] {
|
|
51
|
+
if (typeof raw === "string") return raw;
|
|
52
|
+
if (!Array.isArray(raw)) return "";
|
|
53
|
+
const parts = raw.map(contentPart).filter((p): p is Rec => p !== null);
|
|
54
|
+
// A pure-text message is cheaper to carry as a string.
|
|
55
|
+
if (parts.every((p) => p.type === "text")) return parts.map((p) => p.text as string).join("");
|
|
56
|
+
return parts;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function outputText(raw: unknown): string {
|
|
60
|
+
if (typeof raw === "string") return raw;
|
|
61
|
+
if (Array.isArray(raw)) return raw.map((p) => (typeof p === "string" ? p : isRec(p) && typeof p.text === "string" ? p.text : "")).join("");
|
|
62
|
+
if (isRec(raw)) return JSON.stringify(raw);
|
|
63
|
+
return "";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Translates a Responses body into the chat-completions shape; throws WireErrorException on a malformed body. */
|
|
67
|
+
export function responsesToChatBody(body: unknown): Rec {
|
|
68
|
+
if (!isRec(body)) throw invalidRequest("Request body must be a JSON object");
|
|
69
|
+
if (typeof body.previous_response_id === "string" && body.previous_response_id !== "") {
|
|
70
|
+
throw invalidRequest("previous_response_id is not supported: the router keeps no response store; send the full input (Codex does with store=false)");
|
|
71
|
+
}
|
|
72
|
+
const messages: Rec[] = [];
|
|
73
|
+
if (typeof body.instructions === "string" && body.instructions !== "") messages.push({ role: "system", content: body.instructions });
|
|
74
|
+
|
|
75
|
+
const input = body.input;
|
|
76
|
+
if (typeof input === "string") messages.push({ role: "user", content: input });
|
|
77
|
+
else if (Array.isArray(input)) {
|
|
78
|
+
for (const item of input) {
|
|
79
|
+
if (!isRec(item)) continue;
|
|
80
|
+
const type = typeof item.type === "string" ? item.type : typeof item.role === "string" ? "message" : "";
|
|
81
|
+
if (type === "message") {
|
|
82
|
+
const role = typeof item.role === "string" ? item.role : "user";
|
|
83
|
+
messages.push({ role, content: messageContent(item.content) });
|
|
84
|
+
} else if (type === "function_call") {
|
|
85
|
+
const call = {
|
|
86
|
+
id: typeof item.call_id === "string" ? item.call_id : typeof item.id === "string" ? item.id : `call_${messages.length}`,
|
|
87
|
+
type: "function",
|
|
88
|
+
function: { name: typeof item.name === "string" ? item.name : "", arguments: typeof item.arguments === "string" ? item.arguments : "{}" },
|
|
89
|
+
};
|
|
90
|
+
// Parallel calls arrive as consecutive items; they belong to one assistant message.
|
|
91
|
+
const last = messages[messages.length - 1];
|
|
92
|
+
if (last !== undefined && last.role === "assistant" && Array.isArray(last.tool_calls)) (last.tool_calls as Rec[]).push(call);
|
|
93
|
+
else messages.push({ role: "assistant", content: null, tool_calls: [call] });
|
|
94
|
+
} else if (type === "function_call_output") {
|
|
95
|
+
messages.push({ role: "tool", tool_call_id: typeof item.call_id === "string" ? item.call_id : "", content: outputText(item.output) });
|
|
96
|
+
}
|
|
97
|
+
// reasoning, item references, built-in tool calls: nothing the upstream can use.
|
|
98
|
+
}
|
|
99
|
+
} else if (input !== undefined) throw invalidRequest("input must be a string or an array of items");
|
|
100
|
+
if (messages.length === 0) throw invalidRequest("input must contain at least one message");
|
|
101
|
+
|
|
102
|
+
const tools: Rec[] = [];
|
|
103
|
+
if (Array.isArray(body.tools)) {
|
|
104
|
+
for (const t of body.tools) {
|
|
105
|
+
if (!isRec(t) || t.type !== "function" || typeof t.name !== "string") continue;
|
|
106
|
+
tools.push({ type: "function", function: { name: t.name, ...(typeof t.description === "string" ? { description: t.description } : {}), ...(isRec(t.parameters) ? { parameters: t.parameters } : {}) } });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const out: Rec = {};
|
|
111
|
+
for (const [k, v] of Object.entries(body)) {
|
|
112
|
+
if (RESPONSES_ONLY_PARAMS.includes(k) || k === "tools" || k === "tool_choice" || k === "reasoning") continue;
|
|
113
|
+
out[k] = v;
|
|
114
|
+
}
|
|
115
|
+
out.messages = messages;
|
|
116
|
+
if (tools.length > 0) out.tools = tools;
|
|
117
|
+
const tc = body.tool_choice;
|
|
118
|
+
if (isRec(tc) && tc.type === "function" && typeof tc.name === "string") out.tool_choice = { type: "function", function: { name: tc.name } };
|
|
119
|
+
else if (typeof tc === "string") out.tool_choice = tc;
|
|
120
|
+
if (typeof body.max_output_tokens === "number") out.max_tokens = body.max_output_tokens;
|
|
121
|
+
if (isRec(body.reasoning) && typeof body.reasoning.effort === "string") out.reasoning = { effort: body.reasoning.effort };
|
|
122
|
+
out.stream = body.stream === true;
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function parseResponsesRequest(body: unknown, headers: Headers): NormRequest {
|
|
127
|
+
const norm = parseChatRequest(responsesToChatBody(body), headers);
|
|
128
|
+
return { ...norm, protocol: "openai-responses" };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Response rendering
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
interface MessageItem {
|
|
136
|
+
kind: "message";
|
|
137
|
+
id: string;
|
|
138
|
+
text: string;
|
|
139
|
+
open: boolean;
|
|
140
|
+
}
|
|
141
|
+
interface CallItem {
|
|
142
|
+
kind: "function_call";
|
|
143
|
+
id: string;
|
|
144
|
+
callId: string;
|
|
145
|
+
name: string;
|
|
146
|
+
args: string;
|
|
147
|
+
open: boolean;
|
|
148
|
+
}
|
|
149
|
+
type Item = MessageItem | CallItem;
|
|
150
|
+
|
|
151
|
+
function summaryFields(summary: TurnSummary): Record<string, unknown> {
|
|
152
|
+
return { model: summary.servedSlug, tier: summary.tier, cost_usd: summary.reportedUsd ?? summary.predictedUsd, attempts: summary.attempts };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function itemJson(it: Item, status: "in_progress" | "completed"): Rec {
|
|
156
|
+
return it.kind === "message"
|
|
157
|
+
? { id: it.id, type: "message", role: "assistant", status, content: [{ type: "output_text", text: it.text, annotations: [] }] }
|
|
158
|
+
: { id: it.id, type: "function_call", call_id: it.callId, name: it.name, arguments: it.args, status };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function usageJson(summary: TurnSummary): Rec {
|
|
162
|
+
const u = summary.usage;
|
|
163
|
+
return {
|
|
164
|
+
input_tokens: u.promptTokens,
|
|
165
|
+
input_tokens_details: { cached_tokens: u.cachedTokens },
|
|
166
|
+
output_tokens: u.completionTokens,
|
|
167
|
+
output_tokens_details: { reasoning_tokens: u.reasoningTokens },
|
|
168
|
+
total_tokens: u.promptTokens + u.completionTokens,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Turns the upstream chat stream into Responses output items, emitting the
|
|
174
|
+
* standard event sequence through `emit`. Shared by the streaming and the
|
|
175
|
+
* buffered sink; the buffered one simply ignores the events.
|
|
176
|
+
*/
|
|
177
|
+
class ResponseBuilder {
|
|
178
|
+
readonly id = `resp_${crypto.randomUUID().replaceAll("-", "")}`;
|
|
179
|
+
readonly createdAt = Math.floor(Date.now() / 1000);
|
|
180
|
+
private seq = 0;
|
|
181
|
+
private started = false;
|
|
182
|
+
readonly items: Item[] = [];
|
|
183
|
+
private readonly callsByIndex = new Map<number, CallItem>();
|
|
184
|
+
private message: MessageItem | null = null;
|
|
185
|
+
|
|
186
|
+
constructor(
|
|
187
|
+
private readonly model: string,
|
|
188
|
+
private readonly emit: (type: string, payload: Rec) => void,
|
|
189
|
+
) {}
|
|
190
|
+
|
|
191
|
+
private event(type: string, payload: Rec): void {
|
|
192
|
+
this.emit(type, { type, sequence_number: this.seq++, ...payload });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private response(status: string, extra: Rec = {}): Rec {
|
|
196
|
+
return { id: this.id, object: "response", created_at: this.createdAt, status, model: this.model, output: this.items.map((it) => itemJson(it, "completed")), ...extra };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
start(): void {
|
|
200
|
+
if (this.started) return;
|
|
201
|
+
this.started = true;
|
|
202
|
+
this.event("response.created", { response: this.response("in_progress") });
|
|
203
|
+
this.event("response.in_progress", { response: this.response("in_progress") });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private openMessage(): MessageItem {
|
|
207
|
+
if (this.message !== null && this.message.open) return this.message;
|
|
208
|
+
const item: MessageItem = { kind: "message", id: `msg_${crypto.randomUUID().replaceAll("-", "")}`, text: "", open: true };
|
|
209
|
+
this.items.push(item);
|
|
210
|
+
this.message = item;
|
|
211
|
+
const output_index = this.items.length - 1;
|
|
212
|
+
this.event("response.output_item.added", { output_index, item: itemJson(item, "in_progress") });
|
|
213
|
+
this.event("response.content_part.added", { item_id: item.id, output_index, content_index: 0, part: { type: "output_text", text: "", annotations: [] } });
|
|
214
|
+
return item;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private closeMessage(): void {
|
|
218
|
+
const item = this.message;
|
|
219
|
+
if (item === null || !item.open) return;
|
|
220
|
+
item.open = false;
|
|
221
|
+
const output_index = this.items.indexOf(item);
|
|
222
|
+
this.event("response.output_text.done", { item_id: item.id, output_index, content_index: 0, text: item.text });
|
|
223
|
+
this.event("response.content_part.done", { item_id: item.id, output_index, content_index: 0, part: { type: "output_text", text: item.text, annotations: [] } });
|
|
224
|
+
this.event("response.output_item.done", { output_index, item: itemJson(item, "completed") });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private closeCall(item: CallItem): void {
|
|
228
|
+
if (!item.open) return;
|
|
229
|
+
item.open = false;
|
|
230
|
+
const output_index = this.items.indexOf(item);
|
|
231
|
+
this.event("response.function_call_arguments.done", { item_id: item.id, output_index, arguments: item.args });
|
|
232
|
+
this.event("response.output_item.done", { output_index, item: itemJson(item, "completed") });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
chunk(chunk: UpstreamChunk): void {
|
|
236
|
+
for (const ev of chunk.events) {
|
|
237
|
+
if (ev.type === "start") this.start();
|
|
238
|
+
else if (ev.type === "text") {
|
|
239
|
+
if (ev.delta === "") continue;
|
|
240
|
+
this.start();
|
|
241
|
+
const item = this.openMessage();
|
|
242
|
+
item.text += ev.delta;
|
|
243
|
+
this.event("response.output_text.delta", { item_id: item.id, output_index: this.items.indexOf(item), content_index: 0, delta: ev.delta });
|
|
244
|
+
} else if (ev.type === "tool_call") {
|
|
245
|
+
this.start();
|
|
246
|
+
let item = this.callsByIndex.get(ev.index);
|
|
247
|
+
if (item === undefined) {
|
|
248
|
+
this.closeMessage();
|
|
249
|
+
item = {
|
|
250
|
+
kind: "function_call",
|
|
251
|
+
id: `fc_${crypto.randomUUID().replaceAll("-", "")}`,
|
|
252
|
+
callId: ev.id ?? `call_${crypto.randomUUID().replaceAll("-", "").slice(0, 24)}`,
|
|
253
|
+
name: ev.name ?? "",
|
|
254
|
+
args: "",
|
|
255
|
+
open: true,
|
|
256
|
+
};
|
|
257
|
+
this.callsByIndex.set(ev.index, item);
|
|
258
|
+
this.items.push(item);
|
|
259
|
+
this.event("response.output_item.added", { output_index: this.items.length - 1, item: itemJson(item, "in_progress") });
|
|
260
|
+
} else if (ev.name !== undefined && item.name === "") item.name = ev.name;
|
|
261
|
+
if (ev.argsDelta !== undefined && ev.argsDelta !== "") {
|
|
262
|
+
item.args += ev.argsDelta;
|
|
263
|
+
this.event("response.function_call_arguments.delta", { item_id: item.id, output_index: this.items.indexOf(item), delta: ev.argsDelta });
|
|
264
|
+
}
|
|
265
|
+
} else if (ev.type === "finish") {
|
|
266
|
+
this.closeMessage();
|
|
267
|
+
for (const c of this.callsByIndex.values()) this.closeCall(c);
|
|
268
|
+
}
|
|
269
|
+
// reasoning and usage: carried by the summary, not the item stream.
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
finish(summary: TurnSummary): Rec {
|
|
274
|
+
this.start();
|
|
275
|
+
this.closeMessage();
|
|
276
|
+
for (const c of this.callsByIndex.values()) this.closeCall(c);
|
|
277
|
+
const response = this.response("completed", { usage: usageJson(summary) });
|
|
278
|
+
this.event("response.completed", { response, x_auto_model_router: summaryFields(summary) });
|
|
279
|
+
return { ...response, x_auto_model_router: summaryFields(summary) };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
fail(error: WireError): void {
|
|
283
|
+
this.event("error", { code: error.code, message: error.message });
|
|
284
|
+
this.event("response.failed", { response: this.response("failed", { error: { code: error.code, message: error.message } }) });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function createResponsesStreamingSink(virtualModel: string): { sink: ResponseSink; response: Response } {
|
|
289
|
+
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
|
|
290
|
+
let closed = false;
|
|
291
|
+
const body = new ReadableStream<Uint8Array>({
|
|
292
|
+
start(c) {
|
|
293
|
+
controller = c;
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
const send = (text: string): void => {
|
|
297
|
+
if (closed) return;
|
|
298
|
+
try {
|
|
299
|
+
controller?.enqueue(encoder.encode(text));
|
|
300
|
+
} catch {
|
|
301
|
+
closed = true;
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
const close = (): void => {
|
|
305
|
+
if (closed) return;
|
|
306
|
+
closed = true;
|
|
307
|
+
try {
|
|
308
|
+
controller?.close();
|
|
309
|
+
} catch {
|
|
310
|
+
// Already closed by the runtime.
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
const builder = new ResponseBuilder(virtualModel, (type, payload) => send(`event: ${type}\n${sseDataFrame(payload)}`));
|
|
314
|
+
const sink: ResponseSink = {
|
|
315
|
+
chunk(chunk) {
|
|
316
|
+
builder.chunk(chunk);
|
|
317
|
+
},
|
|
318
|
+
error(error) {
|
|
319
|
+
builder.fail(error);
|
|
320
|
+
close();
|
|
321
|
+
},
|
|
322
|
+
finish(summary) {
|
|
323
|
+
builder.finish(summary);
|
|
324
|
+
close();
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
return { sink, response: new Response(body, { status: 200, headers: { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" } }) };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function createResponsesBufferedSink(virtualModel: string): { sink: ResponseSink; response: Promise<Response> } {
|
|
331
|
+
let resolve!: (r: Response) => void;
|
|
332
|
+
const response = new Promise<Response>((r) => {
|
|
333
|
+
resolve = r;
|
|
334
|
+
});
|
|
335
|
+
let settled = false;
|
|
336
|
+
const builder = new ResponseBuilder(virtualModel, () => {});
|
|
337
|
+
const sink: ResponseSink = {
|
|
338
|
+
chunk(chunk) {
|
|
339
|
+
builder.chunk(chunk);
|
|
340
|
+
},
|
|
341
|
+
error(error) {
|
|
342
|
+
if (settled) return;
|
|
343
|
+
settled = true;
|
|
344
|
+
resolve(new Response(JSON.stringify(renderErrorEnvelope(error)), { status: error.status, headers: { "content-type": "application/json" } }));
|
|
345
|
+
},
|
|
346
|
+
finish(summary) {
|
|
347
|
+
if (settled) return;
|
|
348
|
+
settled = true;
|
|
349
|
+
const f = summaryFields(summary);
|
|
350
|
+
resolve(
|
|
351
|
+
new Response(JSON.stringify(builder.finish(summary)), {
|
|
352
|
+
status: 200,
|
|
353
|
+
headers: {
|
|
354
|
+
"content-type": "application/json",
|
|
355
|
+
"x-auto-model-router-model": String(f.model),
|
|
356
|
+
"x-auto-model-router-tier": String(f.tier),
|
|
357
|
+
"x-auto-model-router-cost-usd": String(f.cost_usd),
|
|
358
|
+
"x-auto-model-router-attempts": String(f.attempts),
|
|
359
|
+
},
|
|
360
|
+
}),
|
|
361
|
+
);
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
return { sink, response };
|
|
365
|
+
}
|
package/src/wire/openai/sink.ts
CHANGED
|
@@ -20,6 +20,9 @@ function summaryFields(summary: TurnSummary): Record<string, unknown> {
|
|
|
20
20
|
export function createStreamingSink(virtualModel: string): { sink: ResponseSink; response: Response } {
|
|
21
21
|
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
|
|
22
22
|
let closed = false;
|
|
23
|
+
// The upstream's chunk identity, so the trailer can be a well-formed chunk.
|
|
24
|
+
let lastId: unknown = null;
|
|
25
|
+
let lastCreated: unknown = null;
|
|
23
26
|
const body = new ReadableStream<Uint8Array>({
|
|
24
27
|
start(c) {
|
|
25
28
|
controller = c;
|
|
@@ -51,6 +54,8 @@ export function createStreamingSink(virtualModel: string): { sink: ResponseSink;
|
|
|
51
54
|
const sink: ResponseSink = {
|
|
52
55
|
chunk(chunk: UpstreamChunk) {
|
|
53
56
|
const raw = chunk.raw;
|
|
57
|
+
if (typeof raw.id === "string") lastId = raw.id;
|
|
58
|
+
if (typeof raw.created === "number") lastCreated = raw.created;
|
|
54
59
|
// The client asked for the virtual id and must see it, so its own
|
|
55
60
|
// bookkeeping stays consistent; the served slug stays observable
|
|
56
61
|
// under x_auto_model_router. The tier is only known at finish time, so
|
|
@@ -73,8 +78,20 @@ export function createStreamingSink(virtualModel: string): { sink: ResponseSink;
|
|
|
73
78
|
},
|
|
74
79
|
finish(summary: TurnSummary) {
|
|
75
80
|
// Response headers flushed with the first chunk, so x-auto-model-router-*
|
|
76
|
-
// cannot be real headers here; this final frame is their carrier.
|
|
77
|
-
|
|
81
|
+
// cannot be real headers here; this final frame is their carrier. It is
|
|
82
|
+
// shaped as a real chunk with no choices (the shape OpenAI's own usage
|
|
83
|
+
// chunk has): strict clients validate every frame, and OpenCode's AI
|
|
84
|
+
// SDK rejected a bare object here ("choices: expected array").
|
|
85
|
+
send(
|
|
86
|
+
encodeSseData({
|
|
87
|
+
id: lastId ?? "auto-model-router",
|
|
88
|
+
object: "chat.completion.chunk",
|
|
89
|
+
created: lastCreated ?? Math.floor(Date.now() / 1000),
|
|
90
|
+
model: virtualModel,
|
|
91
|
+
choices: [],
|
|
92
|
+
x_auto_model_router: summaryFields(summary),
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
78
95
|
send(SSE_DONE_BYTES);
|
|
79
96
|
close();
|
|
80
97
|
},
|
package/src/wire/types.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import type { UsageCounts } from "../cost/types.ts";
|
|
12
12
|
|
|
13
|
-
export type WireProtocol = "openai-chat" | "pi-native";
|
|
13
|
+
export type WireProtocol = "openai-chat" | "openai-responses" | "pi-native";
|
|
14
14
|
|
|
15
15
|
export type Role = "system" | "developer" | "user" | "assistant" | "tool";
|
|
16
16
|
|
package/test/digest.test.ts
CHANGED
|
@@ -198,6 +198,9 @@ describe("createDigester", () => {
|
|
|
198
198
|
expect(dg.noteToolCalls("omp-1", [{ name: "read", argsJson: '{"path":"src/b.ts"}' }, { name: "grep", argsJson: '{"pattern":"src/a.ts"}' }])).toBe(0);
|
|
199
199
|
expect(dg.noteToolCalls("omp-2", [{ name: "read", argsJson: '{"path":"src/a.ts"}' }])).toBe(0);
|
|
200
200
|
expect(row().wasted).toBe(false);
|
|
201
|
+
// The next request carries the call that PRODUCED the digest in its last assistant message: not a re-run.
|
|
202
|
+
expect(dg.noteToolCalls("omp-1", [{ name: "read", argsJson: '{"path":"src/a.ts","offset":1}' }])).toBe(0);
|
|
203
|
+
expect(row().wasted).toBe(false);
|
|
201
204
|
// The same read again (case-insensitive tool name, any other args): the agent wanted the full output.
|
|
202
205
|
expect(dg.noteToolCalls("omp-1", [{ name: "Read", argsJson: '{"path":"src/a.ts","limit":50}' }])).toBe(1);
|
|
203
206
|
expect(row().wasted).toBe(true);
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"harness": "aider",
|
|
3
|
+
"capturedAtMs": 1788816104921,
|
|
4
|
+
"headers": {
|
|
5
|
+
"content-type": "application/json",
|
|
6
|
+
"user-agent": "OpenAI/Python 2.20.0"
|
|
7
|
+
},
|
|
8
|
+
"body": {
|
|
9
|
+
"messages": [
|
|
10
|
+
{
|
|
11
|
+
"role": "system",
|
|
12
|
+
"content": "Act as an expert software developer.\nTake requests for changes to the supplied code.\nIf the request is ambiguous, ask questions.\nReply in English_United States.\n\nOnce you understand the request you MUST:\n1. Determine if any code changes are needed.\n2. Explain any needed changes.\n3. If changes are needed, output a copy of each file that needs changes.\n\nTo suggest changes to a file you MUST return t\u00e2\u20ac\u00a6[1219 chars]"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"role": "user",
|
|
16
|
+
"content": "Change the greeting to be more casual"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"role": "assistant",
|
|
20
|
+
"content": "Ok, I will:\n\n1. Switch the greeting text from \"Hello\" to \"Hey\".\n\nshow_greeting.py\n```\nimport sys\n\ndef greeting(name):\n print(f\"Hey {name}\")\n\nif __name__ == '__main__':\n greeting(sys.argv[1])\n```\n"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"role": "user",
|
|
24
|
+
"content": "I switched to a new code base. Please don't consider the above files or try to edit them any longer."
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"role": "assistant",
|
|
28
|
+
"content": "Ok."
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"role": "user",
|
|
32
|
+
"content": "I am not sharing any files that you can edit yet."
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"role": "assistant",
|
|
36
|
+
"content": "Ok."
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"role": "user",
|
|
40
|
+
"content": "Reply with the single word ok\n\nTo suggest changes to a file you MUST return the entire content of the updated file.\nYou MUST use this *file listing* format:\n\npath/to/filename.js\n```\n// entire file content ...\n// ... goes in between\n```\n\nEvery *file listing* MUST use this format:\n- First line: the filename with any originally provided path; no extra markup, punctuation, comments, etc. **JUST** the \u00e2\u20ac\u00a6[896 chars]"
|
|
41
|
+
}
|
|
42
|
+
],
|
|
43
|
+
"model": "auto",
|
|
44
|
+
"stream": true,
|
|
45
|
+
"temperature": 0
|
|
46
|
+
}
|
|
47
|
+
}
|