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
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
},
|
|
8
8
|
"metadata": {
|
|
9
9
|
"description": "auto-model-router: a local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter",
|
|
10
|
-
"version": "0.4.
|
|
10
|
+
"version": "0.4.8",
|
|
11
11
|
"pluginRoot": "."
|
|
12
12
|
},
|
|
13
13
|
"plugins": [
|
|
14
14
|
{
|
|
15
15
|
"name": "auto-model-router",
|
|
16
16
|
"description": "Local cost/complexity-aware model router for Oh My Pi, backed by OpenRouter. Runs in-process, routes per turn by price and task complexity, with budget caps, mid-stream escalation, and cache-aware hysteresis.",
|
|
17
|
-
"version": "0.4.
|
|
17
|
+
"version": "0.4.8",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/README.md
CHANGED
|
@@ -410,8 +410,12 @@ user-visible notice channel; use `/router why`), the automatic daily summary
|
|
|
410
410
|
|
|
411
411
|
### Codex CLI
|
|
412
412
|
|
|
413
|
-
Codex
|
|
414
|
-
|
|
413
|
+
Codex (0.150 and later) speaks only the Responses API, which the router
|
|
414
|
+
serves at `POST /v1/responses`: the body is translated to the chat shape
|
|
415
|
+
the router routes on (`instructions` → system, `input` items → messages,
|
|
416
|
+
function calls and outputs → tool calls and tool messages) and the upstream
|
|
417
|
+
stream is rendered back as Responses events. Run the router
|
|
418
|
+
(`auto-model-router serve --port 8788`) and add a provider:
|
|
415
419
|
|
|
416
420
|
```toml
|
|
417
421
|
# ~/.codex/config.toml
|
|
@@ -421,13 +425,17 @@ model_provider = "auto-model-router"
|
|
|
421
425
|
[model_providers.auto-model-router]
|
|
422
426
|
name = "auto-model-router"
|
|
423
427
|
base_url = "http://127.0.0.1:8788/v1"
|
|
424
|
-
|
|
428
|
+
env_key = "AUTO_MODEL_ROUTER_API_KEY" # any value; the router is keyless
|
|
429
|
+
wire_api = "responses"
|
|
425
430
|
http_headers = { "X-Omp-Harness" = "codex" }
|
|
426
431
|
```
|
|
427
432
|
|
|
428
|
-
|
|
429
|
-
`
|
|
430
|
-
|
|
433
|
+
Verified live with codex 0.153: the captured request is
|
|
434
|
+
`test/fixtures/harness/codex-responses.json`. Stateless only — Codex sends
|
|
435
|
+
`store: false` and the full input each turn; `previous_response_id` is
|
|
436
|
+
rejected. Reasoning summaries and encrypted reasoning are not produced. No
|
|
437
|
+
session id or hooks: reports are per harness, and there is no toast, digest
|
|
438
|
+
or `/router`.
|
|
431
439
|
|
|
432
440
|
### Aider
|
|
433
441
|
|
|
@@ -437,8 +445,12 @@ export OPENAI_API_KEY=local
|
|
|
437
445
|
aider --model openai/auto
|
|
438
446
|
```
|
|
439
447
|
|
|
440
|
-
|
|
441
|
-
|
|
448
|
+
Verified live with aider 0.86 (captured request:
|
|
449
|
+
`test/fixtures/harness/aider.json`). Aider sends no tool calls, so every turn
|
|
450
|
+
classifies on its text alone, and no custom headers, so its rows carry no
|
|
451
|
+
harness id unless you set one in a model settings file
|
|
452
|
+
(`extra_params: { extra_headers: { X-Omp-Harness: aider } }`). No session
|
|
453
|
+
id or hooks.
|
|
442
454
|
|
|
443
455
|
### Cline, Roo Code, Kilo Code
|
|
444
456
|
|
|
@@ -468,9 +480,28 @@ digest applies only through summarising compaction
|
|
|
468
480
|
}
|
|
469
481
|
```
|
|
470
482
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
483
|
+
Verified live with opencode 1.18 (captured request:
|
|
484
|
+
`test/fixtures/harness/opencode.json`). OpenCode's AI SDK validates every
|
|
485
|
+
SSE frame, which is why the router's final summary frame is shaped as a
|
|
486
|
+
chunk with no choices. Its tool names (`read`, `grep`, `glob`, `bash`,
|
|
487
|
+
`webfetch`) match the router's canonical list.
|
|
488
|
+
|
|
489
|
+
**Native features (OpenCode plugin API).** Copy
|
|
490
|
+
`opencode-plugin/auto-model-router.ts` to `~/.config/opencode/plugin/` (or a
|
|
491
|
+
project's `.opencode/plugin/`); OpenCode loads it on start. It adds:
|
|
492
|
+
|
|
493
|
+
- **Session identity** — `X-Omp-Session`, `X-Omp-Harness` (`opencode`, or
|
|
494
|
+
`OMP_HARNESS_ID`) and `X-Omp-Subagent` for sessions with a parent, through
|
|
495
|
+
the `chat.headers` hook.
|
|
496
|
+
- **Routing toast** — when a session goes idle, its last routed turn's
|
|
497
|
+
provider, model, tier and cost appear as a TUI toast.
|
|
498
|
+
- **Tool-result digest** — large `read`, `grep`, `glob`, `bash` and
|
|
499
|
+
`webfetch` results go to `/v1/router/digest` through `tool.execute.after`
|
|
500
|
+
and the model gets the digest. Off unless `digest.enabled`.
|
|
501
|
+
|
|
502
|
+
No `/router` command (OpenCode commands are markdown files, not plugin
|
|
503
|
+
hooks): use `auto-model-router report` on the terminal, or the router's
|
|
504
|
+
HTTP endpoints.
|
|
474
505
|
|
|
475
506
|
### The OpenRouter key
|
|
476
507
|
|
|
@@ -1077,10 +1108,10 @@ plus a harness header; the rest needs the harness's own hook API.
|
|
|
1077
1108
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
|
1078
1109
|
| omp | native provider | yes | yes | yes | yes | full hub | yes | yes | experimental |
|
|
1079
1110
|
| Hermes | provider plugin | yes | yes (native plugin) | yes (native plugin) | no | text | yes (native plugin) | on demand | no |
|
|
1080
|
-
| Codex CLI |
|
|
1081
|
-
| Aider | config only |
|
|
1111
|
+
| Codex CLI | Responses API wire | yes | no | no | no | no | compaction only | no | no |
|
|
1112
|
+
| Aider | config only | via model settings | no | no | no | no | no tools | no | no |
|
|
1082
1113
|
| Cline / Roo / Kilo | config only | if headers supported | no | no | no | no | compaction only | no | no |
|
|
1083
|
-
| OpenCode | config
|
|
1114
|
+
| OpenCode | config + plugin | yes | yes (plugin) | yes (plugin) | yes (plugin) | no | yes (plugin) | no | no |
|
|
1084
1115
|
| Claude Code | needs an Anthropic Messages wire module | — | — | — | — | — | — | — | — |
|
|
1085
1116
|
|
|
1086
1117
|
A single embedded router can serve several omp sessions without them stepping
|
|
@@ -273,9 +273,11 @@ def _why_text(e: Dict[str, Any]) -> str:
|
|
|
273
273
|
pt, ct = usage.get("promptTokens", 0) or 0, usage.get("cachedTokens", 0) or 0
|
|
274
274
|
cache = f"{round(100 * ct / pt)}%" if pt else "n/a"
|
|
275
275
|
cost = e.get("reportedUsd")
|
|
276
|
+
if cost is None:
|
|
277
|
+
cost = e.get("predictedUsd") or 0
|
|
276
278
|
lines = [
|
|
277
279
|
f"last turn: {e.get('servedSlug') or e.get('slug')} [{e.get('tier')}] · {e.get('classificationSource')} (confidence {e.get('confidence')})",
|
|
278
|
-
f"cost ${cost
|
|
280
|
+
f"cost ${float(cost):.5f} · cache hit {cache} · latency {e.get('latencyMs')}ms",
|
|
279
281
|
]
|
|
280
282
|
for r in e.get("reasons") or []:
|
|
281
283
|
lines.append(f" - {r}")
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode plugin: auto-model-router native features.
|
|
3
|
+
*
|
|
4
|
+
* OpenCode reaches the router through an OpenAI-compatible provider block
|
|
5
|
+
* (see README, "OpenCode"). This plugin adds what the provider block cannot:
|
|
6
|
+
*
|
|
7
|
+
* - **Session identity** — `chat.headers` adds `X-Omp-Session`,
|
|
8
|
+
* `X-Omp-Harness` and, for a session with a parent (a subagent),
|
|
9
|
+
* `X-Omp-Subagent`, so per-session reports, feedback and the router's
|
|
10
|
+
* subagent profile work as in omp.
|
|
11
|
+
* - **Routing toast** — when a session goes idle, the model, tier and cost of
|
|
12
|
+
* its last routed turn are shown as a TUI toast (nothing in `run` mode).
|
|
13
|
+
* - **Tool-result digest** — `tool.execute.after` sends a large read, grep,
|
|
14
|
+
* glob, bash or webfetch result to the router's `/v1/router/digest` and
|
|
15
|
+
* hands the model the digest instead. The router decides (policy, session
|
|
16
|
+
* tier, cost guard). Off unless `digest.enabled` is set in the router config.
|
|
17
|
+
*
|
|
18
|
+
* Install: copy this file to `~/.config/opencode/plugin/auto-model-router.ts`
|
|
19
|
+
* (or `.opencode/plugin/` in a project). OpenCode loads it on start. The
|
|
20
|
+
* router URL is `AUTO_MODEL_ROUTER_URL`, else port `AUTO_MODEL_ROUTER_PORT`
|
|
21
|
+
* (default 8788) on localhost.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
25
|
+
|
|
26
|
+
const PROVIDER_ID = "auto-model-router";
|
|
27
|
+
const BASE_URL = process.env.AUTO_MODEL_ROUTER_URL ?? `http://127.0.0.1:${process.env.AUTO_MODEL_ROUTER_PORT ?? "8788"}`;
|
|
28
|
+
const HARNESS_ID = process.env.OMP_HARNESS_ID ?? "opencode";
|
|
29
|
+
const POLICY_TTL_MS = 60_000;
|
|
30
|
+
|
|
31
|
+
interface DigestPolicy {
|
|
32
|
+
enabled: boolean;
|
|
33
|
+
minBytes: number;
|
|
34
|
+
maxBytes: number;
|
|
35
|
+
tools: string[];
|
|
36
|
+
toolAliases: Record<string, string>;
|
|
37
|
+
}
|
|
38
|
+
const DISABLED: DigestPolicy = { enabled: false, minBytes: 0, maxBytes: 0, tools: [], toolAliases: {} };
|
|
39
|
+
|
|
40
|
+
function parsePolicy(json: unknown): DigestPolicy {
|
|
41
|
+
if (json === null || typeof json !== "object") return DISABLED;
|
|
42
|
+
const p = json as Record<string, unknown>;
|
|
43
|
+
if (p.enabled !== true) return DISABLED;
|
|
44
|
+
return {
|
|
45
|
+
enabled: true,
|
|
46
|
+
minBytes: typeof p.minBytes === "number" ? p.minBytes : 12_000,
|
|
47
|
+
maxBytes: typeof p.maxBytes === "number" ? p.maxBytes : 400_000,
|
|
48
|
+
tools: Array.isArray(p.tools) ? p.tools.filter((t): t is string => typeof t === "string").map((t) => t.toLowerCase()) : [],
|
|
49
|
+
toolAliases: p.toolAliases !== null && typeof p.toolAliases === "object" ? Object.fromEntries(Object.entries(p.toolAliases as Record<string, unknown>).filter((e): e is [string, string] => typeof e[1] === "string")) : {},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Whether a tool result is worth sending: policy on, eligible tool, size in the window. */
|
|
54
|
+
export function shouldDigest(policy: DigestPolicy, tool: string, text: string): boolean {
|
|
55
|
+
if (!policy.enabled) return false;
|
|
56
|
+
const lower = tool.toLowerCase();
|
|
57
|
+
if (!policy.tools.includes(policy.toolAliases[lower] ?? lower)) return false;
|
|
58
|
+
const bytes = Buffer.byteLength(text);
|
|
59
|
+
return bytes >= policy.minBytes && bytes <= policy.maxBytes;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** One toast line for the last routed turn of a session. */
|
|
63
|
+
export function toastLine(e: { slug?: string; servedSlug?: string; tier?: string; reportedUsd?: number | null; predictedUsd?: number }): string {
|
|
64
|
+
const slug = e.servedSlug ?? e.slug ?? "?";
|
|
65
|
+
const provider = slug.startsWith("ollama/") ? "ollama" : "openrouter";
|
|
66
|
+
const usd = e.reportedUsd ?? e.predictedUsd ?? 0;
|
|
67
|
+
return `${provider} · ${slug.replace(/^ollama\//, "")} [${e.tier ?? "?"}] · $${usd.toFixed(5)}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const AutoModelRouter: Plugin = async ({ client }) => {
|
|
71
|
+
let policy = DISABLED;
|
|
72
|
+
let policyAtMs = 0;
|
|
73
|
+
const parents = new Map<string, string | null>();
|
|
74
|
+
const lastToasted = new Map<string, string>();
|
|
75
|
+
|
|
76
|
+
async function refreshPolicy(): Promise<DigestPolicy> {
|
|
77
|
+
if (Date.now() - policyAtMs < POLICY_TTL_MS) return policy;
|
|
78
|
+
policyAtMs = Date.now();
|
|
79
|
+
try {
|
|
80
|
+
const res = await fetch(`${BASE_URL}/v1/router/digest/policy`, { signal: AbortSignal.timeout(2_000) });
|
|
81
|
+
policy = res.ok ? parsePolicy(await res.json()) : DISABLED;
|
|
82
|
+
} catch {
|
|
83
|
+
policy = DISABLED;
|
|
84
|
+
}
|
|
85
|
+
return policy;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function parentOf(sessionID: string): Promise<string | null> {
|
|
89
|
+
const known = parents.get(sessionID);
|
|
90
|
+
if (known !== undefined) return known;
|
|
91
|
+
let parent: string | null = null;
|
|
92
|
+
try {
|
|
93
|
+
const res = await client.session.get({ path: { id: sessionID } });
|
|
94
|
+
parent = res.data?.parentID ?? null;
|
|
95
|
+
} catch {
|
|
96
|
+
parent = null;
|
|
97
|
+
}
|
|
98
|
+
parents.set(sessionID, parent);
|
|
99
|
+
return parent;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function toast(sessionID: string): Promise<void> {
|
|
103
|
+
try {
|
|
104
|
+
const res = await fetch(`${BASE_URL}/v1/router/decisions?limit=1&session=${encodeURIComponent(sessionID)}`, { signal: AbortSignal.timeout(2_000) });
|
|
105
|
+
if (!res.ok) return;
|
|
106
|
+
const body = (await res.json()) as { entries?: { id: string; slug?: string; servedSlug?: string; tier?: string; reportedUsd?: number | null; predictedUsd?: number }[] };
|
|
107
|
+
const entry = body.entries?.[0];
|
|
108
|
+
if (entry === undefined || lastToasted.get(sessionID) === entry.id) return;
|
|
109
|
+
lastToasted.set(sessionID, entry.id);
|
|
110
|
+
await client.tui.showToast({ body: { title: "auto-model-router", message: toastLine(entry), variant: "info", duration: 4_000 } });
|
|
111
|
+
} catch {
|
|
112
|
+
// No TUI (run mode) or router down: nothing to show.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
"chat.headers": async (input, output) => {
|
|
118
|
+
if (input.model.providerID !== PROVIDER_ID) return;
|
|
119
|
+
output.headers["X-Omp-Session"] = input.sessionID;
|
|
120
|
+
output.headers["X-Omp-Harness"] = HARNESS_ID;
|
|
121
|
+
if ((await parentOf(input.sessionID)) !== null) output.headers["X-Omp-Subagent"] = "1";
|
|
122
|
+
},
|
|
123
|
+
event: async ({ event }) => {
|
|
124
|
+
if (event.type === "session.created") {
|
|
125
|
+
const info = (event as { properties: { info: { id: string; parentID?: string } } }).properties.info;
|
|
126
|
+
parents.set(info.id, info.parentID ?? null);
|
|
127
|
+
} else if (event.type === "session.idle") {
|
|
128
|
+
await toast((event as { properties: { sessionID: string } }).properties.sessionID);
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
"tool.execute.after": async (input, output) => {
|
|
132
|
+
const p = await refreshPolicy();
|
|
133
|
+
if (typeof output.output !== "string" || !shouldDigest(p, input.tool, output.output)) return;
|
|
134
|
+
try {
|
|
135
|
+
const res = await fetch(`${BASE_URL}/v1/router/digest`, {
|
|
136
|
+
method: "POST",
|
|
137
|
+
headers: { "content-type": "application/json" },
|
|
138
|
+
body: JSON.stringify({ ompSessionId: input.sessionID, harnessId: HARNESS_ID, toolName: input.tool, input: input.args ?? {}, content: output.output, query: "" }),
|
|
139
|
+
signal: AbortSignal.timeout(30_000),
|
|
140
|
+
});
|
|
141
|
+
if (!res.ok) return;
|
|
142
|
+
const r = (await res.json()) as { digested: boolean; text?: string };
|
|
143
|
+
if (r.digested && typeof r.text === "string") output.output = r.text;
|
|
144
|
+
} catch {
|
|
145
|
+
// Router unreachable or slow: the raw result stands.
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export default AutoModelRouter;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LOCAL ambient stub for OpenCode's plugin API — not the vendor's types.
|
|
3
|
+
*
|
|
4
|
+
* `@opencode-ai/plugin` is resolved by OpenCode when it loads the plugin; it
|
|
5
|
+
* is not a dependency of this repo. This declares only the surface
|
|
6
|
+
* auto-model-router.ts uses, so the plugin is type-checked with the rest of
|
|
7
|
+
* the tree (tsconfig.all.json). The real types live in the package OpenCode
|
|
8
|
+
* installs under ~/.config/opencode/node_modules/@opencode-ai/plugin; treat a
|
|
9
|
+
* change here as a claim about that API that only a live session confirms.
|
|
10
|
+
*/
|
|
11
|
+
declare module "@opencode-ai/plugin" {
|
|
12
|
+
export interface SessionInfo {
|
|
13
|
+
id: string;
|
|
14
|
+
parentID?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface OpencodeClient {
|
|
17
|
+
session: {
|
|
18
|
+
get(options: { path: { id: string } }): Promise<{ data?: SessionInfo }>;
|
|
19
|
+
};
|
|
20
|
+
tui: {
|
|
21
|
+
showToast(options: { body: { title?: string; message: string; variant: "info" | "success" | "warning" | "error"; duration?: number } }): Promise<unknown>;
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export interface PluginInput {
|
|
25
|
+
client: OpencodeClient;
|
|
26
|
+
directory: string;
|
|
27
|
+
worktree: string;
|
|
28
|
+
}
|
|
29
|
+
export type Event =
|
|
30
|
+
| { type: "session.created"; properties: { info: SessionInfo } }
|
|
31
|
+
| { type: "session.idle"; properties: { sessionID: string } }
|
|
32
|
+
| { type: string; properties?: unknown };
|
|
33
|
+
export interface Hooks {
|
|
34
|
+
event?: (input: { event: Event }) => Promise<void>;
|
|
35
|
+
"chat.headers"?: (input: { sessionID: string; agent: string; model: { providerID?: string; id?: string } }, output: { headers: Record<string, string> }) => Promise<void>;
|
|
36
|
+
"tool.execute.after"?: (input: { tool: string; sessionID: string; callID: string; args: unknown }, output: { title: string; output: string; metadata: unknown }) => Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
export type Plugin = (input: PluginInput) => Promise<Hooks>;
|
|
39
|
+
}
|
package/package.json
CHANGED
package/src/server/digest.ts
CHANGED
|
@@ -121,6 +121,14 @@ interface RecentDigest {
|
|
|
121
121
|
atMs: number;
|
|
122
122
|
ledgerId: string;
|
|
123
123
|
rerun: boolean;
|
|
124
|
+
/**
|
|
125
|
+
* Whether the call that PRODUCED this digest has been seen. A tool_result
|
|
126
|
+
* digest is made before the next request, and that request's last
|
|
127
|
+
* assistant message carries the producing call; it must not count as a
|
|
128
|
+
* re-run. A compaction digest covers a call already in history, so its
|
|
129
|
+
* origin counts as seen from the start.
|
|
130
|
+
*/
|
|
131
|
+
originSeen: boolean;
|
|
124
132
|
}
|
|
125
133
|
const RERUN_WINDOW_MS = 2 * 3_600_000;
|
|
126
134
|
const RECENT_PER_SESSION = 50;
|
|
@@ -261,7 +269,7 @@ export function createDigester(deps: DigesterDeps): Digester {
|
|
|
261
269
|
if (text === "" || text.length >= inputBytes * 0.9) return { digested: false, reason: "digest did not shrink the output" };
|
|
262
270
|
if (req.ompSessionId !== "") {
|
|
263
271
|
const list = recent.get(req.ompSessionId) ?? [];
|
|
264
|
-
list.push({ tool: req.toolName.toLowerCase(), arg: primaryArg(JSON.stringify(req.input)), atMs: startedAt, ledgerId: entry.id, rerun: false });
|
|
272
|
+
list.push({ tool: req.toolName.toLowerCase(), arg: primaryArg(JSON.stringify(req.input)), atMs: startedAt, ledgerId: entry.id, rerun: false, originSeen: source === "compaction" });
|
|
265
273
|
recent.set(req.ompSessionId, list.slice(-RECENT_PER_SESSION));
|
|
266
274
|
}
|
|
267
275
|
return {
|
|
@@ -284,6 +292,11 @@ export function createDigester(deps: DigesterDeps): Digester {
|
|
|
284
292
|
if (arg === null) continue;
|
|
285
293
|
for (const d of list) {
|
|
286
294
|
if (d.rerun || d.tool !== tool || d.arg !== arg || nowMs - d.atMs > RERUN_WINDOW_MS) continue;
|
|
295
|
+
if (!d.originSeen) {
|
|
296
|
+
// The producing call, arriving in the next request's history.
|
|
297
|
+
d.originSeen = true;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
287
300
|
d.rerun = true;
|
|
288
301
|
marked++;
|
|
289
302
|
try {
|
package/src/server/http.ts
CHANGED
|
@@ -25,8 +25,9 @@ import { openDb } from "../util/sqlite.ts";
|
|
|
25
25
|
import { WireErrorException, renderErrorEnvelope } from "../wire/openai/errors.ts";
|
|
26
26
|
import { renderModelList } from "../wire/openai/models.ts";
|
|
27
27
|
import { parseChatRequest } from "../wire/openai/request.ts";
|
|
28
|
+
import { createResponsesBufferedSink, createResponsesStreamingSink, parseResponsesRequest } from "../wire/openai/responses.ts";
|
|
28
29
|
import { createBufferedSink, createStreamingSink } from "../wire/openai/sink.ts";
|
|
29
|
-
import type { NormRequest, WireError } from "../wire/types.ts";
|
|
30
|
+
import type { NormRequest, ResponseSink, WireError } from "../wire/types.ts";
|
|
30
31
|
import { runTurn } from "./turn.ts";
|
|
31
32
|
|
|
32
33
|
export interface StartedServer {
|
|
@@ -321,10 +322,19 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
321
322
|
inFlightTurns--;
|
|
322
323
|
};
|
|
323
324
|
|
|
324
|
-
|
|
325
|
+
/** A wire: how a request body becomes a NormRequest and how the turn is rendered back. */
|
|
326
|
+
interface Wire {
|
|
327
|
+
parse(body: unknown, headers: Headers): NormRequest;
|
|
328
|
+
streaming(model: string): { sink: ResponseSink; response: Response };
|
|
329
|
+
buffered(model: string): { sink: ResponseSink; response: Promise<Response> };
|
|
330
|
+
}
|
|
331
|
+
const CHAT_WIRE: Wire = { parse: parseChatRequest, streaming: createStreamingSink, buffered: createBufferedSink };
|
|
332
|
+
const RESPONSES_WIRE: Wire = { parse: parseResponsesRequest, streaming: createResponsesStreamingSink, buffered: createResponsesBufferedSink };
|
|
333
|
+
|
|
334
|
+
const handleTurn = async (req: Request, wire: Wire): Promise<Response> => {
|
|
325
335
|
let normReq: NormRequest;
|
|
326
336
|
try {
|
|
327
|
-
normReq =
|
|
337
|
+
normReq = wire.parse(await req.json(), req.headers);
|
|
328
338
|
} catch (err) {
|
|
329
339
|
// The slot was acquired before parsing; a rejected body never reaches
|
|
330
340
|
// runTurn's `finally`, so it must be released here or every malformed
|
|
@@ -338,9 +348,7 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
338
348
|
});
|
|
339
349
|
}
|
|
340
350
|
|
|
341
|
-
const { sink, response } = normReq.stream
|
|
342
|
-
? createStreamingSink(normReq.requestedModel)
|
|
343
|
-
: createBufferedSink(normReq.requestedModel);
|
|
351
|
+
const { sink, response } = normReq.stream ? wire.streaming(normReq.requestedModel) : wire.buffered(normReq.requestedModel);
|
|
344
352
|
|
|
345
353
|
// The client signal aborts the upstream dispatch on disconnect. runTurn is
|
|
346
354
|
// expected to render its own failures into the sink; this catch is the last
|
|
@@ -399,7 +407,14 @@ export function startServer(cfg: RouterConfig): StartedServer {
|
|
|
399
407
|
if (!acquireTurn()) {
|
|
400
408
|
return wireErrorResponse({ status: 429, code: "too_many_requests", message: "too many concurrent turns" });
|
|
401
409
|
}
|
|
402
|
-
return await
|
|
410
|
+
return await handleTurn(req, CHAT_WIRE);
|
|
411
|
+
}
|
|
412
|
+
if (req.method === "POST" && url.pathname === "/v1/responses") {
|
|
413
|
+
// The Responses API wire (Codex CLI). Same turn, different rendering.
|
|
414
|
+
if (!acquireTurn()) {
|
|
415
|
+
return wireErrorResponse({ status: 429, code: "too_many_requests", message: "too many concurrent turns" });
|
|
416
|
+
}
|
|
417
|
+
return await handleTurn(req, RESPONSES_WIRE);
|
|
403
418
|
}
|
|
404
419
|
if (req.method === "GET" && url.pathname === "/v1/models") {
|
|
405
420
|
return json(renderModelList(cfg, ledger.blendedRate(cfg.ledger.blendWindowDays)));
|
package/src/util/sse.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* frame is emitted per token on the hot path.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
const encoder = new TextEncoder();
|
|
7
|
+
export const encoder = new TextEncoder();
|
|
8
8
|
|
|
9
9
|
/** One `data:` frame carrying a JSON payload, terminated by a blank line. */
|
|
10
10
|
export function sseDataFrame(value: unknown): string {
|