@vincemakes/kiso-runtime 0.13.0 → 0.15.0

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/dist/agent.d.ts CHANGED
@@ -39,6 +39,10 @@ export interface AgentDefinition {
39
39
  readonly provider?: "anthropic" | "openai-compat";
40
40
  readonly apiKey?: string;
41
41
  readonly baseUrl?: string;
42
+ /** PH-1c.1: opt-in Anthropic prompt caching (cache_control
43
+ * breakpoints) — OFF by default; the openai-compat path ignores it
44
+ * (that dialect's caching is server-automatic). Type-only additive. */
45
+ readonly promptCaching?: boolean;
42
46
  readonly maxTurns?: number;
43
47
  readonly maxTokens?: number;
44
48
  readonly temperature?: number;
@@ -90,4 +94,5 @@ export declare function createAgent(definition: AgentDefinition): AgentRuntime;
90
94
  export declare function buildAdapter(provider: "anthropic" | "openai-compat", opts?: {
91
95
  readonly apiKey?: string;
92
96
  readonly baseUrl?: string;
97
+ readonly promptCaching?: boolean;
93
98
  }): Promise<Adapter>;
package/dist/agent.js CHANGED
@@ -132,6 +132,7 @@ async function resolveAdapter(definition) {
132
132
  return createAnthropicProvider({
133
133
  ...(definition.apiKey !== undefined ? { apiKey: definition.apiKey } : {}),
134
134
  ...(definition.baseUrl !== undefined ? { baseUrl: definition.baseUrl } : {}),
135
+ ...(definition.promptCaching !== undefined ? { promptCaching: definition.promptCaching } : {}),
135
136
  });
136
137
  }
137
138
  case "openai-compat": {
package/dist/compose.js CHANGED
@@ -201,8 +201,13 @@ export function composeApprovalChain(extensions) {
201
201
  try {
202
202
  v = await Promise.resolve(policy.decide(payload, ctx));
203
203
  }
204
- catch {
204
+ catch (err) {
205
205
  v = { action: "ask" }; // a throwing policy counts as ask — it speaks, never silently
206
+ // PH-1a (finding PH-F17): the degradation itself must speak
207
+ // too — a permanently-throwing policy used to manifest only
208
+ // as "why is it asking me about everything?". One stderr
209
+ // line, naming the extension; the verdict is unchanged.
210
+ console.error(`[kiso] approval policy from extension "${extension}" threw (${err instanceof Error ? err.message : String(err)}) — degraded to ask`);
206
211
  }
207
212
  if (v.action === "abstain")
208
213
  continue; // no opinion — not a verdict
package/dist/index.d.ts CHANGED
@@ -31,6 +31,8 @@ export { disposeExtensions, loadExtensions, loadProjectExtensions } from "./exte
31
31
  export type { KisoExtension } from "./extensions.js";
32
32
  export { executionForCallId, executionLedger } from "./ledger.js";
33
33
  export type { ExecutionRecord, ExecutionStatus } from "./ledger.js";
34
+ export { assessTasks } from "./task-assessment.js";
35
+ export type { EvidenceVerdict, TaskAssessment, TaskClaim } from "./task-assessment.js";
34
36
  export { kisoHome, projectArtifacts, recordTrust, trustFor } from "./trust.js";
35
37
  export type { ProjectArtifact, ProjectArtifacts, TrustDecision, TrustRecord } from "./trust.js";
36
38
  export { canonicalizeUsage } from "./usage/canonical.js";
package/dist/index.js CHANGED
@@ -31,6 +31,9 @@ export { SessionStore, StaleWriterError, StoreCorruptionError } from "./store.js
31
31
  export { disposeExtensions, loadExtensions, loadProjectExtensions } from "./extensions.js";
32
32
  // ledger
33
33
  export { executionForCallId, executionLedger } from "./ledger.js";
34
+ // task assessment (TV-1A) — the pure projection separating the model's
35
+ // CLAIM from VERIFIED under Verified ⟹ evidenceSeq > lastMutationSeq
36
+ export { assessTasks } from "./task-assessment.js";
34
37
  // trust
35
38
  export { kisoHome, projectArtifacts, recordTrust, trustFor } from "./trust.js";
36
39
  // usage — the canonical accounting schema (E2/1.3.0, R4b-1 ruling:
@@ -23,3 +23,5 @@ export * from "./lock-adapter.js";
23
23
  export * from "./ledger.js";
24
24
  export * from "./extensions.js";
25
25
  export * from "./trust.js";
26
+ export * from "./provider/metadata.js";
27
+ export { canonicalizeUsageForModel } from "./usage/canonical.js";
package/dist/internal.js CHANGED
@@ -23,3 +23,10 @@ export * from "./lock-adapter.js";
23
23
  export * from "./ledger.js";
24
24
  export * from "./extensions.js";
25
25
  export * from "./trust.js";
26
+ // PH-1c: the model metadata registry (capabilities + dated pricing) —
27
+ // first-party consumers (the CLI window derivation) read it here; the
28
+ // curated root surface does not move.
29
+ export * from "./provider/metadata.js";
30
+ // PH-1c: the model-keyed cost derivation rides the same door (the root
31
+ // surface keeps only the frozen canonicalizeUsage).
32
+ export { canonicalizeUsageForModel } from "./usage/canonical.js";
@@ -0,0 +1,56 @@
1
+ /**
2
+ * PH-1c — the model metadata table (findings PH-F14/PH-F15/PH-F16).
3
+ *
4
+ * Capabilities and pricing keyed by MODEL (+ optional endpoint), never
5
+ * by route: `anthropic`/`openai-compat` are protocol conventions — the
6
+ * same route serves models whose windows and rates have nothing in
7
+ * common, which is how the old route-keyed table priced an Anthropic
8
+ * run at DeepSeek's rates. The registry's one discipline: **unknown is
9
+ * null, everywhere** — an absent entry, an absent field, an unverified
10
+ * price all surface as null and no layer downstream may guess.
11
+ *
12
+ * Pricing is SEPARATE from capabilities (the review boundary): a price
13
+ * is a dated claim about someone else's billing page, so every entry
14
+ * carries `asOf` (the freeze date) and `source` (the page). A model we
15
+ * can name but not price stays `pricing: null` — the honest stamp the
16
+ * E2 nullable convention already defined.
17
+ *
18
+ * Lives under runtime/internal — the curated root surface (44 names)
19
+ * does not move; core is untouched.
20
+ */
21
+ export interface ModelCapabilities {
22
+ /** tokens of context the model accepts; null = unknown. */
23
+ readonly contextWindow: number | null;
24
+ readonly maxOutputTokens: number | null;
25
+ /** "automatic" — the provider caches without request markup (DeepSeek,
26
+ * OpenAI); "explicit" — the request must place cache_control
27
+ * breakpoints (Anthropic); "none" — no caching; null = unknown. */
28
+ readonly promptCaching: "none" | "automatic" | "explicit" | null;
29
+ /** the model emits a reasoning stream (thinking); null = unknown. */
30
+ readonly reasoning: boolean | null;
31
+ }
32
+ export interface ModelPricing {
33
+ readonly inputPerM: number;
34
+ readonly outputPerM: number;
35
+ readonly cacheReadPerM: number;
36
+ readonly cacheWritePerM: number;
37
+ /** the date the rates were read — a price is a dated claim. */
38
+ readonly asOf: string;
39
+ /** the billing page the rates came from. */
40
+ readonly source: string;
41
+ }
42
+ export interface ModelMetadataEntry {
43
+ /** EXACT model id — v1 does no pattern matching. */
44
+ readonly model: string;
45
+ /** origin qualifier (e.g. "https://api.deepseek.com"): when present,
46
+ * the entry matches only requests aimed at that endpoint. */
47
+ readonly endpoint?: string;
48
+ readonly capabilities: ModelCapabilities;
49
+ readonly pricing: ModelPricing | null;
50
+ }
51
+ /**
52
+ * Look a model up. `endpoint` narrows: an entry WITH an endpoint only
53
+ * matches when the caller's endpoint origin equals it; an entry without
54
+ * one matches any endpoint. Unknown model → null, never a default.
55
+ */
56
+ export declare function lookupModelMetadata(model: string, endpoint?: string): ModelMetadataEntry | null;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * PH-1c — the model metadata table (findings PH-F14/PH-F15/PH-F16).
3
+ *
4
+ * Capabilities and pricing keyed by MODEL (+ optional endpoint), never
5
+ * by route: `anthropic`/`openai-compat` are protocol conventions — the
6
+ * same route serves models whose windows and rates have nothing in
7
+ * common, which is how the old route-keyed table priced an Anthropic
8
+ * run at DeepSeek's rates. The registry's one discipline: **unknown is
9
+ * null, everywhere** — an absent entry, an absent field, an unverified
10
+ * price all surface as null and no layer downstream may guess.
11
+ *
12
+ * Pricing is SEPARATE from capabilities (the review boundary): a price
13
+ * is a dated claim about someone else's billing page, so every entry
14
+ * carries `asOf` (the freeze date) and `source` (the page). A model we
15
+ * can name but not price stays `pricing: null` — the honest stamp the
16
+ * E2 nullable convention already defined.
17
+ *
18
+ * Lives under runtime/internal — the curated root surface (44 names)
19
+ * does not move; core is untouched.
20
+ */
21
+ const DEEPSEEK_PRICING = {
22
+ // The E2-frozen rates (pricing table v1, freeze date 2026-08-13),
23
+ // re-homed here with their provenance made explicit. The caveat
24
+ // carries forward verbatim: an approximation, not a bill.
25
+ inputPerM: 0.27,
26
+ outputPerM: 1.1,
27
+ cacheReadPerM: 0.027,
28
+ cacheWritePerM: 0,
29
+ asOf: "2026-08-13",
30
+ source: "https://api-docs.deepseek.com/quick_start/pricing",
31
+ };
32
+ /** The v1 table. Nulls outnumber numbers ON PURPOSE: only values with a
33
+ * named source enter; everything else waits for one. */
34
+ const ENTRIES = [
35
+ {
36
+ model: "deepseek-chat",
37
+ endpoint: "https://api.deepseek.com",
38
+ capabilities: { contextWindow: null, maxOutputTokens: null, promptCaching: "automatic", reasoning: false },
39
+ pricing: DEEPSEEK_PRICING,
40
+ },
41
+ {
42
+ model: "deepseek-reasoner",
43
+ endpoint: "https://api.deepseek.com",
44
+ capabilities: { contextWindow: null, maxOutputTokens: null, promptCaching: "automatic", reasoning: true },
45
+ pricing: DEEPSEEK_PRICING,
46
+ },
47
+ {
48
+ model: "claude-sonnet-5",
49
+ capabilities: { contextWindow: 200_000, maxOutputTokens: null, promptCaching: "explicit", reasoning: true },
50
+ // Priced only when the rates are read from the live billing page
51
+ // and dated — never copied from memory (the review's boundary ②).
52
+ pricing: null,
53
+ },
54
+ {
55
+ model: "gpt-4o",
56
+ capabilities: { contextWindow: 128_000, maxOutputTokens: null, promptCaching: "automatic", reasoning: false },
57
+ pricing: null,
58
+ },
59
+ ];
60
+ /**
61
+ * Look a model up. `endpoint` narrows: an entry WITH an endpoint only
62
+ * matches when the caller's endpoint origin equals it; an entry without
63
+ * one matches any endpoint. Unknown model → null, never a default.
64
+ */
65
+ export function lookupModelMetadata(model, endpoint) {
66
+ for (const entry of ENTRIES) {
67
+ if (entry.model !== model)
68
+ continue;
69
+ if (entry.endpoint !== undefined && endpoint !== undefined && entry.endpoint !== originOf(endpoint))
70
+ continue;
71
+ return entry;
72
+ }
73
+ return null;
74
+ }
75
+ function originOf(endpoint) {
76
+ try {
77
+ return new URL(endpoint).origin;
78
+ }
79
+ catch {
80
+ return endpoint;
81
+ }
82
+ }
package/dist/run.d.ts CHANGED
@@ -13,7 +13,7 @@ import { type AgentSession, type SessionConfig } from "./session.js";
13
13
  export declare class Run implements AsyncIterable<Event> {
14
14
  #private;
15
15
  runId: string;
16
- constructor(store: SessionStore, adapter: Adapter, config: SessionConfig, session: AgentSession, input: string | undefined, externalSignal: AbortSignalLike | undefined, resume: boolean);
16
+ constructor(store: SessionStore, adapter: Adapter, config: SessionConfig, session: AgentSession, input: string | undefined, externalSignal: AbortSignalLike | undefined, resume: boolean, source?: import("@vincemakes/kiso-core").MessageSource);
17
17
  /** Cancel the run: propagates to the adapter (SDK) and future executions. */
18
18
  abort(): void;
19
19
  [Symbol.asyncIterator](): AsyncIterator<Event>;
package/dist/run.js CHANGED
@@ -23,12 +23,17 @@ export class Run {
23
23
  #session;
24
24
  #input;
25
25
  #resume;
26
+ #source;
26
27
  #abort = new AbortController();
27
28
  #externalSignal;
28
29
  #decisionIds = [];
29
30
  #uncertaintyIds = [];
30
31
  #started = false;
31
- constructor(store, adapter, config, session, input, externalSignal, resume) {
32
+ constructor(store, adapter, config, session, input, externalSignal, resume,
33
+ // TV-1B: durable PROVENANCE for the input (e.g. the verification
34
+ // seed's source:"system") — who produced the line, never a
35
+ // provider-role escalation. Absent = plain user input.
36
+ source) {
32
37
  this.#store = store;
33
38
  this.#adapter = adapter;
34
39
  this.#config = config;
@@ -36,6 +41,7 @@ export class Run {
36
41
  this.#input = input;
37
42
  this.#externalSignal = externalSignal;
38
43
  this.#resume = resume;
44
+ this.#source = source;
39
45
  this.runId = crypto.randomUUID();
40
46
  }
41
47
  /** Cancel the run: propagates to the adapter (SDK) and future executions. */
@@ -244,7 +250,7 @@ export class Run {
244
250
  // session. The prompt is also the first event the consumer
245
251
  // sees, so what was asked and what happened live in the same
246
252
  // stream.
247
- const inputEvent = log.append({ type: "user_input", content: this.#input });
253
+ const inputEvent = log.append({ type: "user_input", content: this.#input, ...(this.#source !== undefined ? { source: this.#source } : {}) });
248
254
  await this.#session.persist(this.runId, inputEvent);
249
255
  yield inputEvent;
250
256
  // 2. The loop projects from the session log — multi-turn context
@@ -430,8 +436,12 @@ export class Run {
430
436
  verdict = chainVerdict;
431
437
  }
432
438
  }
433
- catch {
439
+ catch (err) {
434
440
  verdict = { action: "ask" };
441
+ // PH-1a (finding PH-F17): same speaking degradation as the chain's
442
+ // per-policy catch — the recovery path has no extension name, so
443
+ // the chain is named as a whole.
444
+ console.error(`[kiso] approval chain threw during recovery (${err instanceof Error ? err.message : String(err)}) — degraded to ask`);
435
445
  }
436
446
  if (verdict === undefined && hooks?.onPreTool !== undefined) {
437
447
  const decision = await abortable(Promise.resolve(hooks.onPreTool(payload, { sessionId: this.#session.id })), signal);
@@ -714,8 +724,8 @@ export class Run {
714
724
  }
715
725
  // ruling #12 correction one: the honest note rides the recovered failure too —
716
726
  // the receipt and the repaired tool_result reproduce the live path
717
- // losslessly.
718
- if (result.isError && tool?.idempotent !== true) {
727
+ // losslessly. WR-1-F1: precondition refusals excluded — nothing ran.
728
+ if (result.isError && result.errorKind !== "precondition" && tool?.idempotent !== true) {
719
729
  result = {
720
730
  ...result,
721
731
  content: `${result.content}\n[non-idempotent tool failed — its side effects may have partially applied; verify before retrying]`,
package/dist/session.d.ts CHANGED
@@ -28,6 +28,7 @@
28
28
  * same package, same exports (index.ts re-exports all four).
29
29
  */
30
30
  import { EventLog, type AbortSignalLike, type Adapter, type Event, type KisoExtension, type Message, type PermissionDecision, type Tool } from "@vincemakes/kiso-core";
31
+ import { type TaskAssessment } from "./task-assessment.js";
31
32
  import { type SessionStore } from "./store.js";
32
33
  import { Run } from "./run.js";
33
34
  /** TUI2-R3v2 ③ — one off-trajectory model request (session.sideQuery).
@@ -113,9 +114,25 @@ export declare class AgentSession {
113
114
  * turns (dispatch's /model), never mid-run.
114
115
  */
115
116
  setAdapter(adapter: Adapter): void;
117
+ /**
118
+ * PH-1a (finding PH-F8, P0): the ATOMIC model switch — adapter, model
119
+ * id, and provider route replace together, effective at the next run.
120
+ * setAdapter alone is for a same-binding adapter swap (the faux
121
+ * re-arm); a switch that changes WHAT model answers must come through
122
+ * here, or the UI claims one model while requests carry another.
123
+ * Omitting `provider` clears the route — an unknown binding is
124
+ * canonicalized under the honest "adapter" route (null-priced), never
125
+ * the stale one. (The context window joins the binding when per-model
126
+ * metadata exists — the PH-1c registry.)
127
+ */
128
+ setModelBinding(binding: {
129
+ readonly adapter: Adapter;
130
+ readonly model: string;
131
+ readonly provider?: "anthropic" | "openai-compat";
132
+ }): void;
116
133
  /** E2: the adapter identity ("anthropic" | "openai-compat") — the route
117
134
  * key the canonical consumer (CLI usage, the trace block) keys on. The
118
- * per-run tracer reads the SAME #config.provider; one source, one
135
+ * per-run tracer reads the SAME live binding; one source, one
119
136
  * route — the CLI and the trace can never disagree. */
120
137
  get provider(): "anthropic" | "openai-compat" | undefined;
121
138
  /**
@@ -158,6 +175,7 @@ export declare class AgentSession {
158
175
  /** Run one user turn. Iterate to consume; `run.abort()` cancels. */
159
176
  run(input: string, options?: {
160
177
  signal?: AbortSignalLike;
178
+ source?: import("@vincemakes/kiso-core").MessageSource;
161
179
  }): Run;
162
180
  /**
163
181
  * Continue the interrupted run (Area 2): apply durable decisions,
@@ -220,6 +238,20 @@ export declare class AgentSession {
220
238
  approve(decisionId: string, allow: boolean, reason?: string): Promise<void>;
221
239
  /** Executions that started but never reported a result (crash window). */
222
240
  uncertainExecutions(): import("./ledger.js").ExecutionRecord[];
241
+ /**
242
+ * TV-1A — assess the task claims and their evidence freshness over THIS
243
+ * session's durable log. The non-mutating set comes from the live tools'
244
+ * own `effects.precommitSafe` certificates (one direction of truth,
245
+ * never a second declaration) — the read-only+free+local contract, the
246
+ * only certificate that proves the world untouched. `concurrency:
247
+ * "shared"` is a SCHEDULING promise and never feeds this set (TV-1C —
248
+ * slow_touch is shared and writes). The evidence policy defaults to
249
+ * {"shell"} — the convention the task extension's own "make the LAST
250
+ * item a verification step" guidance produces.
251
+ */
252
+ assessTasks(opts?: {
253
+ readonly evidenceTools?: ReadonlySet<string>;
254
+ }): TaskAssessment;
223
255
  /**
224
256
  * The human's verdict on an interrupted execution, keyed by EXECUTION ID
225
257
  * (B group): "rerun" (the human says the side effect did NOT happen — the
package/dist/session.js CHANGED
@@ -29,9 +29,14 @@
29
29
  */
30
30
  import { EventLog, projectMessages, } from "@vincemakes/kiso-core";
31
31
  import { executionLedger } from "./ledger.js";
32
+ import { assessTasks } from "./task-assessment.js";
33
+ /** TV-1A — the session-level evidence policy: the PURE projection defaults
34
+ * to ∅ (never inventing evidence); the session names the one built-in
35
+ * verification surface. Override per call for custom evidence tools. */
36
+ const DEFAULT_EVIDENCE_TOOLS = new Set(["shell"]);
32
37
  import { denialResult } from "@vincemakes/kiso-core";
33
38
  import { DROP_PLACEHOLDER, estimateSummarySavings, KEEP_RECENT_ROUNDS, KEEP_TOKENS_DEFAULT, lastSummaryPoint, MAX_SUMMARY_FAILURES, policyTriggerFromWindow, serializeCovered, SUMMARY_MAX_OUTPUT, summarizeConversation, summaryBoundarySeq, } from "./summarize.js";
34
- import { canonicalizeUsage } from "./usage/canonical.js";
39
+ import { canonicalizeUsageForModel } from "./usage/canonical.js";
35
40
  import { appendFileSync, mkdirSync } from "node:fs";
36
41
  import { join } from "node:path";
37
42
  import { estimateTokens } from "@vincemakes/kiso-core";
@@ -67,9 +72,18 @@ export class AgentSession {
67
72
  log;
68
73
  #store;
69
74
  // NOT readonly since 0.1.23: /model replaces it between runs (the
70
- // constructor and setAdapter are the only writers).
75
+ // constructor, setAdapter, and setModelBinding are the only writers).
71
76
  #adapter;
72
77
  #config;
78
+ // PH-1a (finding PH-F8, P0): the LIVE model binding. #config froze the
79
+ // startup model/provider, so a /model switch replaced the adapter while
80
+ // every later run kept sending the OLD model id and canonicalizing
81
+ // usage under the OLD route. These two travel WITH the adapter now —
82
+ // setModelBinding writes all three in one call; runs read them through
83
+ // #effectiveConfig at run construction (next-turn semantics, same as
84
+ // setAdapter always had).
85
+ #model;
86
+ #provider;
73
87
  #pendingResolvers = new Map();
74
88
  #uncertaintyResolvers = new Map();
75
89
  #answered = new Set();
@@ -121,6 +135,16 @@ export class AgentSession {
121
135
  }
122
136
  const composedHooks = composeHooks(config.hooks, config.extensions ?? []);
123
137
  this.#config = composedHooks === undefined ? config : { ...config, hooks: composedHooks };
138
+ this.#model = config.model;
139
+ this.#provider = config.provider;
140
+ }
141
+ /** The config a NEW run/resume/summary sees: the frozen startup config
142
+ * with the LIVE binding fields (model, provider) substituted. Built
143
+ * fresh per call so an in-flight run keeps the config it started with
144
+ * — the same boundary setAdapter has always drawn. */
145
+ #effectiveConfig() {
146
+ const { provider: _startup, ...rest } = this.#config;
147
+ return { ...rest, model: this.#model, ...(this.#provider !== undefined ? { provider: this.#provider } : {}) };
124
148
  }
125
149
  /** Write-ahead through the store; a rejected write POISONS the session
126
150
  * (round 1/round 4): the in-memory log no longer matches the disk — whatever
@@ -165,12 +189,28 @@ export class AgentSession {
165
189
  setAdapter(adapter) {
166
190
  this.#adapter = adapter;
167
191
  }
192
+ /**
193
+ * PH-1a (finding PH-F8, P0): the ATOMIC model switch — adapter, model
194
+ * id, and provider route replace together, effective at the next run.
195
+ * setAdapter alone is for a same-binding adapter swap (the faux
196
+ * re-arm); a switch that changes WHAT model answers must come through
197
+ * here, or the UI claims one model while requests carry another.
198
+ * Omitting `provider` clears the route — an unknown binding is
199
+ * canonicalized under the honest "adapter" route (null-priced), never
200
+ * the stale one. (The context window joins the binding when per-model
201
+ * metadata exists — the PH-1c registry.)
202
+ */
203
+ setModelBinding(binding) {
204
+ this.#adapter = binding.adapter;
205
+ this.#model = binding.model;
206
+ this.#provider = binding.provider;
207
+ }
168
208
  /** E2: the adapter identity ("anthropic" | "openai-compat") — the route
169
209
  * key the canonical consumer (CLI usage, the trace block) keys on. The
170
- * per-run tracer reads the SAME #config.provider; one source, one
210
+ * per-run tracer reads the SAME live binding; one source, one
171
211
  * route — the CLI and the trace can never disagree. */
172
212
  get provider() {
173
- return this.#config.provider;
213
+ return this.#provider;
174
214
  }
175
215
  /**
176
216
  * TUI2-R3v2 ③ — ONE model request that belongs to no run (the
@@ -217,8 +257,8 @@ export class AgentSession {
217
257
  root: this.#store.root,
218
258
  sessionId: this.id,
219
259
  runId,
220
- provider: this.#config.provider ?? "adapter",
221
- model: this.#config.model,
260
+ provider: this.#provider ?? "adapter",
261
+ model: this.#model,
222
262
  adapterVersion: runtimeVersion(),
223
263
  purpose: options.purpose,
224
264
  // the manifest's seqRange pointers derive from the log, and a side
@@ -234,7 +274,7 @@ export class AgentSession {
234
274
  let text = "";
235
275
  try {
236
276
  for await (const ev of guarded.stream({
237
- model: this.#config.model,
277
+ model: this.#model,
238
278
  messages: [{ role: "user", content: options.prompt }],
239
279
  systemPrompt: options.systemPrompt,
240
280
  ...(options.maxTokens !== undefined ? { maxTokens: options.maxTokens } : {}),
@@ -252,7 +292,7 @@ export class AgentSession {
252
292
  /** Run one user turn. Iterate to consume; `run.abort()` cancels. */
253
293
  run(input, options) {
254
294
  this.ensureHealthy();
255
- return new Run(this.#store, this.#adapter, this.#config, this, input, options?.signal, false);
295
+ return new Run(this.#store, this.#adapter, this.#effectiveConfig(), this, input, options?.signal, false, options?.source);
256
296
  }
257
297
  /**
258
298
  * Continue the interrupted run (Area 2): apply durable decisions,
@@ -262,7 +302,7 @@ export class AgentSession {
262
302
  */
263
303
  resume() {
264
304
  this.ensureHealthy();
265
- return new Run(this.#store, this.#adapter, this.#config, this, undefined, undefined, true);
305
+ return new Run(this.#store, this.#adapter, this.#effectiveConfig(), this, undefined, undefined, true);
266
306
  }
267
307
  /**
268
308
  * /compact (ADR-0044): compress the older conversation with a model
@@ -317,7 +357,7 @@ export class AgentSession {
317
357
  else {
318
358
  const call = await summarizeConversation({
319
359
  adapter: this.#adapter,
320
- model: this.#config.model,
360
+ model: this.#model,
321
361
  // E6 (a): ONE serialized user message — the DSML bug's
322
362
  // raw-message array is structurally dead on this path.
323
363
  messages: [{ role: "user", content: serializedInput }],
@@ -351,7 +391,7 @@ export class AgentSession {
351
391
  // a degraded ledger costs one stderr line, never the summary.
352
392
  if (usage !== null) {
353
393
  try {
354
- const canonical = canonicalizeUsage(this.#config.provider ?? "adapter", usage);
394
+ const canonical = canonicalizeUsageForModel(this.#model, undefined, this.#provider ?? "adapter", usage);
355
395
  const line = JSON.stringify({ kind: "summary", canonical }) + "\n";
356
396
  mkdirSync(join(this.#store.root, "traces"), { recursive: true });
357
397
  appendFileSync(join(this.#store.root, "traces", `${this.id}.jsonl`), line);
@@ -528,6 +568,28 @@ export class AgentSession {
528
568
  uncertainExecutions() {
529
569
  return [...executionLedger(this.log.all).values()].filter((r) => r.status === "uncertain");
530
570
  }
571
+ /**
572
+ * TV-1A — assess the task claims and their evidence freshness over THIS
573
+ * session's durable log. The non-mutating set comes from the live tools'
574
+ * own `effects.precommitSafe` certificates (one direction of truth,
575
+ * never a second declaration) — the read-only+free+local contract, the
576
+ * only certificate that proves the world untouched. `concurrency:
577
+ * "shared"` is a SCHEDULING promise and never feeds this set (TV-1C —
578
+ * slow_touch is shared and writes). The evidence policy defaults to
579
+ * {"shell"} — the convention the task extension's own "make the LAST
580
+ * item a verification step" guidance produces.
581
+ */
582
+ assessTasks(opts) {
583
+ const nonMutatingTools = new Set();
584
+ for (const tool of this.#config.registry.list()) {
585
+ if (tool.effects?.precommitSafe === true)
586
+ nonMutatingTools.add(tool.name);
587
+ }
588
+ return assessTasks(this.log.all, {
589
+ nonMutatingTools,
590
+ evidenceTools: opts?.evidenceTools ?? DEFAULT_EVIDENCE_TOOLS,
591
+ });
592
+ }
531
593
  /**
532
594
  * The human's verdict on an interrupted execution, keyed by EXECUTION ID
533
595
  * (B group): "rerun" (the human says the side effect did NOT happen — the
@@ -0,0 +1,93 @@
1
+ /**
2
+ * TV-1A — TaskAssessment as a PURE PROJECTION + Evidence Freshness.
3
+ *
4
+ * The task checklist the model maintains through task_set is a SELF-REPORT:
5
+ * an item is "done" because the model said so. This projection mechanically
6
+ * separates that CLAIM from VERIFIED, under one frozen sentence:
7
+ *
8
+ * Verified ⟹ evidenceSeq > lastRelevantMutationSeq.
9
+ *
10
+ * No new event kind, no core diff — the projection reads the existing
11
+ * durable vocabulary (`deriveRecoveryPlan` is the shape precedent). The
12
+ * classifiers are maximally conservative; the exemptions are exactly
13
+ * three (TV-1C — no wider taxonomy exists or is wanted):
14
+ *
15
+ * - MUTATION marker: `tool_execution_started` (intent-to-effect) of any
16
+ * name NOT in `nonMutatingTools`. Started — not the receipt — so failed
17
+ * and crash-window executions invalidate too, and pre-EC-1 overlap eras
18
+ * are covered by the same rule. Absence of a certificate is a mutation.
19
+ * The exemption set is fed by `effects.precommitSafe` — the frozen
20
+ * read-only+free+local contract, the only certificate that PROVES the
21
+ * world untouched. `concurrency: "shared"` proves overlap-safety and
22
+ * nothing else (the TV-1C finding: slow_touch is shared AND writes) —
23
+ * it never feeds this set.
24
+ * ONE proven exception (TV-1B, grounded in WR-1A): an execution whose
25
+ * terminal receipt is failed(errorKind:"precondition") never counts —
26
+ * that kind's frozen contract is "work refused BEFORE it starts".
27
+ * - EVIDENCE: only receipts (`tool_execution_succeeded`) of names in
28
+ * `evidenceTools`. Default ∅ — the projection never invents evidence.
29
+ * What TV-1A's verdict asserts is POSITIONAL: the arc's last
30
+ * intent-to-effect was a successful evidence-class run — an "evidence",
31
+ * never a "proof" (semantic knowledge is the TV-1B driver's).
32
+ * - task_set itself never invalidates: recording the claim after the check
33
+ * is the natural arc (tests → mark done), and the claim-recording act
34
+ * mutates nothing the evidence observed.
35
+ * - VOIDED RANGES (TV-1C): a `model_output_abandoned` marker voids
36
+ * (voidFromSeq, seq] — the durable vocabulary's own "never happened",
37
+ * the same ranges the kernel projection skips. Every consumer here
38
+ * skips them uniformly: a voided echo is not a claim, a voided receipt
39
+ * is not evidence, a voided start is not a mutation. task_set waits
40
+ * for turn commit today (TT-1), so the scheduler already keeps claims
41
+ * out of voided turns — the projection encodes the admissibility rule
42
+ * itself instead of relying on that forever.
43
+ *
44
+ * `verified` means FRESH EVIDENCE UNDER THE CONFIGURED POLICY — never
45
+ * "objectively proven correct". Goal Truth is external (PE-1's
46
+ * evaluator); this projection only ever ranks the model's own claim
47
+ * against the trajectory's own receipts.
48
+ */
49
+ import type { Event } from "@vincemakes/kiso-core";
50
+ /** One item of the model's plan, exactly as last claimed. */
51
+ export interface TaskClaim {
52
+ readonly text: string;
53
+ readonly status: "pending" | "active" | "done";
54
+ }
55
+ /** The freshness verdict. `stale` and `unreadable` NAME their cause. */
56
+ export type EvidenceVerdict = {
57
+ readonly kind: "verified";
58
+ readonly evidenceSeq: number;
59
+ } | {
60
+ readonly kind: "stale";
61
+ readonly evidenceSeq: number;
62
+ readonly invalidatedBySeq: number;
63
+ } | {
64
+ readonly kind: "none";
65
+ } | {
66
+ readonly kind: "unreadable";
67
+ readonly atSeq: number;
68
+ readonly reason: string;
69
+ };
70
+ export interface TaskAssessment {
71
+ /** The LAST successful task_set echo, parsed — the model's claims. */
72
+ readonly claims: readonly TaskClaim[];
73
+ /** True only for a non-empty plan whose every item is claimed done. */
74
+ readonly allClaimedDone: boolean;
75
+ readonly lastTaskSetSeq: number | null;
76
+ /** Seq of the last mutation-class `tool_execution_started`, if any. */
77
+ readonly lastMutationSeq: number | null;
78
+ readonly evidence: EvidenceVerdict;
79
+ }
80
+ /**
81
+ * Assess the task claims and their evidence freshness over a durable
82
+ * trajectory. Pure: same events, same options, same assessment.
83
+ */
84
+ export declare function assessTasks(events: readonly Event[], opts?: {
85
+ /** Names whose executions PROVABLY never mutate the observable
86
+ * world (from `effects.precommitSafe` certificates — read-only +
87
+ * free + local). `concurrency: "shared"` is NOT such a proof
88
+ * (TV-1C). Default ∅ — everything mutates. */
89
+ readonly nonMutatingTools?: ReadonlySet<string>;
90
+ /** Names whose successful receipts count as evidence. Default ∅ —
91
+ * nothing does. */
92
+ readonly evidenceTools?: ReadonlySet<string>;
93
+ }): TaskAssessment;
@@ -0,0 +1,165 @@
1
+ /**
2
+ * TV-1A — TaskAssessment as a PURE PROJECTION + Evidence Freshness.
3
+ *
4
+ * The task checklist the model maintains through task_set is a SELF-REPORT:
5
+ * an item is "done" because the model said so. This projection mechanically
6
+ * separates that CLAIM from VERIFIED, under one frozen sentence:
7
+ *
8
+ * Verified ⟹ evidenceSeq > lastRelevantMutationSeq.
9
+ *
10
+ * No new event kind, no core diff — the projection reads the existing
11
+ * durable vocabulary (`deriveRecoveryPlan` is the shape precedent). The
12
+ * classifiers are maximally conservative; the exemptions are exactly
13
+ * three (TV-1C — no wider taxonomy exists or is wanted):
14
+ *
15
+ * - MUTATION marker: `tool_execution_started` (intent-to-effect) of any
16
+ * name NOT in `nonMutatingTools`. Started — not the receipt — so failed
17
+ * and crash-window executions invalidate too, and pre-EC-1 overlap eras
18
+ * are covered by the same rule. Absence of a certificate is a mutation.
19
+ * The exemption set is fed by `effects.precommitSafe` — the frozen
20
+ * read-only+free+local contract, the only certificate that PROVES the
21
+ * world untouched. `concurrency: "shared"` proves overlap-safety and
22
+ * nothing else (the TV-1C finding: slow_touch is shared AND writes) —
23
+ * it never feeds this set.
24
+ * ONE proven exception (TV-1B, grounded in WR-1A): an execution whose
25
+ * terminal receipt is failed(errorKind:"precondition") never counts —
26
+ * that kind's frozen contract is "work refused BEFORE it starts".
27
+ * - EVIDENCE: only receipts (`tool_execution_succeeded`) of names in
28
+ * `evidenceTools`. Default ∅ — the projection never invents evidence.
29
+ * What TV-1A's verdict asserts is POSITIONAL: the arc's last
30
+ * intent-to-effect was a successful evidence-class run — an "evidence",
31
+ * never a "proof" (semantic knowledge is the TV-1B driver's).
32
+ * - task_set itself never invalidates: recording the claim after the check
33
+ * is the natural arc (tests → mark done), and the claim-recording act
34
+ * mutates nothing the evidence observed.
35
+ * - VOIDED RANGES (TV-1C): a `model_output_abandoned` marker voids
36
+ * (voidFromSeq, seq] — the durable vocabulary's own "never happened",
37
+ * the same ranges the kernel projection skips. Every consumer here
38
+ * skips them uniformly: a voided echo is not a claim, a voided receipt
39
+ * is not evidence, a voided start is not a mutation. task_set waits
40
+ * for turn commit today (TT-1), so the scheduler already keeps claims
41
+ * out of voided turns — the projection encodes the admissibility rule
42
+ * itself instead of relying on that forever.
43
+ *
44
+ * `verified` means FRESH EVIDENCE UNDER THE CONFIGURED POLICY — never
45
+ * "objectively proven correct". Goal Truth is external (PE-1's
46
+ * evaluator); this projection only ever ranks the model's own claim
47
+ * against the trajectory's own receipts.
48
+ */
49
+ const TASK_TOOL = "task_set";
50
+ const STATUSES = new Set(["pending", "active", "done"]);
51
+ const COUNT_LINE = /^\[task\] (\d+) items? — (\d+) pending, (\d+) active, (\d+) done$/;
52
+ const ITEM_LINE = /^\[(\w+)\] (.*)$/;
53
+ /** Parse the canonical task_set echo (the frozen content contract the
54
+ * checklist cell also reads). Returns the claims, or the reason it
55
+ * cannot — never a guess. */
56
+ function parseEcho(content) {
57
+ const lines = content.split("\n");
58
+ const head = COUNT_LINE.exec(lines[0] ?? "");
59
+ if (head === null)
60
+ return { reason: "line 1 is not the [task] count line" };
61
+ const total = Number(head[1]);
62
+ const declared = { pending: Number(head[2]), active: Number(head[3]), done: Number(head[4]) };
63
+ const claims = [];
64
+ for (let i = 1; i < lines.length; i += 1) {
65
+ const m = ITEM_LINE.exec(lines[i]);
66
+ if (m === null || !STATUSES.has(m[1]))
67
+ return { reason: `line ${i + 1} is not a [pending|active|done] item line` };
68
+ claims.push({ text: m[2], status: m[1] });
69
+ }
70
+ const counts = { pending: 0, active: 0, done: 0 };
71
+ for (const c of claims)
72
+ counts[c.status] += 1;
73
+ if (claims.length !== total || counts.pending !== declared.pending || counts.active !== declared.active || counts.done !== declared.done) {
74
+ return { reason: "the count line disagrees with the item lines" };
75
+ }
76
+ return { claims };
77
+ }
78
+ /**
79
+ * Assess the task claims and their evidence freshness over a durable
80
+ * trajectory. Pure: same events, same options, same assessment.
81
+ */
82
+ export function assessTasks(events, opts) {
83
+ const nonMutating = opts?.nonMutatingTools ?? new Set();
84
+ const evidenceNames = opts?.evidenceTools ?? new Set();
85
+ // TV-1C: PASS 0 — the voided ranges. (voidFromSeq, seq] of every
86
+ // `model_output_abandoned` marker is "never happened" for this whole
87
+ // projection, exactly as the kernel's own context projection treats
88
+ // it. Markers are rare; a linear range check is honest and enough.
89
+ const voidRanges = [];
90
+ for (const ev of events) {
91
+ if (ev.type === "model_output_abandoned")
92
+ voidRanges.push({ from: ev.voidFromSeq, to: ev.seq });
93
+ }
94
+ const voided = (seq) => voidRanges.some((r) => seq > r.from && seq <= r.to);
95
+ // TV-1B: PASS 1 — executions whose terminal receipt is a
96
+ // PRECONDITION failure are PROVEN no-mutation (WR-1A froze that
97
+ // contract: work refused before it starts). Everything else stays
98
+ // conservative: no receipt (crash window), fatal/transient/
99
+ // invalid_input, success, and legacy receipts with no errorKind.
100
+ // Two passes, no temporal rollback state — same events, same verdict.
101
+ const provenNoMutation = new Set();
102
+ for (const ev of events) {
103
+ if (voided(ev.seq))
104
+ continue;
105
+ if (ev.type === "tool_execution_failed" && ev.errorKind === "precondition") {
106
+ provenNoMutation.add(ev.executionId);
107
+ }
108
+ }
109
+ const nameByExecution = new Map();
110
+ let claims = [];
111
+ let lastTaskSetSeq = null;
112
+ let unreadable = null;
113
+ let lastMutationSeq = null;
114
+ let lastEvidenceSeq = null;
115
+ let staleBySeq = null;
116
+ for (const ev of events) {
117
+ if (voided(ev.seq))
118
+ continue;
119
+ if (ev.type === "tool_execution_started") {
120
+ nameByExecution.set(ev.executionId, ev.name);
121
+ if (ev.name !== TASK_TOOL && !nonMutating.has(ev.name) && !provenNoMutation.has(ev.executionId)) {
122
+ lastMutationSeq = ev.seq;
123
+ if (lastEvidenceSeq !== null && staleBySeq === null)
124
+ staleBySeq = ev.seq;
125
+ }
126
+ }
127
+ else if (ev.type === "tool_execution_succeeded") {
128
+ const name = nameByExecution.get(ev.executionId);
129
+ if (name === TASK_TOOL) {
130
+ const parsed = parseEcho(ev.result.content);
131
+ if ("reason" in parsed) {
132
+ unreadable = { atSeq: ev.seq, reason: parsed.reason };
133
+ claims = [];
134
+ lastTaskSetSeq = ev.seq;
135
+ }
136
+ else {
137
+ unreadable = null;
138
+ claims = parsed.claims;
139
+ lastTaskSetSeq = ev.seq;
140
+ }
141
+ }
142
+ else if (name !== undefined && evidenceNames.has(name)) {
143
+ // a fresh evidence receipt supersedes both the older evidence
144
+ // AND any staleness its own start incurred.
145
+ lastEvidenceSeq = ev.seq;
146
+ staleBySeq = null;
147
+ }
148
+ }
149
+ }
150
+ const allClaimedDone = claims.length > 0 && claims.every((c) => c.status === "done");
151
+ let evidence;
152
+ if (unreadable !== null) {
153
+ evidence = { kind: "unreadable", atSeq: unreadable.atSeq, reason: unreadable.reason };
154
+ }
155
+ else if (lastEvidenceSeq === null) {
156
+ evidence = { kind: "none" };
157
+ }
158
+ else if (staleBySeq !== null) {
159
+ evidence = { kind: "stale", evidenceSeq: lastEvidenceSeq, invalidatedBySeq: staleBySeq };
160
+ }
161
+ else {
162
+ evidence = { kind: "verified", evidenceSeq: lastEvidenceSeq };
163
+ }
164
+ return { claims, allClaimedDone, lastTaskSetSeq, lastMutationSeq, evidence };
165
+ }
@@ -25,7 +25,7 @@ import { randomUUID } from "node:crypto";
25
25
  import { buildContextManifest, segmentHashes } from "./manifest.js";
26
26
  import { cacheableHashes } from "./analyze.js";
27
27
  import { hashContext, hashSystemPrompt, hashToolSpecs, stablePrefixFingerprint } from "./hash.js";
28
- import { PRICING_TABLE_V1, canonicalizeUsage } from "../usage/canonical.js";
28
+ import { PRICING_TABLE_V1, canonicalizeUsageForModel } from "../usage/canonical.js";
29
29
  import { buildRentLedger } from "./rent.js";
30
30
  import { TRACE_SCHEMA_VERSION } from "./record.js";
31
31
  import { TraceWriter } from "./writer.js";
@@ -201,11 +201,14 @@ export class RequestTracer {
201
201
  record.cacheWrite = p.cacheWrite ?? null;
202
202
  record.output = p.outputTokens ?? 0;
203
203
  }
204
- // E2 — the canonical block formalizes the same raw (the route-keyed
205
- // mapping; the validator pins block == quartet, so the two can never
206
- // drift apart). Unknown usage canonicalizes to the "0 = unknown"
207
- // convention, consistent with the quartet above.
208
- record.canonical = canonicalizeUsage(this.#provider, {
204
+ // E2 — the canonical block formalizes the same raw (the validator
205
+ // pins block == quartet, so the two can never drift apart). Unknown
206
+ // usage canonicalizes to the "0 = unknown" convention, consistent
207
+ // with the quartet above. PH-1c: the PRICE keys on the record's own
208
+ // MODEL via the metadata registry (route-keyed pricing priced an
209
+ // Anthropic run at DeepSeek's rates); the convention still keys on
210
+ // the route. An unpriced model costs null — never a fallback rate.
211
+ record.canonical = canonicalizeUsageForModel(record.model, undefined, this.#provider, {
209
212
  inputTokens: p.inputTokens,
210
213
  outputTokens: p.outputTokens,
211
214
  cacheRead: p.cacheRead,
@@ -111,6 +111,21 @@ export declare function priceFor(route: string, u: {
111
111
  * builtin v1 table so the export's default behavior is the pinned one
112
112
  * and a future injection never widens this frozen signature. */
113
113
  export declare function canonicalizeUsage(route: string, raw: RawUsage, table?: PricingTable): CanonicalUsage;
114
+ /**
115
+ * PH-1c — the MODEL-KEYED cost path (findings PH-F14/PH-F16). The
116
+ * convention still keys on the ROUTE (a protocol property: fresh vs
117
+ * total input); the PRICE keys on the model+endpoint metadata entry —
118
+ * the route-keyed table is how an Anthropic run got priced at
119
+ * DeepSeek's rates. No metadata entry, or an entry with `pricing:
120
+ * null`, costs null — the honest stamp, never a fallback rate.
121
+ *
122
+ * A NEW function beside canonicalizeUsage, not a widened signature —
123
+ * the E2 ruling froze that one ("a future injection never widens this
124
+ * frozen signature"), and this is the future it reserved room for.
125
+ * Metadata-priced records stamp pricingTableId "metadata" (version 1);
126
+ * the (id, version) tuple ritual carries forward unchanged.
127
+ */
128
+ export declare function canonicalizeUsageForModel(model: string, endpoint: string | undefined, route: string, raw: RawUsage): CanonicalUsage;
114
129
  /** The canonical schema's invariants, machine-checked: closed key set;
115
130
  * non-negative numbers; cacheWrite/reasoning null-or-number; the pinned
116
131
  * sentence — cacheRead can never exceed total (= input + cacheRead +
@@ -27,6 +27,7 @@
27
27
  * accounting boundary (R4 Case B, pure derivation; the union does not
28
28
  * move). Raw is provider observation; canonical is the accounting truth.
29
29
  */
30
+ import { lookupModelMetadata } from "../provider/metadata.js";
30
31
  /** The route → input-convention table (R2a). `input` semantics differ
31
32
  * between the two routes; the convention is a property of the ROUTE, not
32
33
  * the provider. Unknown routes fall back to "total" — exactly the
@@ -61,15 +62,14 @@ export function pricingTableFor(version) {
61
62
  return table;
62
63
  }
63
64
  export function priceFor(route, u, table) {
64
- // The R5b-④c semantics: a REAL route (an INPUT_CONVENTIONS key — the
65
- // routes the table is expected to price) missing from the table is a
66
- // HOLE null, the explicit-absent stamp. The builtin table never
67
- // backfills an injected table's hole. Unknown routes keep the legacy
68
- // within-table mirror fallback (the total-convention entry, matching
69
- // the convention fallback above one table, one fallback, no drift);
70
- // a table without even the mirror entry yields null, never a crash.
71
- const entry = table.entries[route] ??
72
- (route in INPUT_CONVENTIONS ? undefined : table.entries["openai-compat"]);
65
+ // PH-1a (supersedes R5b-④c's mirror fallback): a route missing from
66
+ // the table is a HOLE null, the explicit-absent stamp — REAL routes
67
+ // and unknown routes alike. The retired mirror fallback priced the
68
+ // "adapter" route (a directly-injected adapter, finding PH-F7) at the
69
+ // openai-compat entry: a fabricated cost on exactly the runs whose
70
+ // rates are unknown. null is the honest answer; the builtin table
71
+ // never backfills an injected table's hole either.
72
+ const entry = table.entries[route];
73
73
  if (entry === undefined)
74
74
  return null;
75
75
  return ((u.input * entry.inputPerM +
@@ -107,6 +107,37 @@ export function canonicalizeUsage(route, raw, table = PRICING_TABLE_V1) {
107
107
  pricingTableVersion: table.version,
108
108
  };
109
109
  }
110
+ /**
111
+ * PH-1c — the MODEL-KEYED cost path (findings PH-F14/PH-F16). The
112
+ * convention still keys on the ROUTE (a protocol property: fresh vs
113
+ * total input); the PRICE keys on the model+endpoint metadata entry —
114
+ * the route-keyed table is how an Anthropic run got priced at
115
+ * DeepSeek's rates. No metadata entry, or an entry with `pricing:
116
+ * null`, costs null — the honest stamp, never a fallback rate.
117
+ *
118
+ * A NEW function beside canonicalizeUsage, not a widened signature —
119
+ * the E2 ruling froze that one ("a future injection never widens this
120
+ * frozen signature"), and this is the future it reserved room for.
121
+ * Metadata-priced records stamp pricingTableId "metadata" (version 1);
122
+ * the (id, version) tuple ritual carries forward unchanged.
123
+ */
124
+ export function canonicalizeUsageForModel(model, endpoint, route, raw) {
125
+ const base = canonicalizeUsage(route, raw, EMPTY_TABLE); // convention math only — EMPTY_TABLE prices nothing
126
+ const entry = lookupModelMetadata(model, endpoint);
127
+ const pricing = entry?.pricing ?? null;
128
+ if (pricing === null) {
129
+ return { ...base, costUsd: null, pricingTableId: "metadata", pricingTableVersion: 1 };
130
+ }
131
+ const costUsd = (base.input * pricing.inputPerM +
132
+ base.output * pricing.outputPerM +
133
+ base.cacheRead * pricing.cacheReadPerM +
134
+ (base.cacheWrite ?? 0) * pricing.cacheWritePerM) /
135
+ 1e6;
136
+ return { ...base, costUsd, pricingTableId: "metadata", pricingTableVersion: 1 };
137
+ }
138
+ /** No entries at all — canonicalizeUsageForModel's convention-only pass
139
+ * through canonicalizeUsage; every price it could produce is null. */
140
+ const EMPTY_TABLE = { id: "metadata", version: 1, entries: {} };
110
141
  // ── The validator ──────────────────────────────────────────────────────────
111
142
  // Strict by design: closed keys (a misspelled field can never enter a
112
143
  // ledger), non-negative numbers, the pinned sentence machine-checked, and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-runtime",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "kiso runtime — durable multi-turn agent sessions: AgentDefinition, AgentRuntime, AgentSession, Run, append-only JSONL store.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,11 +25,11 @@
25
25
  "test": "vitest run"
26
26
  },
27
27
  "dependencies": {
28
- "@vincemakes/kiso-core": "0.13.0"
28
+ "@vincemakes/kiso-core": "0.15.0"
29
29
  },
30
30
  "peerDependencies": {
31
- "@vincemakes/kiso-provider-anthropic": "0.13.0",
32
- "@vincemakes/kiso-provider-openai": "0.13.0"
31
+ "@vincemakes/kiso-provider-anthropic": "0.15.0",
32
+ "@vincemakes/kiso-provider-openai": "0.15.0"
33
33
  },
34
34
  "peerDependenciesMeta": {
35
35
  "@vincemakes/kiso-provider-anthropic": {
@@ -40,7 +40,7 @@
40
40
  }
41
41
  },
42
42
  "devDependencies": {
43
- "@vincemakes/kiso-evals": "0.13.0",
43
+ "@vincemakes/kiso-evals": "0.15.0",
44
44
  "@types/node": "^26.1.2",
45
45
  "typescript": "^5.7.2",
46
46
  "vitest": "^3.0.0"