auto-model-router 0.3.1 → 0.3.3
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/CLAUDE.md +18 -98
- package/README.md +4 -3
- package/docs/AGENTDOX-BRIDGE.md +8 -2
- package/omp-extension/report-logic.ts +4 -3
- package/package.json +1 -1
- package/src/cli/config-wizard.ts +1 -1
- package/src/config/types.ts +3 -3
- package/src/context/bridge.ts +9 -1
- package/src/context/query.ts +58 -0
- package/src/context/types.ts +8 -1
- package/src/server/turn.ts +5 -2
- package/src/upstream/ollama-usage.ts +62 -4
- package/test/context-bridge.test.ts +12 -0
- package/test/context-query.test.ts +67 -0
- package/test/ollama.test.ts +31 -12
- package/test/report-logic.test.ts +2 -2
- package/tools/agentdox-e2e.ts +1 -0
|
@@ -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.3.
|
|
10
|
+
"version": "0.3.3",
|
|
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.3.
|
|
17
|
+
"version": "0.3.3",
|
|
18
18
|
"author": {
|
|
19
19
|
"name": "drewappling",
|
|
20
20
|
"email": "drewappling@gmail.com"
|
package/CLAUDE.md
CHANGED
|
@@ -11,108 +11,28 @@ It presents one keyless OpenAI-compatible provider and picks a concrete OpenRout
|
|
|
11
11
|
|
|
12
12
|
## agentdox — shared context/memory (**MANDATORY to keep updated**)
|
|
13
13
|
|
|
14
|
-
agentdox is this repo's memory + docs + live-conversation
|
|
15
|
-
**`omp-router`** — ALWAYS scope agentdox writes to it. The
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
the token grants every scope, so a wrong slug is **not** rejected — it silently writes into
|
|
19
|
-
another project. Getting `omp-router` right is on you, not on RBAC.
|
|
14
|
+
agentdox is this repo's memory + docs + live-conversation store. The project slug is
|
|
15
|
+
**`omp-router`** — ALWAYS scope agentdox writes to it. The bearer token (`AGENTDOX_TOKEN`)
|
|
16
|
+
is one global PAT that grants every scope, so a wrong slug is **not** rejected: it silently
|
|
17
|
+
files this project's data under another project. Getting `omp-router` right is on you.
|
|
20
18
|
|
|
21
|
-
**
|
|
19
|
+
**Full protocol: `.claude/skills/agentdox/SKILL.md`** (on connect, before asking the user,
|
|
20
|
+
batching writes to the end of the session, the REST fallback with request shapes, search
|
|
21
|
+
tips). Read it before using agentdox; do not improvise from this summary.
|
|
22
22
|
|
|
23
23
|
| What | Where |
|
|
24
24
|
| --- | --- |
|
|
25
|
-
| Token
|
|
26
|
-
| What `.mcp.json` reads | the `AGENTDOX_TOKEN` **environment variable
|
|
27
|
-
|
|
|
28
|
-
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
question. `docs_search` returns whole documents, which then get truncated, and the truncation
|
|
37
|
-
is rarely the relevant part.
|
|
38
|
-
- **If results look thin, run `index_stats {scope}` before concluding the store is empty.** It
|
|
39
|
-
reports how much of the scope is indexed and whether the embedding provider is reachable;
|
|
40
|
-
`embedded` far below `total`, or an unreachable provider, means you are getting keyword-only
|
|
41
|
-
results.
|
|
42
|
-
|
|
43
|
-
`.env.agentdox` is the durable record; the environment variable is what Claude Code actually
|
|
44
|
-
substitutes into `.mcp.json` at MCP-server startup. If agentdox MCP returns **401**, the
|
|
45
|
-
variable is missing from the environment — re-set it from `.env.agentdox` and restart Claude
|
|
46
|
-
Code (substitution happens once, at startup). Re-mint instructions are in `.env.agentdox`.
|
|
47
|
-
|
|
48
|
-
**Requirement: keeping agentdox current is part of completing any task, not optional.**
|
|
49
|
-
Do NOT close out a task while memory, docs, or conversation history for the area you touched
|
|
50
|
-
is stale or incomplete.
|
|
51
|
-
|
|
52
|
-
Concrete duties (all scoped to `omp-router`):
|
|
53
|
-
|
|
54
|
-
- **On connect:** run `project_ensure` with `slug: "omp-router"` before any memory/docs work.
|
|
55
|
-
- **On startup:** read `context_brief` to onboard on decisions, conventions, and gotchas
|
|
56
|
-
before rediscovering them.
|
|
57
|
-
- **Memory** (`memory_add` / `memory_update` / `memory_search`): record user-stated
|
|
58
|
-
preferences and corrections. When a fact changes, UPDATE the existing entry — never leave
|
|
59
|
-
contradictory facts. Keep entries compact and high-signal.
|
|
60
|
-
- **Docs** (`docs_write` / `docs_update`): keep architecture and decisions current as reality
|
|
61
|
-
changes; writing once is not enough.
|
|
62
|
-
- **Sessions** (`session_start` / `session_append`): append messages in real time, not as an
|
|
63
|
-
end-of-task summary.
|
|
64
|
-
- **Context** (`context_assemble`): consult it (with a query) before re-asking the user about
|
|
65
|
-
anything already captured.
|
|
66
|
-
- **Decisions** (`context_brief_record`): record decisions and conventions as they are made.
|
|
67
|
-
|
|
68
|
-
### How to actually call it (MCP tools vs REST)
|
|
69
|
-
|
|
70
|
-
The duties above name the **MCP tools** (`memory_add`, `docs_write`, `context_brief_record`, …),
|
|
71
|
-
served from this repo's `.mcp.json`.
|
|
72
|
-
|
|
73
|
-
**omp gets these tools too** — verified 2026-08-28. omp reads `.mcp.json` (repo root),
|
|
74
|
-
`.omp/mcp.json`, `.claude/mcp.json`, and `~/.omp/agent/mcp.json`, and it expands `${VAR}` in
|
|
75
|
-
headers. It mounts them **prefixed**: `agentdox_memory_add`, `agentdox_context_assemble`, …
|
|
76
|
-
(fully qualified `mcp__agentdox_*`). All 17 tools load.
|
|
77
|
-
|
|
78
|
-
The one prerequisite is `AGENTDOX_TOKEN` being present in the **launching shell's**
|
|
79
|
-
environment. It is persisted at Windows *User* scope, so only shells started afterwards
|
|
80
|
-
inherit it — an already-open terminal will show no agentdox tools until restarted.
|
|
81
|
-
|
|
82
|
-
**A harness genuinely without those tools (e.g. Hermes, or omp before the env var is
|
|
83
|
-
inherited) MUST use the REST API directly** — same live store, same RBAC. Don't skip recording
|
|
84
|
-
just because the MCP tools are absent.
|
|
85
|
-
|
|
86
|
-
REST basics: base `http://localhost:3003`, header `Authorization: Bearer <token>` where the token
|
|
87
|
-
is `AGENTDOX_TOKEN` from `.env.agentdox` (global; grants every scope). **memory uses
|
|
88
|
-
`category`, everything else uses `scope`; both are always `"omp-router"`.** MCP-tool → REST map:
|
|
89
|
-
|
|
90
|
-
| Duty / MCP tool | REST |
|
|
91
|
-
| --- | --- |
|
|
92
|
-
| `project_ensure` | `POST /projects` `{slug,name}` (idempotent; re-ensure returns the project) |
|
|
93
|
-
| `memory_search` | `GET /memory?category=omp-router&limit=N` · `GET /memory/search?q=…&category=omp-router` |
|
|
94
|
-
| `memory_add` | `POST /memory` `{content, category:"omp-router", importance:0..1}` |
|
|
95
|
-
| `memory_update` | `PATCH /memory/:id` `{content?, importance?, …}` (edit in place — never pile on dupes) |
|
|
96
|
-
| `docs_write` | `POST /docs` `{slug, title, content, scope:"omp-router", tags?}` |
|
|
97
|
-
| `docs_update` | `PATCH /docs/:id` `{title?, content?, tags?}` · list `GET /docs?scope=omp-router` |
|
|
98
|
-
| read `context_brief` | `GET /context/brief?scope=omp-router` (404 `no_brief` until seeded) |
|
|
99
|
-
| seed the brief | `PUT /context/brief` `{scope, overview?, repoLayout?, codeStyle?, buildTest?, assetConventions?, gotchas?}` |
|
|
100
|
-
| `context_brief_record` | `POST /context/brief/decision` `{scope, title, decision, rationale}` |
|
|
101
|
-
| `context_assemble` | `POST /context/assemble` `{scope, query}` · baseline `GET /context/snapshot?scope=` · `POST /context/refresh` `{scope}` |
|
|
102
|
-
| `session_start` / `session_append` | `POST /sessions` `{scope, title}` → `POST /sessions/:id/messages` `{role, content, refs?}` → `POST /sessions/:id/end` |
|
|
103
|
-
|
|
104
|
-
Cleanest call path (avoids Windows PowerShell mangling `$` in inline JSON): a throwaway
|
|
105
|
-
`bun` script that reads the token and `fetch`es —
|
|
106
|
-
|
|
107
|
-
```ts
|
|
108
|
-
const tok = /AGENTDOX_TOKEN=(.+)/.exec(await Bun.file(".env.agentdox").text())?.[1]?.trim() ?? "";
|
|
109
|
-
const H = { Authorization: `Bearer ${tok}`, "content-type": "application/json" };
|
|
110
|
-
await fetch("http://localhost:3003/memory", { method: "POST", headers: H,
|
|
111
|
-
body: JSON.stringify({ content: "…", category: "omp-router", importance: 0.9 }) });
|
|
112
|
-
```
|
|
113
|
-
|
|
114
|
-
Endpoints are defined in `E:/projects/agentdox/packages/server/src/index.ts`; the router's own
|
|
115
|
-
read/write client is `src/context/agentdox.ts` (assemble / createSession / append only).
|
|
25
|
+
| Token, URL, scope | `.env.agentdox` in this repo root (gitignored — never commit) |
|
|
26
|
+
| What `.mcp.json` reads | the `AGENTDOX_TOKEN` **environment variable** (Windows *User* scope; shells opened before it was set lack it) |
|
|
27
|
+
| Server | `http://localhost:3003` — Docker container `agentdox-server`; endpoints in `E:/projects/agentdox/packages/server/src/index.ts` |
|
|
28
|
+
| Admin token (re-mint) | `E:/projects/agentdox/deploy/.env` |
|
|
29
|
+
|
|
30
|
+
Two rules that cause silent mistakes: **memory calls take `category`, everything else takes
|
|
31
|
+
`scope`** (both always `"omp-router"`); and a **401 means the env var is missing** from the
|
|
32
|
+
launching shell — re-set it from `.env.agentdox` and restart the harness. omp mounts the
|
|
33
|
+
tools prefixed (`agentdox_memory_add`, …); a harness without them MUST use REST (the skill
|
|
34
|
+
has the map) rather than skip recording. Cleanest REST call path is a throwaway `bun`
|
|
35
|
+
script reading the token from `.env.agentdox`.
|
|
116
36
|
|
|
117
37
|
## This repo also *implements* an agentdox client
|
|
118
38
|
|
package/README.md
CHANGED
|
@@ -623,7 +623,7 @@ an OpenRouter sibling). See [Ollama Cloud](#ollama-cloud) below.
|
|
|
623
623
|
| `usagePollMs` | `600000` (10 min) | How often plan usage is re-read. `0` disables it (static bias). Needs the API key; the daemon path without one keeps a static bias. |
|
|
624
624
|
| `quotaCooldownMs` | `900000` | Route around Ollama this long after a 402 (credits exhausted). |
|
|
625
625
|
| `rateLimitCooldownMs` | `60000` | Route around Ollama this long after a 429 (concurrency cap). |
|
|
626
|
-
| `planCreditsUsd` | `0` |
|
|
626
|
+
| `planCreditsUsd` | `0` | Override for the plan's included monthly credits. `0` detects the plan from ollama.com (`POST /api/me`) and applies its published allowance (Pro $60, Max $300), so `/health` and `/router status` show ollama.com's reading as dollars next to the ledger's figure. Set it for a plan the router does not know. |
|
|
627
627
|
|
|
628
628
|
### `tiers` — per-tier economic envelope
|
|
629
629
|
|
|
@@ -824,8 +824,9 @@ What happens once it is on:
|
|
|
824
824
|
previous prompt is taken as the cached prefix and priced at the cached
|
|
825
825
|
rate; a first turn, a switch, or a longer gap is priced cold. The ledger
|
|
826
826
|
flags these rows (`usage.cachedEstimated`) and reports show their cache
|
|
827
|
-
rate as `~N%`.
|
|
828
|
-
|
|
827
|
+
rate as `~N%`. `/router status` shows ollama.com's own dollar reading as
|
|
828
|
+
the cross-check: the plan is read from `POST /api/me` and its published
|
|
829
|
+
allowance applied (`planCreditsUsd` overrides it).
|
|
829
830
|
- **Same economics, same failover.** Candidates from both providers are ranked
|
|
830
831
|
together; `costBias` tilts the comparison while a plan's included credits
|
|
831
832
|
would otherwise go unused. **Credit-aware by default:** the router reads the
|
package/docs/AGENTDOX-BRIDGE.md
CHANGED
|
@@ -175,8 +175,14 @@ would fix that, at the cost of one credential reaching every project.
|
|
|
175
175
|
so router test turns feed back into the next block. The omp-router scope had accumulated 19
|
|
176
176
|
sessions of which 18 were noise (`hi`, `say hello`, `Reply with exactly the word: PONG`,
|
|
177
177
|
injection probes, `bridge e2e …`); they were deleted, and `tools/agentdox-e2e.ts` writes two
|
|
178
|
-
more every run.
|
|
179
|
-
|
|
178
|
+
more every run. **Done 2026-09-07:** recent sessions ride only on a conversation's FIRST
|
|
179
|
+
block (`ContextResolveInput.firstFetch`); refreshes ask for `sessionLimit: 0`, since by then
|
|
180
|
+
the section was this conversation's own turns (1.4-1.9k chars per block, changing every
|
|
181
|
+
refresh). In the same change the relevance query became `context/query.ts`
|
|
182
|
+
`relevanceQuery`: the last user message with real content after omp's wrapper elements
|
|
183
|
+
(`<system-reminder>`, `<recap>`, `<chat>`, image stubs) are stripped, capped at 400 chars —
|
|
184
|
+
live blocks had been queried with those wrappers verbatim, one header running to 2k chars.
|
|
185
|
+
Recorded user turns use the same cleaner.
|
|
180
186
|
- **`context.timeoutMs` is 3000ms** and failures degrade silently at `debug` level by design.
|
|
181
187
|
If agentdox is cold this can no-op invisibly. Consider logging the first failure at `warn`.
|
|
182
188
|
- **Four copies of this project exist** on this machine: this repo, the research checkout,
|
|
@@ -74,8 +74,8 @@ export interface HealthSnapshot {
|
|
|
74
74
|
available?: boolean;
|
|
75
75
|
cooldownUntilMs?: number | null;
|
|
76
76
|
lastTrip?: { kind?: string; atMs?: number; message?: string } | null;
|
|
77
|
-
usage?: { monthlyUsedFraction?: number | null; activityCostUsd?: number | null; fetchedAtMs?: number | null } | null;
|
|
78
|
-
meter?: { usedUsd?: number; creditsUsd?: number } | null;
|
|
77
|
+
usage?: { monthlyUsedFraction?: number | null; activityCostUsd?: number | null; plan?: string | null; fetchedAtMs?: number | null } | null;
|
|
78
|
+
meter?: { usedUsd?: number; creditsUsd?: number; plan?: string | null } | null;
|
|
79
79
|
costBias?: { configured?: number; effective?: number; biasUntilUsage?: number };
|
|
80
80
|
} | null;
|
|
81
81
|
catalog?: {
|
|
@@ -106,7 +106,8 @@ export function renderStatus(baseUrl: string, h: HealthSnapshot, nowMs = Date.no
|
|
|
106
106
|
const avail = o.available === true ? "available" : `COOLING DOWN${o.cooldownUntilMs ? ` until ${new Date(o.cooldownUntilMs).toLocaleTimeString()}` : ""}`;
|
|
107
107
|
const frac = o.usage?.monthlyUsedFraction;
|
|
108
108
|
const meter = o.meter !== undefined && o.meter !== null && o.meter.usedUsd !== undefined ? ` ($${o.meter.usedUsd.toFixed(2)} of $${o.meter.creditsUsd ?? "?"})` : "";
|
|
109
|
-
const
|
|
109
|
+
const planName = o.meter?.plan ?? o.usage?.plan ?? null;
|
|
110
|
+
const usage = frac === undefined || frac === null ? "plan usage unknown" : `${planName === null ? "plan" : `${planName} plan`} usage ${(frac * 100).toFixed(1)}%${meter}`;
|
|
110
111
|
const bias = o.costBias === undefined ? "" : ` · cost bias ×${o.costBias.effective ?? o.costBias.configured ?? 1} (until ${((o.costBias.biasUntilUsage ?? 1) * 100).toFixed(0)}%)`;
|
|
111
112
|
const trip = o.lastTrip !== undefined && o.lastTrip !== null ? ` · last trip ${o.lastTrip.kind ?? "?"}${o.lastTrip.atMs ? ` ${mins(nowMs - o.lastTrip.atMs)} ago` : ""}` : "";
|
|
112
113
|
out.push(`ollama cloud: ${o.models ?? 0} models · ${avail} · key ${o.apiKeySource ?? "?"} · ${usage}${bias}${trip}`);
|
package/package.json
CHANGED
package/src/cli/config-wizard.ts
CHANGED
|
@@ -143,7 +143,7 @@ export const WIZARD_SECTIONS: readonly SectionSpec[] = [
|
|
|
143
143
|
{ path: "ollama.usagePollMs", label: "Plan usage poll", kind: "number", min: 0, hint: "ms, 0=off" },
|
|
144
144
|
{ path: "ollama.quotaCooldownMs", label: "Quota (402) cooldown", kind: "number", min: 0, hint: "ms" },
|
|
145
145
|
{ path: "ollama.rateLimitCooldownMs", label: "Rate-limit (429) cooldown", kind: "number", min: 0, hint: "ms" },
|
|
146
|
-
{ path: "ollama.planCreditsUsd", label: "Plan credits per month $", kind: "number", min: 0, hint: "Pro 60, Max 300
|
|
146
|
+
{ path: "ollama.planCreditsUsd", label: "Plan credits per month $", kind: "number", min: 0, hint: "0=detect plan (Pro 60, Max 300)" },
|
|
147
147
|
],
|
|
148
148
|
},
|
|
149
149
|
{
|
package/src/config/types.ts
CHANGED
|
@@ -115,9 +115,9 @@ export interface OllamaConfig {
|
|
|
115
115
|
/** How long to route around Ollama after a 429 (concurrency cap), ms. */
|
|
116
116
|
rateLimitCooldownMs: number;
|
|
117
117
|
/**
|
|
118
|
-
*
|
|
119
|
-
* the plan
|
|
120
|
-
*
|
|
118
|
+
* Override for the plan's included monthly credits, USD. 0 (default) reads
|
|
119
|
+
* the plan from ollama.com (`POST /api/me`) and applies its published
|
|
120
|
+
* allowance (Pro 60, Max 300); set this for a plan the router does not know.
|
|
121
121
|
*/
|
|
122
122
|
planCreditsUsd: number;
|
|
123
123
|
}
|
package/src/context/bridge.ts
CHANGED
|
@@ -121,7 +121,15 @@ export function createContextBridge(opts: BridgeOptions): ContextBridge {
|
|
|
121
121
|
return { ...pinned, fetchedAtMs: input.pinnedFetchedAtMs };
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
|
|
124
|
+
// Recent sessions only on a conversation's first block: after that they
|
|
125
|
+
// are this conversation's own recorded turns, duplicating the prompt
|
|
126
|
+
// and changing on every refresh (measured 1.4-1.9k chars per block).
|
|
127
|
+
const raw = await client.assemble(input.scope, input.query, {
|
|
128
|
+
memoryLimit,
|
|
129
|
+
docsLimit,
|
|
130
|
+
sessionLimit: input.firstFetch ? sessionLimit : 0,
|
|
131
|
+
briefChars,
|
|
132
|
+
});
|
|
125
133
|
if (raw === null) {
|
|
126
134
|
// agentdox unreachable or empty. Keep serving the pinned block if we
|
|
127
135
|
// have one: stale shared context beats none, and re-using it also
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What to tell agentdox this turn is about.
|
|
3
|
+
*
|
|
4
|
+
* The relevance query biases which memories and docs the assembled block
|
|
5
|
+
* carries, and agentdox echoes it verbatim into the block's header. Taking
|
|
6
|
+
* the last user-role message literally sent omp's own machinery instead of
|
|
7
|
+
* the user's ask: `<system-reminder>` nudges, `<system-notice>` job results,
|
|
8
|
+
* `<recap>` prompts, "Attached image(s) from tool result:" continuations.
|
|
9
|
+
* Measured on the live ledger: the three most recent blocks for one scope
|
|
10
|
+
* were queried with `<chat>`, `<system-reminder>` and an image notice, one
|
|
11
|
+
* header alone ran to 2k chars, and every refresh re-ranked memory against
|
|
12
|
+
* noise — different bytes each time, at the head of the cached prefix.
|
|
13
|
+
*
|
|
14
|
+
* The query is the last user message with real content once wrapper blocks
|
|
15
|
+
* are stripped, falling back to the conversation's opening ask, capped so the
|
|
16
|
+
* header stays a line rather than a message.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { NormRequest } from "../wire/types.ts";
|
|
20
|
+
|
|
21
|
+
/** Longest query worth sending: enough to rank on, short enough to stay stable. */
|
|
22
|
+
export const MAX_QUERY_CHARS = 400;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Harness wrapper tags whose whole element is machinery, not the user's ask.
|
|
26
|
+
* Stripped before judging whether a message has content.
|
|
27
|
+
*/
|
|
28
|
+
const WRAPPER_TAGS = ["system-reminder", "system-notice", "system-directive", "recap", "chat", "checkpoint-active-reminder", "interrupted-thinking"];
|
|
29
|
+
|
|
30
|
+
const WRAPPER_RE = new RegExp(`<(${WRAPPER_TAGS.join("|")})(\\s[^>]*)?>[\\s\\S]*?</\\1>`, "gi");
|
|
31
|
+
/** An unclosed wrapper at the start swallows the rest: treat the message as machinery. */
|
|
32
|
+
const OPEN_WRAPPER_RE = new RegExp(`^\\s*<(${WRAPPER_TAGS.join("|")})(\\s[^>]*)?>`, "i");
|
|
33
|
+
/** omp's tool-result image continuation and similar auto-generated stubs. */
|
|
34
|
+
const STUB_RE = /^(attached image\(s\)(\s.*)?|\[image\]|\(no output\)|continue[.!]?)$/i;
|
|
35
|
+
|
|
36
|
+
/** The user's own words in a message, or "" when it is all harness machinery. */
|
|
37
|
+
export function userContent(text: string): string {
|
|
38
|
+
let t = text.replace(WRAPPER_RE, " ");
|
|
39
|
+
if (OPEN_WRAPPER_RE.test(t)) return "";
|
|
40
|
+
t = t.replace(/\s+/g, " ").trim();
|
|
41
|
+
if (t === "" || STUB_RE.test(t)) return "";
|
|
42
|
+
return t;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function cap(text: string): string {
|
|
46
|
+
return text.length > MAX_QUERY_CHARS ? `${text.slice(0, MAX_QUERY_CHARS - 1)}…` : text;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The relevance query for this turn: last real user message, else the opening ask, else "". */
|
|
50
|
+
export function relevanceQuery(req: Pick<NormRequest, "messages">): string {
|
|
51
|
+
for (let i = req.messages.length - 1; i >= 0; i--) {
|
|
52
|
+
const m = req.messages[i];
|
|
53
|
+
if (m === undefined || m.role !== "user") continue;
|
|
54
|
+
const content = userContent(m.text);
|
|
55
|
+
if (content !== "") return cap(content);
|
|
56
|
+
}
|
|
57
|
+
return "";
|
|
58
|
+
}
|
package/src/context/types.ts
CHANGED
|
@@ -38,8 +38,15 @@ export interface ContextResolveInput {
|
|
|
38
38
|
modelSwitching: boolean;
|
|
39
39
|
/** An escalation or failover retry — the prefix is cold either way. */
|
|
40
40
|
retrying: boolean;
|
|
41
|
-
/**
|
|
41
|
+
/** The user's ask this turn (see `context/query.ts`), used to bias agentdox relevance ranking. */
|
|
42
42
|
query: string;
|
|
43
|
+
/**
|
|
44
|
+
* This conversation has not been given a block before. Recent sessions
|
|
45
|
+
* are included only then: they carry a previous conversation forward, but
|
|
46
|
+
* on a refresh they would mostly be this conversation's own turns, which
|
|
47
|
+
* the prompt already holds.
|
|
48
|
+
*/
|
|
49
|
+
firstFetch: boolean;
|
|
43
50
|
}
|
|
44
51
|
|
|
45
52
|
/** One settled turn, recorded to agentdox with the model that served it. */
|
package/src/server/turn.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { CatalogSource } from "../catalog/types.ts";
|
|
12
|
+
import { relevanceQuery, userContent } from "../context/query.ts";
|
|
12
13
|
import type { ContextBridge } from "../context/types.ts";
|
|
13
14
|
import type { RouterConfig } from "../config/types.ts";
|
|
14
15
|
import { estimateUnreportedCache } from "../cost/cache-estimate.ts";
|
|
@@ -159,7 +160,8 @@ export async function runTurn(
|
|
|
159
160
|
pinnedFetchedAtMs: state.contextFetchedAtMs,
|
|
160
161
|
modelSwitching: state.currentSlug !== null && state.currentSlug !== decision.slug,
|
|
161
162
|
retrying: attempt > 0,
|
|
162
|
-
query:
|
|
163
|
+
query: relevanceQuery(req),
|
|
164
|
+
firstFetch: state.contextVersion === null,
|
|
163
165
|
});
|
|
164
166
|
if (pin !== null) {
|
|
165
167
|
contextBlock = pin.block;
|
|
@@ -589,7 +591,8 @@ export async function runTurn(
|
|
|
589
591
|
// tool-less session is therefore not transcribed: silence beats garbage,
|
|
590
592
|
// because every junk record is re-injected into every later turn.
|
|
591
593
|
if (doxActive && req.tools.length > 0) {
|
|
592
|
-
|
|
594
|
+
// Record the user's words, not omp's wrappers (recap prompts, reminders).
|
|
595
|
+
const userText = userContent(lastUserText(req));
|
|
593
596
|
const turnEnded = finishReason !== "tool_calls";
|
|
594
597
|
log.debug("agentdox record turn", {
|
|
595
598
|
conversationKey: req.conversationKey.slice(0, 8),
|
|
@@ -35,9 +35,26 @@ export interface OllamaUsage {
|
|
|
35
35
|
activityCostUsd: number | null;
|
|
36
36
|
/** Requests this billing month, summed over models. */
|
|
37
37
|
requestsThisMonth: number;
|
|
38
|
+
/** Subscription name from `POST /api/me` (`pro`, `max`, …), or null when unknown. */
|
|
39
|
+
plan: string | null;
|
|
38
40
|
fetchedAtMs: number;
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Included monthly credits per plan, USD, from ollama.com/pricing (2026-09-07):
|
|
45
|
+
* Pro $20/mo carries $60 of usage, Max $100/mo carries $300. The dashboard's
|
|
46
|
+
* dollar figure is `limits.monthly.usage` × this. A plan not listed here
|
|
47
|
+
* (free, team, an unseen tier) yields no dollar reading rather than a guess.
|
|
48
|
+
*/
|
|
49
|
+
export const PLAN_CREDITS_USD: Readonly<Record<string, number>> = { pro: 60, max: 300 };
|
|
50
|
+
|
|
51
|
+
/** The plan named by an `/api/me` payload, lower-cased, or null. */
|
|
52
|
+
export function parseOllamaPlan(json: unknown): string | null {
|
|
53
|
+
const root = asRec(json);
|
|
54
|
+
const plan = root?.Plan ?? root?.plan;
|
|
55
|
+
return typeof plan === "string" && plan.trim() !== "" ? plan.trim().toLowerCase() : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
41
58
|
function asRec(v: unknown): Record<string, unknown> | null {
|
|
42
59
|
return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
|
|
43
60
|
}
|
|
@@ -72,6 +89,7 @@ export function parseOllamaUsage(json: unknown, nowMs = Date.now()): OllamaUsage
|
|
|
72
89
|
monthlyUsageRaw: usageRaw,
|
|
73
90
|
activityCostUsd: Number.isFinite(cost) ? cost : null,
|
|
74
91
|
requestsThisMonth: requests,
|
|
92
|
+
plan: null,
|
|
75
93
|
fetchedAtMs: nowMs,
|
|
76
94
|
};
|
|
77
95
|
}
|
|
@@ -98,8 +116,35 @@ export function createOllamaUsageSource(
|
|
|
98
116
|
let checkedAtMs = 0;
|
|
99
117
|
let inflight: Promise<OllamaUsage | null> | null = null;
|
|
100
118
|
let warned = false;
|
|
119
|
+
let plan: string | null = null;
|
|
120
|
+
let planWarned = false;
|
|
121
|
+
|
|
122
|
+
/** Best-effort: a missing plan only costs the dollar reading, never the bias. */
|
|
123
|
+
async function refreshPlan(): Promise<void> {
|
|
124
|
+
try {
|
|
125
|
+
const res = await fetchImpl(`${root}/api/me`, {
|
|
126
|
+
method: "POST",
|
|
127
|
+
headers: { authorization: `Bearer ${opts.apiKey}` },
|
|
128
|
+
signal: AbortSignal.timeout(opts.timeoutMs),
|
|
129
|
+
});
|
|
130
|
+
if (res.ok) {
|
|
131
|
+
const parsed = parseOllamaPlan(await res.json());
|
|
132
|
+
if (parsed !== null) plan = parsed;
|
|
133
|
+
} else if (!planWarned) {
|
|
134
|
+
planWarned = true;
|
|
135
|
+
opts.log.warn("ollama account endpoint unavailable; plan stays unknown", { status: res.status });
|
|
136
|
+
}
|
|
137
|
+
} catch (err) {
|
|
138
|
+
if (!planWarned) {
|
|
139
|
+
planWarned = true;
|
|
140
|
+
opts.log.warn("ollama account fetch failed; plan stays unknown", { error: err instanceof Error ? err.message : String(err) });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
101
144
|
|
|
102
145
|
async function refresh(): Promise<OllamaUsage | null> {
|
|
146
|
+
// The plan changes rarely, but the call is one small request per poll.
|
|
147
|
+
await refreshPlan();
|
|
103
148
|
try {
|
|
104
149
|
const res = await fetchImpl(`${root}/api/usage`, {
|
|
105
150
|
headers: { authorization: `Bearer ${opts.apiKey}` },
|
|
@@ -108,7 +153,7 @@ export function createOllamaUsageSource(
|
|
|
108
153
|
if (res.ok) {
|
|
109
154
|
const parsed = parseOllamaUsage(await res.json());
|
|
110
155
|
if (parsed !== null) {
|
|
111
|
-
current = parsed;
|
|
156
|
+
current = { ...parsed, plan };
|
|
112
157
|
warned = false;
|
|
113
158
|
} else if (!warned) {
|
|
114
159
|
warned = true;
|
|
@@ -156,8 +201,21 @@ export function effectiveOllamaBias(costBias: number, biasUntilUsage: number, us
|
|
|
156
201
|
return used >= biasUntilUsage ? 1 : costBias;
|
|
157
202
|
}
|
|
158
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Included credits for this account: the configured override when set, else
|
|
206
|
+
* the detected plan's published allowance, else null.
|
|
207
|
+
*/
|
|
208
|
+
export function ollamaPlanCredits(usage: OllamaUsage | null, overrideUsd: number): number | null {
|
|
209
|
+
if (overrideUsd > 0) return overrideUsd;
|
|
210
|
+
const plan = usage?.plan ?? null;
|
|
211
|
+
if (plan === null) return null;
|
|
212
|
+
return PLAN_CREDITS_USD[plan] ?? null;
|
|
213
|
+
}
|
|
214
|
+
|
|
159
215
|
/** The dashboard's dollar reading: plan share × included credits, when both are known. */
|
|
160
|
-
export function ollamaMeter(usage: OllamaUsage | null,
|
|
161
|
-
if (usage === null || usage.monthlyUsedFraction === null
|
|
162
|
-
|
|
216
|
+
export function ollamaMeter(usage: OllamaUsage | null, overrideUsd: number): { usedUsd: number; creditsUsd: number; plan: string | null } | null {
|
|
217
|
+
if (usage === null || usage.monthlyUsedFraction === null) return null;
|
|
218
|
+
const credits = ollamaPlanCredits(usage, overrideUsd);
|
|
219
|
+
if (credits === null) return null;
|
|
220
|
+
return { usedUsd: Math.round(usage.monthlyUsedFraction * credits * 100) / 100, creditsUsd: credits, plan: usage.plan };
|
|
163
221
|
}
|
|
@@ -72,11 +72,23 @@ function input(over: Partial<ContextResolveInput> = {}): ContextResolveInput {
|
|
|
72
72
|
modelSwitching: false,
|
|
73
73
|
retrying: false,
|
|
74
74
|
query: "movement rules",
|
|
75
|
+
firstFetch: true,
|
|
75
76
|
...over,
|
|
76
77
|
};
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
describe("context bridge refresh policy", () => {
|
|
81
|
+
test("recent sessions ride only on a conversation's first block; refreshes ask for none", async () => {
|
|
82
|
+
const client = mkClient();
|
|
83
|
+
const { bridge } = mkBridge(client, { sessionLimit: 6 });
|
|
84
|
+
await bridge.resolve(input({ firstFetch: true }));
|
|
85
|
+
expect(client.lastLimits?.sessionLimit).toBe(6);
|
|
86
|
+
// A refresh (model switch) on a conversation that already had a block.
|
|
87
|
+
await bridge.resolve(input({ firstFetch: false, modelSwitching: true, pinnedVersion: "stale", pinnedFetchedAtMs: 1 }));
|
|
88
|
+
expect(client.lastLimits?.sessionLimit).toBe(0);
|
|
89
|
+
expect(client.assembleCalls).toBe(2);
|
|
90
|
+
});
|
|
91
|
+
|
|
80
92
|
test("fetches on the first turn, then pins without re-fetching", async () => {
|
|
81
93
|
const client = mkClient();
|
|
82
94
|
const { bridge, db } = mkBridge(client);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { MAX_QUERY_CHARS, relevanceQuery, userContent } from "../src/context/query.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The agentdox relevance query must be the user's ask, not omp's wrappers:
|
|
7
|
+
* live blocks were queried with `<chat>`, `<system-reminder>` and an image
|
|
8
|
+
* notice, re-ranking memory against noise on every refresh.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const msg = (role: "user" | "assistant" | "system" | "tool", text: string) => ({ role, text }) as never;
|
|
12
|
+
|
|
13
|
+
describe("userContent", () => {
|
|
14
|
+
test("strips wrapper elements and keeps the user's words", () => {
|
|
15
|
+
expect(userContent("<system-reminder>\n5 todo items still open.\n</system-reminder>\nplease fix the water tests")).toBe("please fix the water tests");
|
|
16
|
+
expect(userContent("<recap>User stepped away; returning.</recap>")).toBe("");
|
|
17
|
+
expect(userContent("<system-notice>Background job bg_43 completed.</system-notice>")).toBe("");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("an unclosed wrapper at the start is all machinery", () => {
|
|
21
|
+
expect(userContent("<chat>\nassistant said things\nuser said things")).toBe("");
|
|
22
|
+
expect(userContent("<system-reminder>Today: 2026-09-07")).toBe("");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("auto-generated stubs are not content", () => {
|
|
26
|
+
expect(userContent("Attached image(s) from tool result:")).toBe("");
|
|
27
|
+
expect(userContent("(no output)")).toBe("");
|
|
28
|
+
expect(userContent("continue")).toBe("");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("ordinary text passes through with whitespace collapsed", () => {
|
|
32
|
+
expect(userContent(" why does the\n\nrouter pick glm? ")).toBe("why does the router pick glm?");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("relevanceQuery", () => {
|
|
37
|
+
test("takes the last user message with real content, skipping wrapper-only turns", () => {
|
|
38
|
+
const req = {
|
|
39
|
+
messages: [
|
|
40
|
+
msg("system", "You are omp."),
|
|
41
|
+
msg("user", "add a /router command with reports"),
|
|
42
|
+
msg("assistant", "done"),
|
|
43
|
+
msg("user", "<system-reminder>5 todo items still open.</system-reminder>"),
|
|
44
|
+
msg("tool", "Attached image(s) from tool result:"),
|
|
45
|
+
msg("user", "Attached image(s) from tool result:"),
|
|
46
|
+
],
|
|
47
|
+
};
|
|
48
|
+
expect(relevanceQuery(req)).toBe("add a /router command with reports");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("prefers the newest real ask over the opening one", () => {
|
|
52
|
+
const req = { messages: [msg("user", "first ask"), msg("assistant", "ok"), msg("user", "<recap>x</recap> now fix the report window")] };
|
|
53
|
+
expect(relevanceQuery(req)).toBe("now fix the report window");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("caps long asks so the block header stays a line", () => {
|
|
57
|
+
const long = "x".repeat(5_000);
|
|
58
|
+
const q = relevanceQuery({ messages: [msg("user", long)] });
|
|
59
|
+
expect(q.length).toBe(MAX_QUERY_CHARS);
|
|
60
|
+
expect(q.endsWith("…")).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("nothing usable yields an empty query", () => {
|
|
64
|
+
expect(relevanceQuery({ messages: [msg("user", "<chat>\nlog"), msg("assistant", "hi")] })).toBe("");
|
|
65
|
+
expect(relevanceQuery({ messages: [] })).toBe("");
|
|
66
|
+
});
|
|
67
|
+
});
|
package/test/ollama.test.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { buildCandidates } from "../src/router/candidates.ts";
|
|
|
22
22
|
import { extractFeatures } from "../src/router/features.ts";
|
|
23
23
|
import { createMultiUpstream } from "../src/upstream/multi.ts";
|
|
24
24
|
import { classifyOllamaStatus, createOllamaClient, toOllamaBody } from "../src/upstream/ollama.ts";
|
|
25
|
-
import { createOllamaUsageSource, effectiveOllamaBias, NO_USAGE, ollamaMeter, parseOllamaUsage, usageFraction } from "../src/upstream/ollama-usage.ts";
|
|
25
|
+
import { createOllamaUsageSource, effectiveOllamaBias, NO_USAGE, ollamaMeter, parseOllamaPlan, parseOllamaUsage, usageFraction } from "../src/upstream/ollama-usage.ts";
|
|
26
26
|
import type { Dispatch, DispatchOptions, UpstreamClient } from "../src/upstream/types.ts";
|
|
27
27
|
import { createLogger } from "../src/util/log.ts";
|
|
28
28
|
import { parseChatRequest } from "../src/wire/openai/request.ts";
|
|
@@ -444,7 +444,7 @@ describe("ollama plan usage (credit-aware bias)", () => {
|
|
|
444
444
|
});
|
|
445
445
|
|
|
446
446
|
test("the bias holds under the threshold, switches to list price above it, and stays on when usage is unknown", () => {
|
|
447
|
-
const at = (f: number | null) => (f === null ? null : { monthlyUsedFraction: f, monthlyUsageRaw: f, activityCostUsd: null, requestsThisMonth: 0, fetchedAtMs: 0 });
|
|
447
|
+
const at = (f: number | null) => (f === null ? null : { monthlyUsedFraction: f, monthlyUsageRaw: f, activityCostUsd: null, requestsThisMonth: 0, plan: null, fetchedAtMs: 0 });
|
|
448
448
|
expect(effectiveOllamaBias(0.1, 0.9, at(0.5))).toBe(0.1);
|
|
449
449
|
expect(effectiveOllamaBias(0.1, 0.9, at(0.9))).toBe(1);
|
|
450
450
|
expect(effectiveOllamaBias(0.1, 0.9, at(1))).toBe(1);
|
|
@@ -457,15 +457,21 @@ describe("ollama plan usage (credit-aware bias)", () => {
|
|
|
457
457
|
let calls = 0;
|
|
458
458
|
let fail = false;
|
|
459
459
|
const fetchImpl = async (url: string, init?: RequestInit): Promise<Response> => {
|
|
460
|
+
expect((init?.headers as Record<string, string>).authorization).toBe("Bearer k");
|
|
461
|
+
// The plan rides along on every poll: POST /api/me (GET answers 405).
|
|
462
|
+
if (url === "https://ollama.com/api/me") {
|
|
463
|
+
expect(init?.method).toBe("POST");
|
|
464
|
+
return Response.json({ ID: "x", Email: "e", Plan: "Pro" });
|
|
465
|
+
}
|
|
460
466
|
calls++;
|
|
461
467
|
expect(url).toBe("https://ollama.com/api/usage");
|
|
462
|
-
expect((init?.headers as Record<string, string>).authorization).toBe("Bearer k");
|
|
463
468
|
if (fail) return new Response("down", { status: 503 });
|
|
464
469
|
return Response.json({ ...PAYLOAD, limits: { monthly: { usage: 42, models: [] } } });
|
|
465
470
|
};
|
|
466
471
|
const src = createOllamaUsageSource({ apiKey: "k", pollMs: 20, timeoutMs: 1000, log, fetchImpl });
|
|
467
472
|
expect(src.peek()).toBeNull();
|
|
468
473
|
expect((await src.get())?.monthlyUsedFraction).toBeCloseTo(0.42, 6);
|
|
474
|
+
expect(src.peek()?.plan).toBe("pro");
|
|
469
475
|
await src.get();
|
|
470
476
|
expect(calls).toBe(1); // within the interval
|
|
471
477
|
fail = true;
|
|
@@ -482,7 +488,7 @@ describe("ollama plan usage (credit-aware bias)", () => {
|
|
|
482
488
|
const source = { get: async () => ollamaModels, peek: () => ollamaModels, invalidate: () => {} };
|
|
483
489
|
const breaker = { available: () => true, cooldownUntilMs: () => null, lastTrip: () => null };
|
|
484
490
|
let used = 0.2;
|
|
485
|
-
const usage = { get: async () => ({ monthlyUsedFraction: used, monthlyUsageRaw: used * 100, activityCostUsd: null, requestsThisMonth: 0, fetchedAtMs: 0 }), peek: () => ({ monthlyUsedFraction: used, monthlyUsageRaw: used * 100, activityCostUsd: null, requestsThisMonth: 0, fetchedAtMs: 0 }) };
|
|
491
|
+
const usage = { get: async () => ({ monthlyUsedFraction: used, monthlyUsageRaw: used * 100, activityCostUsd: null, requestsThisMonth: 0, plan: null, fetchedAtMs: 0 }), peek: () => ({ monthlyUsedFraction: used, monthlyUsageRaw: used * 100, activityCostUsd: null, requestsThisMonth: 0, plan: null, fetchedAtMs: 0 }) };
|
|
486
492
|
const catalog = createCompositeCatalog(openrouter, source, breaker, { costBias: 0.1, biasUntilUsage: 0.9, usage });
|
|
487
493
|
|
|
488
494
|
const a = await catalog.get();
|
|
@@ -506,16 +512,29 @@ describe("ollama plan usage (credit-aware bias)", () => {
|
|
|
506
512
|
|
|
507
513
|
|
|
508
514
|
describe("ollamaMeter", () => {
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
expect(ollamaMeter(usage,
|
|
515
|
+
const usage = (plan: string | null, frac: number | null = 0.104) => ({ monthlyUsedFraction: frac, monthlyUsageRaw: frac, activityCostUsd: 0, requestsThisMonth: 1250, plan, fetchedAtMs: 1 });
|
|
516
|
+
|
|
517
|
+
test("a detected plan applies its published allowance: 10.4% of Pro's $60 is the $6.24 ollama.com shows", () => {
|
|
518
|
+
expect(ollamaMeter(usage("pro"), 0)).toEqual({ usedUsd: 6.24, creditsUsd: 60, plan: "pro" });
|
|
519
|
+
expect(ollamaMeter(usage("max"), 0)).toEqual({ usedUsd: 31.2, creditsUsd: 300, plan: "max" });
|
|
513
520
|
});
|
|
514
521
|
|
|
515
|
-
test("unknown
|
|
516
|
-
|
|
517
|
-
expect(ollamaMeter(usage, 0)).toBeNull();
|
|
522
|
+
test("a configured override wins over the detected plan; an unknown plan without one yields no meter", () => {
|
|
523
|
+
expect(ollamaMeter(usage("pro"), 100)).toEqual({ usedUsd: 10.4, creditsUsd: 100, plan: "pro" });
|
|
524
|
+
expect(ollamaMeter(usage("team"), 0)).toBeNull();
|
|
525
|
+
expect(ollamaMeter(usage("team"), 500)?.creditsUsd).toBe(500);
|
|
526
|
+
expect(ollamaMeter(usage(null), 0)).toBeNull();
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test("no usage reading yields no meter", () => {
|
|
518
530
|
expect(ollamaMeter(null, 60)).toBeNull();
|
|
519
|
-
expect(ollamaMeter(
|
|
531
|
+
expect(ollamaMeter(usage("pro", null), 60)).toBeNull();
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
test("parseOllamaPlan reads the account payload case-insensitively", () => {
|
|
535
|
+
expect(parseOllamaPlan({ ID: "x", Plan: "Pro" })).toBe("pro");
|
|
536
|
+
expect(parseOllamaPlan({ plan: "max" })).toBe("max");
|
|
537
|
+
expect(parseOllamaPlan({ Plan: "" })).toBeNull();
|
|
538
|
+
expect(parseOllamaPlan("nope")).toBeNull();
|
|
520
539
|
});
|
|
521
540
|
});
|
|
@@ -65,7 +65,7 @@ describe("renderStatus", () => {
|
|
|
65
65
|
apiKeySource: "omp",
|
|
66
66
|
lastTrip: { kind: "quota", atMs: now - 120_000, message: "402" },
|
|
67
67
|
usage: { monthlyUsedFraction: 0.42, activityCostUsd: 3.1, fetchedAtMs: now },
|
|
68
|
-
meter: { usedUsd: 25.2, creditsUsd: 60 },
|
|
68
|
+
meter: { usedUsd: 25.2, creditsUsd: 60, plan: "pro" },
|
|
69
69
|
costBias: { configured: 0.1, effective: 0.1, biasUntilUsage: 0.9 },
|
|
70
70
|
},
|
|
71
71
|
catalog: { models: 240, ageMs: 5 * 60_000, keyScoped: true, shrink: { fromModels: 300, toModels: 120, atMs: now } },
|
|
@@ -76,7 +76,7 @@ describe("renderStatus", () => {
|
|
|
76
76
|
expect(text).toContain("refreshed 5m ago");
|
|
77
77
|
expect(text).toContain("SHRANK 300 -> 120");
|
|
78
78
|
expect(text).toContain("COOLING DOWN");
|
|
79
|
-
expect(text).toContain("plan usage 42.0% ($25.20 of $60)");
|
|
79
|
+
expect(text).toContain("pro plan usage 42.0% ($25.20 of $60)");
|
|
80
80
|
expect(text).toContain("cost bias ×0.1 (until 90%)");
|
|
81
81
|
expect(text).toContain("last trip quota 2m ago");
|
|
82
82
|
expect(text).toContain("scope omp-router");
|