modelrig 0.11.0 → 0.11.1

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.
@@ -65,14 +65,14 @@ MODELRIG_API_KEY=rig_sk_…
65
65
  <!-- /BLOCK:KEY -->
66
66
 
67
67
  <!-- BLOCK:CONFIRM_ORG -->
68
- **Confirm the org before you trust an empty console.** Run `modelrig doctor` from the same environment your app runs in. It asks GET /v1/whoami which organization the key belongs to, and the name it prints must match the org badge in the console header. If it names a different organization, your data is landing there: switch the console to that organization, or mint a key in the one you meant (https://modelrig.dev/orgs-and-keys.html).
68
+ **Confirm the org before you trust an empty console.** Run `modelrig doctor` from the same environment your app runs in, once your app has started with the key. It asks GET /v1/whoami which organization the key belongs to and prints the org's name and, in parentheses, its slug; the name must match the org badge in the console header. If it names a different organization, your data is landing there: switch the console to that organization, or mint a key in the one you meant (https://modelrig.dev/orgs-and-keys.html).
69
69
 
70
70
  ```bash
71
71
  npx modelrig doctor
72
- # [modelrig] key rig_sk_…AbCd → org «Your Org» ✓ (ingest https://api.modelrig.ai, …)
72
+ # [modelrig] key rig_sk_…AbCd → org «Your Org» (your-org) ✓ (ingest https://api.modelrig.ai, …)
73
73
 
74
- # No CLI in that environment? Ask the API directly:
75
- curl -H "Authorization: Bearer $MODELRIG_API_KEY" https://api.modelrig.ai/v1/whoami
74
+ # No CLI in that environment? Ask the API directly (same host the SDK uses):
75
+ curl -H "Authorization: Bearer $MODELRIG_API_KEY" "${MODELRIG_INGEST_URL:-https://api.modelrig.ai}/v1/whoami"
76
76
  ```
77
77
  <!-- /BLOCK:CONFIRM_ORG -->
78
78
 
@@ -112,7 +112,103 @@ await rig.close(); // flushes the exporter — call once at shutdown
112
112
  **What ModelRig does not see unless you record it.** ModelRig records the calls your code makes through the SDK (`rig.run`, `rig.runBundle`, `rig.runRaw`, `rig.runRawStream`) or the gateway, plus any call your code makes itself and reports with `rig.recordCall`. Nothing else is seen. Five things stay invisible or unattached unless you record them: (1) direct provider SDK calls, with or without a JSON schema; (2) streams you read from a provider client yourself; (3) a fallback your code makes after the raw lane refuses a `responseSchema` it cannot enforce (deepseek, deepinfra, fireworks and zai fail closed with `invariant_violation`); (4) calls made outside `run.scope` / `run.start`, which land on /inferences but on no run; (5) embeddings. A recorded call shows as "recorded · unverified": ModelRig neither dispatched nor validated it, and its tokens are the numbers you reported. Details and the console tells: https://modelrig.dev/not-captured.html.
113
113
  <!-- /BLOCK:NOT_CAPTURED -->
114
114
 
115
- A provider call you can't move onto a lane (an unsupported client, a stream you read yourself, a fallback): record it with `rig.recordCall({ provider, model, usage, servedTier, latencyMs, stepKey })` right after it returns, never leave it invisible (modelrig ≥ 0.11.0; https://modelrig.dev/not-captured.html).
115
+ A provider call you can't move onto a lane (an unsupported client, a stream you read yourself, a fallback, an embedding): record it right after it returns, never leave it invisible (https://modelrig.dev/not-captured.html):
116
+
117
+ <!-- BLOCK:RECORD_CALL -->
118
+ **Record a call your code makes itself.** A provider call your code makes directly (a client ModelRig has no lane for, a stream you read yourself, an embedding, a fallback) is invisible until you report it. Call `rig.recordCall` right after it returns, inside the run, with the tokens the provider reported: the call shows on /inferences as "recorded · unverified", joins its run and step, and counts toward cost. It is synchronous and never throws; invalid input warns once and records nothing (`inferenceId: null`). Spread optional fields only when the provider reported them, so the call type-checks under `exactOptionalPropertyTypes`.
119
+
120
+ ```ts
121
+ const started = Date.now();
122
+ const resp = await genai.models.generateContent({ model: "gemini-3.1-pro-preview", contents });
123
+ const u = resp.usageMetadata;
124
+ const finishReason = resp.candidates?.[0]?.finishReason;
125
+
126
+ const rec = rig.recordCall({
127
+ provider: "gemini",
128
+ model: "gemini-3.1-pro-preview",
129
+ usage: {
130
+ tokensIn: u?.promptTokenCount ?? 0, // total input, cached included
131
+ tokensOut: (u?.candidatesTokenCount ?? 0) + (u?.thoughtsTokenCount ?? 0),
132
+ tokensCached: u?.cachedContentTokenCount ?? 0,
133
+ // optional fields: spread only when the provider reported them
134
+ ...(u?.thoughtsTokenCount !== undefined ? { reasoningTokens: u.thoughtsTokenCount } : {}),
135
+ },
136
+ servedTier: "standard", // the tier that SERVED the call: pass what the provider reports (flex, priority); it is priced at that tier
137
+ latencyMs: Date.now() - started,
138
+ stepKey: "summarize", // the step on /runs (inside run.scope / run.start)
139
+ ...(finishReason !== undefined ? { finishReason } : {}),
140
+ });
141
+ if (rec.inferenceId !== null) rig.grade({ kind: "inference", id: rec.inferenceId }, { score: 1, kind: "deterministic" });
142
+ ```
143
+ <!-- /BLOCK:RECORD_CALL -->
144
+
145
+ No routes yet (observe, or a `runRaw` seam)? Build the rig raw-only:
146
+
147
+ <!-- BLOCK:RAW_RIG -->
148
+ **No routes yet? Build the rig raw-only.** Observing a pipeline, or recording calls your code makes itself, needs no route files. Construct the rig with `routesDir: null`: `rig.runRaw`, `rig.runRawStream` and `rig.recordCall` work at once, and `rig.run` / `rig.runBundle` refuse with a teaching error until you add routes. Without it, createRig loads ./modelrig/routes: a directory you configured that is missing throws, and a missing default directory falls back to raw-only with a one-line warning.
149
+
150
+ ```ts
151
+ import { createRig, loadConfigFromEnv } from "modelrig";
152
+
153
+ // Raw-only on purpose: no route files needed. rig.runRaw, rig.runRawStream and
154
+ // rig.recordCall work; rig.run needs routes (add them later, drop routesDir: null).
155
+ const rig = createRig({ ...loadConfigFromEnv(), routesDir: null });
156
+ ```
157
+ <!-- /BLOCK:RAW_RIG -->
158
+
159
+ Grade from a signal you already have, and prove a cheaper model on your own traffic:
160
+
161
+ <!-- BLOCK:GRADE_RUN -->
162
+ **Attach a grade to a call or a whole run.** A grade is your judgment: a 0–1 score with an optional comment, from a validator, a downstream accept/reject, a human or a model. Grade one call by its inference id (`result.meta.inferenceId`, or the `inferenceId` rig.recordCall returns). Grade a whole run by its run id, `run.runId` on the handle `run.start` returns: that grade shows on the run page and on /projects. A run grade whose id is the run's `episodeKey` also shows, on the run page only. `run_id` is a reserved tag the SDK stamps, not a variable your code has. A grade whose id matches no call or run attaches to nothing. The write is synchronous and local and never throws into your pipeline.
163
+
164
+ ```ts
165
+ const run = rig.artifacts.run.start({ pipeline: "support", episodeKey: ticketId, environment: "production" });
166
+ const result = await rig.run("example.support_summarize", {
167
+ input: { ticket, product, priorContext },
168
+ tags: { subject: customer },
169
+ stepKey: "summarize",
170
+ });
171
+ run.end("succeeded");
172
+
173
+ // one call: the inference id rides on every result
174
+ rig.grade({ kind: "inference", id: result.meta.inferenceId }, { score: 1, kind: "deterministic", grader: "schema-check" });
175
+ // the whole run: its id is run.runId from run.start (shows on the run page and on /projects)
176
+ rig.grade({ kind: "run", id: run.runId }, { score: 0, kind: "human", comment: "missed the refund request" });
177
+ ```
178
+ <!-- /BLOCK:GRADE_RUN -->
179
+
180
+ <!-- BLOCK:BAKEOFF -->
181
+ **Prove a cheaper model on your own traffic.** A bake-off replays your own inputs through the variants a route declares and reports the effective cost of conformance for each. `--variants` names entries in the route's `variants:` block (`default` is the route as declared), never model ids, and a variant can only narrow the route's candidates. Set `capture: true` first: replay works on traffic captured after that, not before. With no captured traffic (a run-based pipeline), promote a real /runs output to a golden (the console's make golden, or `promoteToEval`) and use `--from-eval-cases <task>`; its variants come from the route bundle named after the task. A bake-off measures and never switches anything.
182
+
183
+ ```bash
184
+ # 1. in modelrig/routes/example.support_summarize.yaml: capture traffic, and declare
185
+ # a cheaper variant (narrowing only: its candidates must be in the route's list)
186
+ capture: true
187
+ variants:
188
+ - name: cheap
189
+ candidates:
190
+ - provider: gemini
191
+ model: gemini-3.1-flash-lite
192
+
193
+ # 2. after traffic accumulates, compare the route as declared (default) with cheap
194
+ npx modelrig bakeoff --route example.support_summarize --variants default,cheap --replay-last 50
195
+
196
+ # no captured route traffic? promote a real /runs output to a golden, then
197
+ npx modelrig bakeoff --from-eval-cases example.support_summarize --variants default,cheap
198
+ ```
199
+ <!-- /BLOCK:BAKEOFF -->
200
+
201
+ On a hosted worker, pin the org a deployment must export to:
202
+
203
+ <!-- BLOCK:EXPECT_ORG -->
204
+ **Pin the org a deployment must export to.** `--expect-org` and `expectOrg` take the org's slug, not its display name. `modelrig doctor` prints both, as `→ org «Your Org» (your-org) ✓`: the slug is the part in parentheses. In the console, hover the org badge in the header to see it. With `--expect-org <slug>`, doctor exits non-zero when the key belongs to another org, so a deploy check fails loudly instead of exporting somewhere else.
205
+
206
+ ```ts
207
+ // The same check in code: the exporter asks whoami on its first flush and logs
208
+ // ORG MISMATCH on every flush while the key belongs to another org.
209
+ const rig = createRig({ ...loadConfigFromEnv(), expectOrg: "your-org" });
210
+ ```
211
+ <!-- /BLOCK:EXPECT_ORG -->
116
212
 
117
213
  Which lane fits a codebase (observe, the `runRaw` seam, or named routes):
118
214
  <https://modelrig.dev/choosing-a-lane.html>. Hosted workers:
@@ -122,11 +218,12 @@ Which lane fits a codebase (observe, the `runRaw` seam, or named routes):
122
218
 
123
219
  Before migrating anything, you can just **watch** an existing pipeline. ModelRig
124
220
  records each run as a run → step → artifact chain you click through in the
125
- console — no routes moved, no model calls rerouted, and with the flag off the
221
+ console — no routes moved, no model calls rerouted, and with no key set the
126
222
  pipeline behaves byte-for-byte as before. Scaffold it:
127
223
  `npx modelrig observe [path] --scope <one pipeline>` writes a self-contained
128
- `record-step` seam and an `OBSERVE.md` whose wiring is emitted as diffs you
129
- apply. The instrumentation is a code-level SDK seam (no zero-code observe), but
224
+ `record-step` seam (a raw-only rig, `routesDir: null`, that reports each call
225
+ with `rig.recordCall` so steps carry a cost) and an `OBSERVE.md` whose wiring,
226
+ including `environment` on the run, is emitted as diffs you apply. The instrumentation is a code-level SDK seam (no zero-code observe), but
130
227
  you no longer hand-write it — see <https://modelrig.dev/instrumentation.html>.
131
228
  The namespace is **on by default once a control plane is configured** (a
132
229
  `rig_sk_` API key, or the self-host Supabase pair); a pure local-only rig stays
@@ -150,8 +247,8 @@ behaviour-affecting, so the agent *proposes* it, proves it with
150
247
  never the skill's job.
151
248
 
152
249
  **Runs are the standard path, not an appendix.** Every pipeline execution should
153
- be a **run**: wrap it in `rig.artifacts.run.start` / `run.scope`, name one step
154
- per model-call family (pass `stepKey`), `artifact.save` the work products, and tag
250
+ be a **run**: wrap it in `rig.artifacts.run.start` / `run.scope`, name each
251
+ model call's step with `stepKey`, `artifact.save` the work products, and tag
155
252
  `subject` / `feature`. Inside a run context the SDK auto-stamps `tags.run_id` and
156
253
  `tags.step` for you, so the run fills `/runs` **and** `/projects`; acceptance is
157
254
  the run appearing on `/runs` with its steps. `run_id` is reserved: never set it
@@ -12,7 +12,7 @@ What is retained beyond that is your choice, per route, and there are two lanes.
12
12
 
13
13
  **Pure router** (capture off). Nothing retained. Real, supported, selectable, and the honest answer for a ZDR-strict or data-residency-bound workload.
14
14
 
15
- **Optimization on** (recommended). Your traffic is what makes the routing smarter and the bill smaller. **What is kept:** per-inference telemetry rows, and bounded review samples for the bake-offs you create. **Where:** your organization, row-scoped by the database, not by our application code. **How you leave:** see it all in the console, delete it, export it, take your routes and go — they were always YAML in your git. **What you get back:** community priors on your very first bake-off and RigIndex-grade evidence on your own routes — at the same flat 2% either way; the corpus earns its place by being useful, not by being a discount.
15
+ **Optimization on** (recommended). Your traffic is what makes the routing smarter and the bill smaller. **What is kept:** per-inference telemetry rows, and bounded review samples for the bake-offs you create. **Where:** your organization, row-scoped by the database, not by our application code. **How you leave:** see it all in the console and export it; never a silent swap — promote a change in ModelRig (console or MCP; versioned, reversible) or merge it as a PR if you keep routes in git. **What you get back:** community priors on your very first bake-off and RigIndex-grade evidence on your own routes — at the same flat 2% either way; the corpus earns its place by being useful, not by being a discount.
16
16
 
17
17
  Captures power **bake-offs**: replay your own recent traffic through route variants — a cheaper model, a different prompt — and get the effective cost of conformance for each, with confidence intervals. A winning variant is a proposal for you to apply in your YAML; nothing swaps models automatically.
18
18
 
@@ -37,8 +37,6 @@ path — never blurred with the telemetry axis here.
37
37
  # set_org_settings MCP tool), not this env var.
38
38
  export MODELRIG_ARTIFACTS=1 # local-only namespace toggle: force ON
39
39
  # with =1 on a rig with no control plane
40
- export MODELRIG_ARTIFACT_STEPS=step_a,step_b # optional: instrument a subset
41
- # (unset/empty = every instrumented step)
42
40
  ```
43
41
 
44
42
  Rows land in the local telemetry buffer and export to the control plane through
@@ -54,38 +52,60 @@ agent-driven setup should plan that one handoff rather than stall on it.
54
52
  The namespace hangs off `rig.artifacts`. Two moving parts: a **run context**
55
53
  around the whole pipeline, and a **per-step save** of the work products.
56
54
 
55
+ No routes yet? Observing needs none. Build the rig raw-only:
56
+
57
+ <!-- BLOCK:RAW_RIG -->
58
+ **No routes yet? Build the rig raw-only.** Observing a pipeline, or recording calls your code makes itself, needs no route files. Construct the rig with `routesDir: null`: `rig.runRaw`, `rig.runRawStream` and `rig.recordCall` work at once, and `rig.run` / `rig.runBundle` refuse with a teaching error until you add routes. Without it, createRig loads ./modelrig/routes: a directory you configured that is missing throws, and a missing default directory falls back to raw-only with a one-line warning.
59
+
57
60
  ```ts
58
61
  import { createRig, loadConfigFromEnv } from "modelrig";
59
62
 
60
- const rig = createRig(loadConfigFromEnv());
63
+ // Raw-only on purpose: no route files needed. rig.runRaw, rig.runRawStream and
64
+ // rig.recordCall work; rig.run needs routes (add them later, drop routesDir: null).
65
+ const rig = createRig({ ...loadConfigFromEnv(), routesDir: null });
66
+ ```
67
+ <!-- /BLOCK:RAW_RIG -->
61
68
 
69
+ Then wrap the pipeline. Here each step's model call stays your own direct call,
70
+ reported with `rig.recordCall` so the step carries tokens and a cost:
71
+
72
+ ```ts
62
73
  // 1. Open a run context around the pipeline. episodeKey bridges grades;
63
74
  // it is a grouping key, not a unique id — a natural choice is your own
64
75
  // per-run attempt id. environment fills the Env column on /runs.
65
76
  const run = rig.artifacts.run.start({
66
77
  pipeline: "my-pipeline",
67
78
  episodeKey: runAttemptId,
68
- environment: process.env.NODE_ENV, // e.g. "production", "staging", "test"
79
+ environment: process.env.NODE_ENV ?? "development", // e.g. "production", "staging", "test"
69
80
  });
70
81
  try {
71
82
  for (const step of steps) {
72
83
  const prompt = assemble(step);
73
- const result = await callModel(prompt); // your own model call
84
+ const started = Date.now();
85
+ const result = await callModel(prompt); // your own direct model call
86
+
87
+ // 2. Report the call: tokens → cost on the step and on /inferences.
88
+ rig.recordCall({
89
+ provider: "openai",
90
+ model: "gpt-5.4-mini",
91
+ usage: { tokensIn: result.usage.inputTokens, tokensOut: result.usage.outputTokens },
92
+ latencyMs: Date.now() - started,
93
+ stepKey: step.key,
94
+ });
74
95
 
75
- // 2. Save the step's artifacts. stepKey groups them in the console's
96
+ // 3. Save the step's artifacts. stepKey groups them in the console's
76
97
  // chain view; task carries the semantic task name.
77
- const p = rig.artifacts.artifact.save(prompt, { name: `${step}.prompt`, type: "prompt", stepKey: step });
78
- const raw = rig.artifacts.artifact.save(result.rawText, { name: `${step}.raw`, type: "raw_response", stepKey: step });
98
+ const p = rig.artifacts.artifact.save(prompt, { name: `${step.key}.prompt`, type: "prompt", stepKey: step.key });
99
+ const raw = rig.artifacts.artifact.save(result.rawText, { name: `${step.key}.raw`, type: "raw_response", stepKey: step.key });
79
100
  const parsed = rig.artifacts.artifact.save(result.json, {
80
- name: `${step}.output`, type: "step_output", task: step, stepKey: step, schema: step.schema,
101
+ name: `${step.key}.output`, type: "step_output", task: step.key, stepKey: step.key, schema: step.schema,
81
102
  });
82
103
 
83
- // 3. Lineage: the parsed output derives from the prompt + raw; this step's
84
- // prompt consumed the previous step's output.
104
+ // 4. Lineage: the parsed output derives from the prompt + raw.
85
105
  if (p && parsed) rig.artifacts.artifact.link(parsed, p, "derived_from");
86
106
  if (raw && parsed) rig.artifacts.artifact.link(parsed, raw, "derived_from");
87
107
 
88
- // 4. Grades (optional, deterministic): flag conformance, degraded
108
+ // 5. Grades (optional, deterministic): flag conformance, degraded
89
109
  // provenance, anything you can decide without an LLM judge.
90
110
  if (parsed) rig.grade({ kind: "artifact", id: parsed.id }, {
91
111
  score: conforms(result.json) ? 1 : 0, kind: "deterministic", grader: "schema-check",
@@ -96,8 +116,13 @@ try {
96
116
  run.end("failed");
97
117
  throw err;
98
118
  }
119
+ await rig.close(); // once, at shutdown
99
120
  ```
100
121
 
122
+ Here `step` is your own `{ key, schema }` record, and `callModel` returns the
123
+ provider's text, parsed JSON and usage. A call routed through `rig.run` or
124
+ `rig.runRaw` records its own step, so it needs no `recordCall`.
125
+
101
126
  For **concurrent** runs in one process, use `rig.artifacts.run.scope(opts, fn)`
102
127
  instead of `start`/`end` — each scope keeps its own ambient context so
103
128
  overlapping runs never cross-attribute. `run.start` uses `enterWith`, which is
@@ -111,9 +136,10 @@ right for one run at a time on a request/async path.
111
136
  - A **step** is an execution row. `rig.run()` auto-creates a **ground-truth**
112
137
  step (its inference is real, so cost and latency are measured) when it runs
113
138
  inside a run context. A stage whose model call does *not* go through the rig
114
- produces artifacts with `step_key` set and **no step row** — which is correct
115
- and honest. Never route a stage through the rig just to mint a step row; the
116
- console renders "cost n/a" for un-routed steps rather than inventing a number.
139
+ and is not reported with `rig.recordCall` produces artifacts with `step_key`
140
+ set and **no step row**, which is correct and honest: the console renders
141
+ "cost n/a" for that step rather than inventing a number. Report the call
142
+ (below) and the step gets its tokens and cost.
117
143
 
118
144
  ### Record a call your code made itself (`rig.recordCall`)
119
145
 
@@ -132,6 +158,34 @@ you reported. It is synchronous and never throws (bad input warns and returns
132
158
  Full contract, usage convention and cost notes:
133
159
  [Not captured → record them](not-captured.md#calls-your-code-makes-itself-record-them).
134
160
 
161
+ <!-- BLOCK:RECORD_CALL -->
162
+ **Record a call your code makes itself.** A provider call your code makes directly (a client ModelRig has no lane for, a stream you read yourself, an embedding, a fallback) is invisible until you report it. Call `rig.recordCall` right after it returns, inside the run, with the tokens the provider reported: the call shows on /inferences as "recorded · unverified", joins its run and step, and counts toward cost. It is synchronous and never throws; invalid input warns once and records nothing (`inferenceId: null`). Spread optional fields only when the provider reported them, so the call type-checks under `exactOptionalPropertyTypes`.
163
+
164
+ ```ts
165
+ const started = Date.now();
166
+ const resp = await genai.models.generateContent({ model: "gemini-3.1-pro-preview", contents });
167
+ const u = resp.usageMetadata;
168
+ const finishReason = resp.candidates?.[0]?.finishReason;
169
+
170
+ const rec = rig.recordCall({
171
+ provider: "gemini",
172
+ model: "gemini-3.1-pro-preview",
173
+ usage: {
174
+ tokensIn: u?.promptTokenCount ?? 0, // total input, cached included
175
+ tokensOut: (u?.candidatesTokenCount ?? 0) + (u?.thoughtsTokenCount ?? 0),
176
+ tokensCached: u?.cachedContentTokenCount ?? 0,
177
+ // optional fields: spread only when the provider reported them
178
+ ...(u?.thoughtsTokenCount !== undefined ? { reasoningTokens: u.thoughtsTokenCount } : {}),
179
+ },
180
+ servedTier: "standard", // the tier that SERVED the call: pass what the provider reports (flex, priority); it is priced at that tier
181
+ latencyMs: Date.now() - started,
182
+ stepKey: "summarize", // the step on /runs (inside run.scope / run.start)
183
+ ...(finishReason !== undefined ? { finishReason } : {}),
184
+ });
185
+ if (rec.inferenceId !== null) rig.grade({ kind: "inference", id: rec.inferenceId }, { score: 1, kind: "deterministic" });
186
+ ```
187
+ <!-- /BLOCK:RECORD_CALL -->
188
+
135
189
  ## Tags, dimensions, and the graph
136
190
 
137
191
  ModelRig has **two** grouping mechanisms, and they compose:
@@ -237,7 +291,9 @@ const { bundle: runnable, arm } = resolveCallConfigForRun(bundle, resolved, { in
237
291
 
238
292
  // Thread the arm onto the run so it lands on inferences.meta.arm.
239
293
  await rig.artifacts.run.scope({ pipeline: "report", episodeKey: runId }, async () => {
240
- const out = await rig.runBundle(runnable, { input, tags: { subject, feature }, arm, stepKey });
294
+ // arm is string | null; RunOptions.arm is optional, so pass it only when drawn
295
+ // (a bare `arm` fails under exactOptionalPropertyTypes).
296
+ const out = await rig.runBundle(runnable, { input, tags: { subject, feature }, stepKey, ...(arm !== null ? { arm } : {}) });
241
297
  rig.artifacts.artifact.save(out.output, { name: "section.output", type: "step_output" });
242
298
  });
243
299
  ```
@@ -294,14 +350,14 @@ orgs and the console picker: [Orgs and keys](https://modelrig.dev/orgs-and-keys.
294
350
  Check first that the console is showing the org your key exports to:
295
351
 
296
352
  <!-- BLOCK:CONFIRM_ORG -->
297
- **Confirm the org before you trust an empty console.** Run `modelrig doctor` from the same environment your app runs in. It asks GET /v1/whoami which organization the key belongs to, and the name it prints must match the org badge in the console header. If it names a different organization, your data is landing there: switch the console to that organization, or mint a key in the one you meant (https://modelrig.dev/orgs-and-keys.html).
353
+ **Confirm the org before you trust an empty console.** Run `modelrig doctor` from the same environment your app runs in, once your app has started with the key. It asks GET /v1/whoami which organization the key belongs to and prints the org's name and, in parentheses, its slug; the name must match the org badge in the console header. If it names a different organization, your data is landing there: switch the console to that organization, or mint a key in the one you meant (https://modelrig.dev/orgs-and-keys.html).
298
354
 
299
355
  ```bash
300
356
  npx modelrig doctor
301
- # [modelrig] key rig_sk_…AbCd → org «Your Org» ✓ (ingest https://api.modelrig.ai, …)
357
+ # [modelrig] key rig_sk_…AbCd → org «Your Org» (your-org) ✓ (ingest https://api.modelrig.ai, …)
302
358
 
303
- # No CLI in that environment? Ask the API directly:
304
- curl -H "Authorization: Bearer $MODELRIG_API_KEY" https://api.modelrig.ai/v1/whoami
359
+ # No CLI in that environment? Ask the API directly (same host the SDK uses):
360
+ curl -H "Authorization: Bearer $MODELRIG_API_KEY" "${MODELRIG_INGEST_URL:-https://api.modelrig.ai}/v1/whoami"
305
361
  ```
306
362
  <!-- /BLOCK:CONFIRM_ORG -->
307
363
 
@@ -319,22 +375,33 @@ an AI — attaches to a subject: one model call (an **inference**), a whole run
319
375
  [optimization coverage](grade-protocol.md) is built from — the evidence level
320
376
  that decides how much confidence a swap proposal carries.
321
377
 
378
+ <!-- BLOCK:GRADE_RUN -->
379
+ **Attach a grade to a call or a whole run.** A grade is your judgment: a 0–1 score with an optional comment, from a validator, a downstream accept/reject, a human or a model. Grade one call by its inference id (`result.meta.inferenceId`, or the `inferenceId` rig.recordCall returns). Grade a whole run by its run id, `run.runId` on the handle `run.start` returns: that grade shows on the run page and on /projects. A run grade whose id is the run's `episodeKey` also shows, on the run page only. `run_id` is a reserved tag the SDK stamps, not a variable your code has. A grade whose id matches no call or run attaches to nothing. The write is synchronous and local and never throws into your pipeline.
380
+
322
381
  ```ts
323
- // One call — the inference id rides on every RunResult:
324
- const result = await rig.run("example.support_summarize", { input, tags: { subject } });
325
- rig.grade({ kind: "inference", id: result.meta.inferenceId }, { score: 1, kind: "human" });
382
+ const run = rig.artifacts.run.start({ pipeline: "support", episodeKey: ticketId, environment: "production" });
383
+ const result = await rig.run("example.support_summarize", {
384
+ input: { ticket, product, priorContext },
385
+ tags: { subject: customer },
386
+ stepKey: "summarize",
387
+ });
388
+ run.end("succeeded");
326
389
 
327
- // A whole run: the subject id is the run's run_id (its episodeKey, or its id).
328
- rig.grade({ kind: "run", id: runAttemptId }, { score: 0.2, comment: "CAGR off by 10x", kind: "human" });
390
+ // one call: the inference id rides on every result
391
+ rig.grade({ kind: "inference", id: result.meta.inferenceId }, { score: 1, kind: "deterministic", grader: "schema-check" });
392
+ // the whole run: its id is run.runId from run.start (shows on the run page and on /projects)
393
+ rig.grade({ kind: "run", id: run.runId }, { score: 0, kind: "human", comment: "missed the refund request" });
329
394
  ```
330
-
331
- - **The subject** is `{ kind: "inference", id }` (`result.meta.inferenceId`),
332
- `{ kind: "run", id }` (the run's `run_id` — its `episodeKey`, or its id), or
333
- `{ kind: "artifact", id }`. **`opts`** is `{ score, scale?, comment?, kind,
334
- source? }` — the `score` in `[0, 1]` is the primitive (up = 1, down = 0), and
335
- the pass/fail verdict is derived from it against the route threshold. A subject
336
- naming no known inference, run, or artifact attaches to nothing — attribution
337
- is never guessed.
395
+ <!-- /BLOCK:GRADE_RUN -->
396
+
397
+ - **The subject** is `{ kind: "inference", id }` (`result.meta.inferenceId`, or
398
+ the `inferenceId` `rig.recordCall` returns), `{ kind: "run", id }` (the run's
399
+ id, `run.runId` from `run.start`; a grade carrying the run's `episodeKey`
400
+ also shows, on the run page only), or `{ kind: "artifact", id }`. **`opts`**
401
+ is `{ score, scale?, comment?, kind, grader?, source? }` — the `score` in
402
+ `[0, 1]` is the primitive (up = 1, down = 0), and the pass/fail verdict is
403
+ derived from it against the route threshold. A subject naming no known
404
+ inference, run, or artifact attaches to nothing — attribution is never guessed.
338
405
  - **Emit it from a signal you already have.** Most pipelines already decide
339
406
  pass/fail somewhere — a schema validator, a downstream accept/reject, a human
340
407
  QA gate. Emit `rig.grade` from that (score 1 on a clean pass, 0 on a rejection)
@@ -368,15 +435,40 @@ const caseId = rig.artifacts.artifact.promoteToEval(handle, { task: "report.sect
368
435
 
369
436
  ```bash
370
437
  # Replay the promoted eval cases through variants — the raw/run-based lane.
438
+ # The variants (default, cheap) are read from the route bundle named report.section.
371
439
  modelrig bakeoff --from-eval-cases report.section --variants default,cheap
372
440
  ```
373
441
 
374
442
  - `promoteToEval(ref, { task?, corrected?, origin? })` binds the case to a route by
375
443
  `task` (from `opts`, else the task the artifact was saved with this session), and
376
444
  is also reachable from the console's one-click **"make golden"**.
377
- - `--route` needs a **declared route**; `--from-eval-cases <task>` is the path for a
378
- `runRaw` seam that owns no route. Either way a bake-off **measures** — it never
379
- switches what serves a route; a swap is a human-approved PR.
445
+ - `--route` replays **captured route traffic**; `--from-eval-cases <task>` replays
446
+ the promoted cases instead, so it is the path for a `runRaw` seam with no
447
+ captured traffic. Both read the variants from a route bundle (`--route`, or the
448
+ bundle named `<task>`): selecting the cases needs no routes directory, but a
449
+ case that replays needs that bundle and its `variants:` block. Either way a
450
+ bake-off **measures** — it never switches what serves a route; never a silent swap — promote it in ModelRig (console or MCP; versioned, reversible) or merge it as a PR if you keep routes in git.
451
+
452
+ <!-- BLOCK:BAKEOFF -->
453
+ **Prove a cheaper model on your own traffic.** A bake-off replays your own inputs through the variants a route declares and reports the effective cost of conformance for each. `--variants` names entries in the route's `variants:` block (`default` is the route as declared), never model ids, and a variant can only narrow the route's candidates. Set `capture: true` first: replay works on traffic captured after that, not before. With no captured traffic (a run-based pipeline), promote a real /runs output to a golden (the console's make golden, or `promoteToEval`) and use `--from-eval-cases <task>`; its variants come from the route bundle named after the task. A bake-off measures and never switches anything.
454
+
455
+ ```bash
456
+ # 1. in modelrig/routes/example.support_summarize.yaml: capture traffic, and declare
457
+ # a cheaper variant (narrowing only: its candidates must be in the route's list)
458
+ capture: true
459
+ variants:
460
+ - name: cheap
461
+ candidates:
462
+ - provider: gemini
463
+ model: gemini-3.1-flash-lite
464
+
465
+ # 2. after traffic accumulates, compare the route as declared (default) with cheap
466
+ npx modelrig bakeoff --route example.support_summarize --variants default,cheap --replay-last 50
467
+
468
+ # no captured route traffic? promote a real /runs output to a golden, then
469
+ npx modelrig bakeoff --from-eval-cases example.support_summarize --variants default,cheap
470
+ ```
471
+ <!-- /BLOCK:BAKEOFF -->
380
472
 
381
473
  ## A reusable per-step seam
382
474
 
@@ -388,15 +480,18 @@ as diffs to apply — it never edits your source. The seam is equally yours to
388
480
  write by hand; here it is, self-contained:
389
481
 
390
482
  ```ts
391
- import { createRig, loadConfigFromEnv, type Rig } from "modelrig";
483
+ import { createRig, loadConfigFromEnv, type RecordCallInput, type Rig } from "modelrig";
392
484
 
393
- const rig: Rig = createRig(loadConfigFromEnv());
485
+ // routesDir: null — the seam needs no route files, so importing it never throws.
486
+ const rig: Rig = createRig({ ...loadConfigFromEnv(), routesDir: null });
394
487
 
395
- /** Save prompt + raw + parsed for one step, link lineage, flag conformance. */
488
+ /** Report the call (tokens → cost), save prompt + raw + parsed, link lineage,
489
+ * flag conformance. */
396
490
  function recordStep(
397
491
  stepKey: string,
398
- work: { prompt: string; rawText: string; json: unknown; conforms: boolean },
492
+ work: { prompt: string; rawText: string; json: unknown; conforms: boolean; call?: Omit<RecordCallInput, "stepKey"> },
399
493
  ): void {
494
+ if (work.call !== undefined) rig.recordCall({ ...work.call, stepKey });
400
495
  const p = rig.artifacts.artifact.save(work.prompt, { name: `${stepKey}.prompt`, type: "prompt", stepKey });
401
496
  const raw = rig.artifacts.artifact.save(work.rawText, { name: `${stepKey}.raw`, type: "raw_response", stepKey });
402
497
  const parsed = rig.artifacts.artifact.save(work.json, { name: `${stepKey}.output`, type: "step_output", task: stepKey, stepKey });
package/docs/llms.txt CHANGED
@@ -4,7 +4,7 @@
4
4
  > grounding, and caching measured empirically, with a capability-aware
5
5
  > gateway that routes by them. Registry data CC BY 4.0; code Apache-2.0.
6
6
 
7
- _This reference is as of modelrig v0.11.0 (the published npm package). Any surface below that a newer workspace adds is marked "unreleased — workspace only" until its release; nothing is so marked as of this version._
7
+ _This reference is as of modelrig v0.11.1 (the published npm package). Any surface below that a newer workspace adds is marked "unreleased — workspace only" until its release; nothing is so marked as of this version._
8
8
 
9
9
  ## Docs
10
10
 
@@ -60,14 +60,14 @@ _The default ingest host is https://api.modelrig.ai (override with `MODELRIG_ING
60
60
  MODELRIG_API_KEY=rig_sk_…
61
61
  ```
62
62
 
63
- **Confirm the org before you trust an empty console.** Run `modelrig doctor` from the same environment your app runs in. It asks GET /v1/whoami which organization the key belongs to, and the name it prints must match the org badge in the console header. If it names a different organization, your data is landing there: switch the console to that organization, or mint a key in the one you meant (https://modelrig.dev/orgs-and-keys.html).
63
+ **Confirm the org before you trust an empty console.** Run `modelrig doctor` from the same environment your app runs in, once your app has started with the key. It asks GET /v1/whoami which organization the key belongs to and prints the org's name and, in parentheses, its slug; the name must match the org badge in the console header. If it names a different organization, your data is landing there: switch the console to that organization, or mint a key in the one you meant (https://modelrig.dev/orgs-and-keys.html).
64
64
 
65
65
  ```bash
66
66
  npx modelrig doctor
67
- # [modelrig] key rig_sk_…AbCd → org «Your Org» ✓ (ingest https://api.modelrig.ai, …)
67
+ # [modelrig] key rig_sk_…AbCd → org «Your Org» (your-org) ✓ (ingest https://api.modelrig.ai, …)
68
68
 
69
- # No CLI in that environment? Ask the API directly:
70
- curl -H "Authorization: Bearer $MODELRIG_API_KEY" https://api.modelrig.ai/v1/whoami
69
+ # No CLI in that environment? Ask the API directly (same host the SDK uses):
70
+ curl -H "Authorization: Bearer $MODELRIG_API_KEY" "${MODELRIG_INGEST_URL:-https://api.modelrig.ai}/v1/whoami"
71
71
  ```
72
72
 
73
73
  **Wrap one pipeline execution in a run.** Every pipeline execution is a run. Wrap each execution in `run.scope` (or `run.start` / `run.end`), name one step per model call with `stepKey`, `artifact.save` the work products, and pass `environment` so the Env column on /runs is filled. Inside the run the SDK stamps `run_id` and `step` on every call. `run_id` is reserved: don't set it yourself.
@@ -103,17 +103,99 @@ await rig.close(); // flushes the exporter — call once at shutdown
103
103
  ```text
104
104
  1. npx modelrig doctor, from the environment your app runs in.
105
105
  no export sink configured → this process does not see MODELRIG_API_KEY: set it, redeploy, open a new shell.
106
+ malformed key → the value is truncated, quoted or padded: paste the whole rig_sk_… secret again.
106
107
  AUTH FAILED (401 / 403) → the key is mistyped, revoked or not accepted: mint a key, set it, redeploy, open a new shell.
108
+ whoami unreachable → no network path to the ingest host (firewall, proxy, wrong MODELRIG_INGEST_URL): fix the route out, retry.
107
109
  2. The org doctor prints is not the org in the badge → wrong org: switch the console, or mint a key in the org you meant.
108
110
  3. /inferences has rows but /runs is empty → the calls are not inside run.scope / run.start.
109
111
  4. A run is missing a step or a cost → that call is not on a ModelRig lane (see not-captured).
110
112
  5. A doc describes something your install lacks → compare `npm ls modelrig` with the version stamp in modelrig.dev/llms.txt.
111
113
  ```
112
114
 
113
- **After you change the key on a hosted worker.** A hosted process reads the environment it was started with. After you set or change MODELRIG_API_KEY, wait for the redeploy to finish, then open a new shell: a shell attached before the deploy still has the old environment. Confirm from inside it that the key resolves to the org you expect.
115
+ **After you change the key on a hosted worker.** A hosted process reads the environment it was started with. After you set or change MODELRIG_API_KEY, wait for the redeploy to finish, then open a new shell: a shell attached before the deploy still has the old environment. Confirm from inside it that the key resolves to the org you expect: `--expect-org` takes the org's slug, the part in parentheses in doctor's output.
114
116
 
115
117
  ```bash
116
- npx modelrig doctor --expect-org your-org-slug # exits non-zero if the key belongs to another org
118
+ npx modelrig doctor --expect-org your-org # exits non-zero if the key belongs to another org
119
+ ```
120
+
121
+ **No routes yet? Build the rig raw-only.** Observing a pipeline, or recording calls your code makes itself, needs no route files. Construct the rig with `routesDir: null`: `rig.runRaw`, `rig.runRawStream` and `rig.recordCall` work at once, and `rig.run` / `rig.runBundle` refuse with a teaching error until you add routes. Without it, createRig loads ./modelrig/routes: a directory you configured that is missing throws, and a missing default directory falls back to raw-only with a one-line warning.
122
+
123
+ ```ts
124
+ import { createRig, loadConfigFromEnv } from "modelrig";
125
+
126
+ // Raw-only on purpose: no route files needed. rig.runRaw, rig.runRawStream and
127
+ // rig.recordCall work; rig.run needs routes (add them later, drop routesDir: null).
128
+ const rig = createRig({ ...loadConfigFromEnv(), routesDir: null });
129
+ ```
130
+
131
+ **Record a call your code makes itself.** A provider call your code makes directly (a client ModelRig has no lane for, a stream you read yourself, an embedding, a fallback) is invisible until you report it. Call `rig.recordCall` right after it returns, inside the run, with the tokens the provider reported: the call shows on /inferences as "recorded · unverified", joins its run and step, and counts toward cost. It is synchronous and never throws; invalid input warns once and records nothing (`inferenceId: null`). Spread optional fields only when the provider reported them, so the call type-checks under `exactOptionalPropertyTypes`.
132
+
133
+ ```ts
134
+ const started = Date.now();
135
+ const resp = await genai.models.generateContent({ model: "gemini-3.1-pro-preview", contents });
136
+ const u = resp.usageMetadata;
137
+ const finishReason = resp.candidates?.[0]?.finishReason;
138
+
139
+ const rec = rig.recordCall({
140
+ provider: "gemini",
141
+ model: "gemini-3.1-pro-preview",
142
+ usage: {
143
+ tokensIn: u?.promptTokenCount ?? 0, // total input, cached included
144
+ tokensOut: (u?.candidatesTokenCount ?? 0) + (u?.thoughtsTokenCount ?? 0),
145
+ tokensCached: u?.cachedContentTokenCount ?? 0,
146
+ // optional fields: spread only when the provider reported them
147
+ ...(u?.thoughtsTokenCount !== undefined ? { reasoningTokens: u.thoughtsTokenCount } : {}),
148
+ },
149
+ servedTier: "standard", // the tier that SERVED the call: pass what the provider reports (flex, priority); it is priced at that tier
150
+ latencyMs: Date.now() - started,
151
+ stepKey: "summarize", // the step on /runs (inside run.scope / run.start)
152
+ ...(finishReason !== undefined ? { finishReason } : {}),
153
+ });
154
+ if (rec.inferenceId !== null) rig.grade({ kind: "inference", id: rec.inferenceId }, { score: 1, kind: "deterministic" });
155
+ ```
156
+
157
+ **Attach a grade to a call or a whole run.** A grade is your judgment: a 0–1 score with an optional comment, from a validator, a downstream accept/reject, a human or a model. Grade one call by its inference id (`result.meta.inferenceId`, or the `inferenceId` rig.recordCall returns). Grade a whole run by its run id, `run.runId` on the handle `run.start` returns: that grade shows on the run page and on /projects. A run grade whose id is the run's `episodeKey` also shows, on the run page only. `run_id` is a reserved tag the SDK stamps, not a variable your code has. A grade whose id matches no call or run attaches to nothing. The write is synchronous and local and never throws into your pipeline.
158
+
159
+ ```ts
160
+ const run = rig.artifacts.run.start({ pipeline: "support", episodeKey: ticketId, environment: "production" });
161
+ const result = await rig.run("example.support_summarize", {
162
+ input: { ticket, product, priorContext },
163
+ tags: { subject: customer },
164
+ stepKey: "summarize",
165
+ });
166
+ run.end("succeeded");
167
+
168
+ // one call: the inference id rides on every result
169
+ rig.grade({ kind: "inference", id: result.meta.inferenceId }, { score: 1, kind: "deterministic", grader: "schema-check" });
170
+ // the whole run: its id is run.runId from run.start (shows on the run page and on /projects)
171
+ rig.grade({ kind: "run", id: run.runId }, { score: 0, kind: "human", comment: "missed the refund request" });
172
+ ```
173
+
174
+ **Prove a cheaper model on your own traffic.** A bake-off replays your own inputs through the variants a route declares and reports the effective cost of conformance for each. `--variants` names entries in the route's `variants:` block (`default` is the route as declared), never model ids, and a variant can only narrow the route's candidates. Set `capture: true` first: replay works on traffic captured after that, not before. With no captured traffic (a run-based pipeline), promote a real /runs output to a golden (the console's make golden, or `promoteToEval`) and use `--from-eval-cases <task>`; its variants come from the route bundle named after the task. A bake-off measures and never switches anything.
175
+
176
+ ```bash
177
+ # 1. in modelrig/routes/example.support_summarize.yaml: capture traffic, and declare
178
+ # a cheaper variant (narrowing only: its candidates must be in the route's list)
179
+ capture: true
180
+ variants:
181
+ - name: cheap
182
+ candidates:
183
+ - provider: gemini
184
+ model: gemini-3.1-flash-lite
185
+
186
+ # 2. after traffic accumulates, compare the route as declared (default) with cheap
187
+ npx modelrig bakeoff --route example.support_summarize --variants default,cheap --replay-last 50
188
+
189
+ # no captured route traffic? promote a real /runs output to a golden, then
190
+ npx modelrig bakeoff --from-eval-cases example.support_summarize --variants default,cheap
191
+ ```
192
+
193
+ **Pin the org a deployment must export to.** `--expect-org` and `expectOrg` take the org's slug, not its display name. `modelrig doctor` prints both, as `→ org «Your Org» (your-org) ✓`: the slug is the part in parentheses. In the console, hover the org badge in the header to see it. With `--expect-org <slug>`, doctor exits non-zero when the key belongs to another org, so a deploy check fails loudly instead of exporting somewhere else.
194
+
195
+ ```ts
196
+ // The same check in code: the exporter asks whoami on its first flush and logs
197
+ // ORG MISMATCH on every flush while the key belongs to another org.
198
+ const rig = createRig({ ...loadConfigFromEnv(), expectOrg: "your-org" });
117
199
  ```
118
200
 
119
201
  ## Capabilities (the full surface)
@@ -135,7 +217,7 @@ npx modelrig doctor --expect-org your-org-slug # exits non-zero if the key bel
135
217
  - Rates below are sampled statistics with confidence intervals in the source data — do not quote them without their n and date.
136
218
  - For PRICING, do not recall list prices — read them: the leaderboard (https://modelrig.dev/leaderboard.html) ranks models by effective cost per 1,000 schema-conformant outputs (list price ≠ what conformance costs), and each model's per-token rates ride the registry JSON with as_of stamps. Quote the effective cost, not the sticker rate.
137
219
  - Attach a GRADE to what you run — capture records what ran, a grade records whether it was good: `rig.grade({ kind: "inference", id: result.meta.inferenceId }, { score: 1, kind: "human" })` (or `{ kind: "run", id: run_id }` for a whole run) from a signal you already have (a validator, a downstream accept/reject, a human QA gate). A grade is a 0–1 score with an optional comment; up = 1, down = 0. It is a synchronous local write, exporter-mirrored, and never blocks or throws. A route with ten attached grades reaches Rung 1 on /coverage; a route with no schema to conform to (a prose output) gains proposal confidence only this way. From a non-SDK surface, `POST /v1/grade` with a grade-scoped `rig_sk_` key. See https://modelrig.dev/grade-protocol.html.
138
- - Prove a cheaper model on YOUR own traffic on the lane that fits: a DECLARED route uses `modelrig bakeoff --route <route> --replay-last N`; a raw/run-based pipeline (a `rig.runRaw` seam, no routes) promotes a real /runs output to a golden (`promoteToEval`, or the console's one-click "make golden") and runs `modelrig bakeoff --from-eval-cases <task> --variants default,<challenger>`. A bake-off measures; it never switches what serves a route — a swap is a human-approved PR. See https://modelrig.dev/bakeoffs.html.
220
+ - Prove a cheaper model on YOUR own traffic on the lane that fits: a DECLARED route uses `modelrig bakeoff --route <route> --replay-last N`; a raw/run-based pipeline (a `rig.runRaw` seam, no routes) promotes a real /runs output to a golden (`promoteToEval`, or the console's one-click "make golden") and runs `modelrig bakeoff --from-eval-cases <task> --variants default,<challenger>`. A bake-off measures; it never switches what serves a route — never a silent swap — promote it in ModelRig (console or MCP; versioned, reversible) or merge it as a PR if you keep routes in git. See https://modelrig.dev/bakeoffs.html.
139
221
 
140
222
  ## Ask this data directly (MCP)
141
223