auto-model-router 0.4.8 → 0.4.10
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 +119 -19
- package/hermes-plugin/native/__pycache__/__init__.cpython-311.pyc +0 -0
- package/hermes-plugin/native/selftest.py +103 -0
- package/package.json +1 -1
- package/src/config/defaults.ts +5 -0
- package/src/cost/ledger.ts +6 -1
- package/src/cost/summary.ts +3 -1
- package/src/wire/openai/responses.ts +28 -1
- package/test/fixtures/harness/cline-cli.json +815 -0
- package/test/fixtures/harness/kilo.json +487 -0
- package/test/fixtures/harness/roo.json +456 -0
- package/test/harness-requests.test.ts +37 -0
- package/test/hermes-plugin.test.ts +32 -0
- package/test/summary.test.ts +1 -1
- package/test/tokens.test.ts +2 -0
- package/test/wire-responses.test.ts +15 -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.10",
|
|
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.10",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/README.md
CHANGED
|
@@ -430,12 +430,14 @@ wire_api = "responses"
|
|
|
430
430
|
http_headers = { "X-Omp-Harness" = "codex" }
|
|
431
431
|
```
|
|
432
432
|
|
|
433
|
-
Verified live with codex 0.153: the captured
|
|
434
|
-
`test/fixtures/harness/codex-responses.json`. Stateless only —
|
|
435
|
-
`store: false` and the full input each turn; `previous_response_id`
|
|
436
|
-
rejected. Reasoning summaries and encrypted reasoning are not produced.
|
|
437
|
-
|
|
438
|
-
|
|
433
|
+
Verified live with codex 0.153, text and tool-call turns: the captured
|
|
434
|
+
request is `test/fixtures/harness/codex-responses.json`. Stateless only —
|
|
435
|
+
Codex sends `store: false` and the full input each turn; `previous_response_id`
|
|
436
|
+
is rejected. Reasoning summaries and encrypted reasoning are not produced.
|
|
437
|
+
Codex's thread id (sent in the body) becomes the session id and its agent
|
|
438
|
+
name marks subagents, so per-session reports, feedback over the HTTP API and
|
|
439
|
+
the subagent profile work without a plugin. No hooks: there is no toast,
|
|
440
|
+
digest or `/router`.
|
|
439
441
|
|
|
440
442
|
### Aider
|
|
441
443
|
|
|
@@ -447,21 +449,97 @@ aider --model openai/auto
|
|
|
447
449
|
|
|
448
450
|
Verified live with aider 0.86 (captured request:
|
|
449
451
|
`test/fixtures/harness/aider.json`). Aider sends no tool calls, so every turn
|
|
450
|
-
classifies on its text alone
|
|
451
|
-
|
|
452
|
-
(`extra_params: { extra_headers: { X-Omp-Harness: aider } }`). No session
|
|
453
|
-
id or hooks.
|
|
452
|
+
classifies on its text alone. It sends no custom headers by default; a model
|
|
453
|
+
settings file in the project adds the harness id (verified live):
|
|
454
454
|
|
|
455
|
-
|
|
455
|
+
```yaml
|
|
456
|
+
# .aider.model.settings.yml
|
|
457
|
+
- name: openai/auto
|
|
458
|
+
extra_params:
|
|
459
|
+
extra_headers:
|
|
460
|
+
X-Omp-Harness: aider
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
No session id or hooks.
|
|
464
|
+
|
|
465
|
+
### Cline CLI
|
|
466
|
+
|
|
467
|
+
```bash
|
|
468
|
+
cline auth -p openai -b http://127.0.0.1:8788/v1 -k local -m auto
|
|
469
|
+
cline -P openai -m auto "your task"
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
Verified live with cline 3.0 (captured request:
|
|
473
|
+
`test/fixtures/harness/cline-cli.json`). The CLI sends native tool calls
|
|
474
|
+
(`read_files`, `search_codebase`, `run_commands`, `fetch_web_content`, …),
|
|
475
|
+
all in `digest.toolAliases`, and no custom headers, so its rows carry no
|
|
476
|
+
harness id. No session id or hooks.
|
|
477
|
+
|
|
478
|
+
### Kilo Code CLI
|
|
479
|
+
|
|
480
|
+
Kilo's CLI is built on OpenCode, so its config is OpenCode's with a different
|
|
481
|
+
file name:
|
|
482
|
+
|
|
483
|
+
```json
|
|
484
|
+
// kilo.json in the project (or ~/.config/kilo/kilo.json)
|
|
485
|
+
{
|
|
486
|
+
"provider": {
|
|
487
|
+
"auto-model-router": {
|
|
488
|
+
"npm": "@ai-sdk/openai-compatible",
|
|
489
|
+
"name": "auto-model-router",
|
|
490
|
+
"options": { "baseURL": "http://127.0.0.1:8788/v1", "apiKey": "local", "headers": { "X-Omp-Harness": "kilo" } },
|
|
491
|
+
"models": { "auto": { "name": "auto" }, "auto-cheap": { "name": "auto-cheap" } }
|
|
492
|
+
}
|
|
493
|
+
},
|
|
494
|
+
"model": "auto-model-router/auto"
|
|
495
|
+
}
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
Verified live with kilo 7.5 (captured request:
|
|
499
|
+
`test/fixtures/harness/kilo.json`); tool names match OpenCode's. The
|
|
500
|
+
OpenCode plugin was not picked up from `.kilo/plugin`, `.opencode/plugin` or
|
|
501
|
+
the config's `plugin` list in this test, so Kilo is config-only for now.
|
|
502
|
+
|
|
503
|
+
### Roo Code (VS Code)
|
|
504
|
+
|
|
505
|
+
Roo's welcome screen has *Import Settings*; a profile file skips the form:
|
|
506
|
+
|
|
507
|
+
```json
|
|
508
|
+
{
|
|
509
|
+
"providerProfiles": {
|
|
510
|
+
"currentApiConfigName": "auto-model-router",
|
|
511
|
+
"apiConfigs": {
|
|
512
|
+
"auto-model-router": {
|
|
513
|
+
"apiProvider": "openai",
|
|
514
|
+
"openAiBaseUrl": "http://127.0.0.1:8788/v1",
|
|
515
|
+
"openAiApiKey": "local",
|
|
516
|
+
"openAiModelId": "auto",
|
|
517
|
+
"openAiHeaders": { "X-Omp-Harness": "roo" },
|
|
518
|
+
"openAiCustomModelInfo": { "maxTokens": 8192, "contextWindow": 400000, "supportsImages": true, "supportsPromptCache": true, "inputPrice": 0, "outputPrice": 0 },
|
|
519
|
+
"id": "amr-0001"
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
Verified live with Roo Code 3.54 (captured request:
|
|
527
|
+
`test/fixtures/harness/roo.json`, including a tool round trip): native tool
|
|
528
|
+
calls (`read_file`, `search_files`, `list_files`, `apply_diff`, …), all in
|
|
529
|
+
`digest.toolAliases`, and the harness header through `openAiHeaders`. Two
|
|
530
|
+
cautions: 3.54 announces itself as the last Roo Code release, and its
|
|
531
|
+
Architect mode loops on a model that never calls `attempt_completion`, so
|
|
532
|
+
start in Code mode or pin a stronger profile (`auto-max`) for it. No session
|
|
533
|
+
id or hooks: the digest applies only through summarising compaction.
|
|
534
|
+
|
|
535
|
+
### Cline (VS Code)
|
|
456
536
|
|
|
457
537
|
Choose the *OpenAI Compatible* provider in the extension's settings, set the
|
|
458
538
|
base URL to `http://127.0.0.1:8788/v1`, any API key, and the model id `auto`
|
|
459
|
-
(or `auto-cheap` / `auto-max`)
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
digest applies only through summarising compaction
|
|
464
|
-
(`compaction.digestToolResults`), which runs inside the router.
|
|
539
|
+
(or `auto-cheap` / `auto-max`); add `X-Omp-Harness` under custom headers if
|
|
540
|
+
offered. Not verified live here (the CLI above was); its tool names are in
|
|
541
|
+
`digest.toolAliases`, and the digest applies only through summarising
|
|
542
|
+
compaction.
|
|
465
543
|
|
|
466
544
|
### OpenCode
|
|
467
545
|
|
|
@@ -1101,6 +1179,25 @@ there); and the switch happens at prompt boundaries, never mid-turn.
|
|
|
1101
1179
|
|
|
1102
1180
|
## Multiple coding harnesses, one router
|
|
1103
1181
|
|
|
1182
|
+
**One router process for everything.** omp's embed extension binds a private
|
|
1183
|
+
router on an ephemeral port per session by default, while Hermes, Codex,
|
|
1184
|
+
OpenCode and Aider talk to a standalone router on port 8788. Those are two
|
|
1185
|
+
processes over two homes, and per-process state (pins, the digest re-run
|
|
1186
|
+
memory) and reports stay apart. To share one router:
|
|
1187
|
+
|
|
1188
|
+
1. Run it once, before the harnesses start: `auto-model-router serve --port 8788`
|
|
1189
|
+
(against the default home, `~/.auto-model-router`).
|
|
1190
|
+
2. Set `AUTO_MODEL_ROUTER_PORT=8788` in omp's environment. The embed then
|
|
1191
|
+
attaches to the router already answering on that port instead of binding
|
|
1192
|
+
its own (it still binds 8788 itself if nothing is there, which the others
|
|
1193
|
+
then reuse).
|
|
1194
|
+
3. Point Hermes, Codex, OpenCode and Aider at `http://127.0.0.1:8788/v1` as
|
|
1195
|
+
in their recipes. Hermes's provider plugin only spawns a router when
|
|
1196
|
+
nothing listens on 8788, so it joins the shared one too.
|
|
1197
|
+
|
|
1198
|
+
Each harness keeps its own `X-Omp-Harness` id, so budgets and reports stay
|
|
1199
|
+
per harness while the ledger, catalog and conversation state are shared.
|
|
1200
|
+
|
|
1104
1201
|
What each harness gets today. "Config only" means the OpenAI-compatible wire
|
|
1105
1202
|
plus a harness header; the rest needs the harness's own hook API.
|
|
1106
1203
|
|
|
@@ -1108,9 +1205,12 @@ plus a harness header; the rest needs the harness's own hook API.
|
|
|
1108
1205
|
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
|
1109
1206
|
| omp | native provider | yes | yes | yes | yes | full hub | yes | yes | experimental |
|
|
1110
1207
|
| Hermes | provider plugin | yes | yes (native plugin) | yes (native plugin) | no | text | yes (native plugin) | on demand | no |
|
|
1111
|
-
| Codex CLI | Responses API wire | yes |
|
|
1208
|
+
| Codex CLI | Responses API wire | yes | yes (from body) | yes (from body) | no | no | compaction only | no | no |
|
|
1112
1209
|
| Aider | config only | via model settings | no | no | no | no | no tools | no | no |
|
|
1113
|
-
| Cline
|
|
1210
|
+
| Cline CLI | config only | no | no | no | no | no | compaction only | no | no |
|
|
1211
|
+
| Kilo Code CLI | config only | yes | no | no | no | no | compaction only | no | no |
|
|
1212
|
+
| Roo Code (VS Code) | config only | yes | no | no | no | no | compaction only | no | no |
|
|
1213
|
+
| Cline (VS Code) | config only, unverified | if headers supported | no | no | no | no | compaction only | no | no |
|
|
1114
1214
|
| OpenCode | config + plugin | yes | yes (plugin) | yes (plugin) | yes (plugin) | no | yes (plugin) | no | no |
|
|
1115
1215
|
| Claude Code | needs an Anthropic Messages wire module | — | — | — | — | — | — | — | — |
|
|
1116
1216
|
|
|
Binary file
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Self-test for the Hermes native plugin's pure functions.
|
|
2
|
+
|
|
3
|
+
Run by test/hermes-plugin.test.ts when a Python interpreter is on PATH (the
|
|
4
|
+
Bun suite cannot import Python), and directly:
|
|
5
|
+
|
|
6
|
+
python hermes-plugin/native/selftest.py
|
|
7
|
+
|
|
8
|
+
Exercises identity headers, the digest gate and replacement, and the /router
|
|
9
|
+
command rendering against fake transports. Exits non-zero on the first
|
|
10
|
+
failure with the assertion that failed.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import importlib.util
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
21
|
+
spec = importlib.util.spec_from_file_location("amr_native", os.path.join(HERE, "__init__.py"))
|
|
22
|
+
m = importlib.util.module_from_spec(spec)
|
|
23
|
+
assert spec.loader is not None
|
|
24
|
+
spec.loader.exec_module(m)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_identity_headers() -> None:
|
|
28
|
+
m.on_pre_llm_call(session_id="s1", parent_session_id="")
|
|
29
|
+
m.on_pre_llm_call(session_id="s2", parent_session_id="s1")
|
|
30
|
+
r = m.on_llm_request(request={"model": "auto", "extra_headers": {"A": "b"}}, provider="auto-model-router", session_id="s2")
|
|
31
|
+
assert r["request"]["extra_headers"] == {"A": "b", "X-Omp-Harness": m.HARNESS_ID, "X-Omp-Session": "s2", "X-Omp-Subagent": "1"}, r
|
|
32
|
+
main = m.on_llm_request(request={"model": "auto"}, provider="auto-model-router", session_id="s1")
|
|
33
|
+
assert "X-Omp-Subagent" not in main["request"]["extra_headers"], main
|
|
34
|
+
assert m.on_llm_request(request={}, provider="anthropic", session_id="s1") is None
|
|
35
|
+
assert m._CURRENT["session_id"] == "s1"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_digest() -> None:
|
|
39
|
+
m.POLICY.value = {"enabled": True, "minBytes": 10, "maxBytes": 100000, "tools": ["read", "grep", "bash"], "toolAliases": {"read_file": "read", "terminal": "bash"}, "fromTier": "moderate"}
|
|
40
|
+
m.POLICY._at = 10**12
|
|
41
|
+
calls = []
|
|
42
|
+
|
|
43
|
+
def fake_post(path, payload, timeout=0):
|
|
44
|
+
calls.append((path, payload["toolName"], len(payload["content"])))
|
|
45
|
+
return {"digested": True, "text": "[digest] short"}
|
|
46
|
+
|
|
47
|
+
big = json.dumps({"content": "x" * 500, "path": "a.ts"})
|
|
48
|
+
out = m.maybe_digest(big, "read_file", {"path": "a.ts"}, "s1", post=fake_post)
|
|
49
|
+
assert json.loads(out)["content"] == "[digest] short", out
|
|
50
|
+
assert json.loads(out)["path"] == "a.ts"
|
|
51
|
+
assert calls == [("/v1/router/digest", "read_file", 500)], calls
|
|
52
|
+
err = json.dumps({"error": "nope", "content": "x" * 500})
|
|
53
|
+
assert m.maybe_digest(err, "read_file", {}, "s1", post=fake_post) == err
|
|
54
|
+
assert m.maybe_digest(json.dumps({"content": "x" * 500}), "write_file", {}, "s1", post=fake_post).startswith('{"content": "xxx')
|
|
55
|
+
plain = "plain text " * 100
|
|
56
|
+
assert m.maybe_digest(plain, "read_file", {}, "s1", post=fake_post) == plain
|
|
57
|
+
assert len(calls) == 1
|
|
58
|
+
assert m.canonical_tool(m.POLICY.value, "TERMINAL") == "bash"
|
|
59
|
+
assert m.largest_string_field({"a": "x" * 90, "b": "y" * 5}) == "a"
|
|
60
|
+
assert m.largest_string_field({"a": "x" * 50, "b": "y" * 50}) is None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_router_command() -> None:
|
|
64
|
+
def fake_get(path, timeout=5.0, text=False):
|
|
65
|
+
if path.startswith("/v1/router/report"):
|
|
66
|
+
return "REPORT " + path
|
|
67
|
+
if path.startswith("/v1/router/summary"):
|
|
68
|
+
return "SUMMARY " + path
|
|
69
|
+
if path.startswith("/health"):
|
|
70
|
+
return {"status": "ok", "apiKeyConfigured": True, "apiKeySource": "env", "catalog": {"models": 3}, "ollama": None, "softFailures": {"spikes": [{"slug": "a/b", "recentRate": 0.5, "recentDispatches": 8, "baselineRate": 0.05}]}}
|
|
71
|
+
if path.startswith("/v1/router/decisions"):
|
|
72
|
+
return {"entries": [{"slug": "a/b", "tier": "hard", "classificationSource": "heuristic", "confidence": 0.7, "reportedUsd": 0.0000097, "usage": {"promptTokens": 100, "cachedTokens": 50}, "latencyMs": 900, "reasons": ["r1"]}]}
|
|
73
|
+
return {}
|
|
74
|
+
|
|
75
|
+
def fake_post(path, payload, timeout=5.0):
|
|
76
|
+
if path == "/v1/router/feedback":
|
|
77
|
+
return {"slug": "a/b", "tier": "hard"}
|
|
78
|
+
return {"override": {"slug": payload.get("slug"), "tier": payload.get("tier"), "turnsLeft": payload.get("turns")}}
|
|
79
|
+
|
|
80
|
+
assert m.router_command("report 30 --all", get=fake_get, post=fake_post) == "REPORT /v1/router/report?days=30&format=text"
|
|
81
|
+
assert m.router_command("report", get=fake_get, post=fake_post).endswith(f"days=7&format=text&harness={m.HARNESS_ID}")
|
|
82
|
+
assert m.router_command("summary", get=fake_get, post=fake_post).startswith("SUMMARY /v1/router/summary?format=text&harness=")
|
|
83
|
+
status = m.router_command("status", get=fake_get, post=fake_post)
|
|
84
|
+
assert "soft failures SPIKING (1)" in status and "a/b: 50% of 8" in status, status
|
|
85
|
+
why = m.router_command("why", get=fake_get, post=fake_post)
|
|
86
|
+
assert "last turn: a/b [hard]" in why and "$0.00001" in why and "cache hit 50%" in why, why
|
|
87
|
+
assert m.router_command("good nice", get=fake_get, post=fake_post) == "recorded good for a/b [hard]"
|
|
88
|
+
assert m.router_command("pin a/b", get=fake_get, post=fake_post) == "pin: a/b"
|
|
89
|
+
assert m.router_command("tier hard 5", get=fake_get, post=fake_post) == "tier: hard for 5 turns"
|
|
90
|
+
assert m.router_command("tier off", get=fake_get, post=fake_post) == "tier: cleared"
|
|
91
|
+
assert m.router_command("bogus", get=fake_get, post=fake_post) == m.USAGE
|
|
92
|
+
|
|
93
|
+
def down(path, timeout=5.0, text=False):
|
|
94
|
+
raise OSError("connection refused")
|
|
95
|
+
|
|
96
|
+
assert m.router_command("status", get=down, post=fake_post).startswith("router unreachable at")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
for name, fn in [(n, f) for n, f in globals().items() if n.startswith("test_")]:
|
|
101
|
+
fn()
|
|
102
|
+
print(f"ok {name}")
|
|
103
|
+
sys.exit(0)
|
package/package.json
CHANGED
package/src/config/defaults.ts
CHANGED
|
@@ -322,6 +322,11 @@ export const DEFAULT_CONFIG: RouterConfig = {
|
|
|
322
322
|
shell: "bash",
|
|
323
323
|
web_extract: "web_fetch",
|
|
324
324
|
fetch_url: "web_fetch",
|
|
325
|
+
// Cline CLI 3.x
|
|
326
|
+
read_files: "read",
|
|
327
|
+
search_codebase: "grep",
|
|
328
|
+
run_commands: "bash",
|
|
329
|
+
fetch_web_content: "web_fetch",
|
|
325
330
|
},
|
|
326
331
|
},
|
|
327
332
|
report: {
|
package/src/cost/ledger.ts
CHANGED
|
@@ -376,6 +376,9 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
|
|
|
376
376
|
const ratioStmt = db.query("SELECT est_bytes, actual_tokens, samples FROM token_calibration WHERE tokenizer = ?");
|
|
377
377
|
const recentStmt = db.query("SELECT * FROM ledger ORDER BY created_at_ms DESC LIMIT ?");
|
|
378
378
|
const pruneStmt = db.query("DELETE FROM ledger WHERE created_at_ms < ?");
|
|
379
|
+
// Ollama meter samples (one per usage poll) only matter for the current
|
|
380
|
+
// billing cycle's calibration; they age out with the ledger rows.
|
|
381
|
+
const pruneMeterStmt = db.query("DELETE FROM ollama_meter_samples WHERE at_ms < ?");
|
|
379
382
|
const wasteStmt = db.query("UPDATE ledger SET wasted = 1 WHERE id = ?");
|
|
380
383
|
const providerSpendStmt = db.query(
|
|
381
384
|
"SELECT COALESCE(SUM(COALESCE(reported_usd, predicted_usd)), 0) AS total FROM ledger WHERE created_at_ms >= ? AND COALESCE(served_slug, slug) LIKE ?",
|
|
@@ -627,7 +630,9 @@ export function createLedger(db: Database, cfg: RouterConfig): Ledger {
|
|
|
627
630
|
},
|
|
628
631
|
prune(retentionDays: number, nowMs = Date.now()): number {
|
|
629
632
|
if (retentionDays <= 0) return 0;
|
|
630
|
-
|
|
633
|
+
const cutoff = nowMs - retentionDays * DAY_MS;
|
|
634
|
+
pruneMeterStmt.run(cutoff);
|
|
635
|
+
return pruneStmt.run(cutoff).changes;
|
|
631
636
|
},
|
|
632
637
|
markWasted(id: string): void {
|
|
633
638
|
wasteStmt.run(id);
|
package/src/cost/summary.ts
CHANGED
|
@@ -28,6 +28,7 @@ export interface SummaryWindow {
|
|
|
28
28
|
modelSwitches: number;
|
|
29
29
|
digests: number;
|
|
30
30
|
digestSpendUsd: number;
|
|
31
|
+
digestReruns: number;
|
|
31
32
|
subagentSpendUsd: number;
|
|
32
33
|
}
|
|
33
34
|
|
|
@@ -81,6 +82,7 @@ function windowOf(r: UsageReport): SummaryWindow {
|
|
|
81
82
|
modelSwitches: t.modelSwitches,
|
|
82
83
|
digests: t.digests,
|
|
83
84
|
digestSpendUsd: t.digestSpendUsd,
|
|
85
|
+
digestReruns: t.digestReruns,
|
|
84
86
|
subagentSpendUsd: t.subagentSpendUsd,
|
|
85
87
|
};
|
|
86
88
|
}
|
|
@@ -172,7 +174,7 @@ export function renderDailySummary(s: DailySummary): string {
|
|
|
172
174
|
out.push(b.savedShare >= 0 ? `saved ${pct(b.savedShare)} vs ${b.slug} (${usd(b.usd)} at list)` : `cost ${pct(-b.savedShare)} MORE than ${b.slug} (${usd(b.usd)} at list)`);
|
|
173
175
|
}
|
|
174
176
|
const extras: string[] = [];
|
|
175
|
-
if (c.digests > 0) extras.push(`${c.digests} digests for ${usd(c.digestSpendUsd)}`);
|
|
177
|
+
if (c.digests > 0) extras.push(`${c.digests} digests for ${usd(c.digestSpendUsd)} (re-run rate ${pct(c.digestReruns / c.digests)})`);
|
|
176
178
|
if (c.subagentSpendUsd > 0) extras.push(`subagents ${usd(c.subagentSpendUsd)}`);
|
|
177
179
|
if (extras.length > 0) out.push(extras.join(" · "));
|
|
178
180
|
}
|
|
@@ -123,8 +123,35 @@ export function responsesToChatBody(body: unknown): Rec {
|
|
|
123
123
|
return out;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Codex carries its identity in the body, not in headers: the thread id under
|
|
128
|
+
* `client_metadata` (also the `prompt_cache_key`), and the agent name inside
|
|
129
|
+
* the JSON-encoded `x-codex-turn-metadata` (`/root` is the main agent). When
|
|
130
|
+
* the caller sent no X-Omp-Session / X-Omp-Subagent header, they are derived
|
|
131
|
+
* from those, so Codex gets per-session reports, feedback and the subagent
|
|
132
|
+
* profile without a plugin.
|
|
133
|
+
*/
|
|
134
|
+
export function identityHeadersFromBody(body: unknown, headers: Headers): Headers {
|
|
135
|
+
if (!isRec(body)) return headers;
|
|
136
|
+
const h = new Headers(headers);
|
|
137
|
+
const cm = isRec(body.client_metadata) ? body.client_metadata : {};
|
|
138
|
+
if ((h.get("x-omp-session") ?? "").trim() === "") {
|
|
139
|
+
const id = [cm.thread_id, cm.session_id, body.prompt_cache_key].find((v): v is string => typeof v === "string" && v !== "");
|
|
140
|
+
if (id !== undefined) h.set("x-omp-session", id);
|
|
141
|
+
}
|
|
142
|
+
if ((h.get("x-omp-subagent") ?? "").trim() === "" && typeof cm["x-codex-turn-metadata"] === "string") {
|
|
143
|
+
try {
|
|
144
|
+
const meta: unknown = JSON.parse(cm["x-codex-turn-metadata"]);
|
|
145
|
+
if (isRec(meta) && typeof meta.agent_name === "string" && meta.agent_name !== "" && meta.agent_name !== "/root") h.set("x-omp-subagent", "1");
|
|
146
|
+
} catch {
|
|
147
|
+
// Not JSON: no subagent signal.
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return h;
|
|
151
|
+
}
|
|
152
|
+
|
|
126
153
|
export function parseResponsesRequest(body: unknown, headers: Headers): NormRequest {
|
|
127
|
-
const norm = parseChatRequest(responsesToChatBody(body), headers);
|
|
154
|
+
const norm = parseChatRequest(responsesToChatBody(body), identityHeadersFromBody(body, headers));
|
|
128
155
|
return { ...norm, protocol: "openai-responses" };
|
|
129
156
|
}
|
|
130
157
|
|