@konneal/engine 0.2.126 → 0.2.130

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.
@@ -1,10 +1,10 @@
1
1
  import {
2
2
  handleAsk
3
- } from "./chunk-ZHHORWM6.js";
3
+ } from "./chunk-AIL7MJS3.js";
4
4
  import "./chunk-J6LBR6GI.js";
5
- import "./chunk-2JUWER2A.js";
5
+ import "./chunk-7AXXR7GD.js";
6
6
  import "./chunk-TH5XGPD5.js";
7
- import "./chunk-DJ65EF5G.js";
7
+ import "./chunk-W7J5SFXJ.js";
8
8
  import "./chunk-A4A26BCV.js";
9
9
  import "./chunk-4NXH5ZI6.js";
10
10
  import "./chunk-VT7DR6NQ.js";
@@ -9,7 +9,7 @@ import {
9
9
  import {
10
10
  bubbleConfirmPage,
11
11
  isAllowedBubbleOrigin
12
- } from "./chunk-DJ65EF5G.js";
12
+ } from "./chunk-W7J5SFXJ.js";
13
13
  import {
14
14
  DATASETS,
15
15
  LIMITS,
@@ -24725,6 +24725,18 @@ function usageTotal(usage) {
24725
24725
  const total = p + c;
24726
24726
  return Number.isFinite(total) && total > 0 ? total : null;
24727
24727
  }
24728
+ var CREDIT_TOKEN_UNIT = 1e3;
24729
+ function creditLimit(env, bucketId) {
24730
+ return Math.floor(tokenLimit(env, bucketId) / CREDIT_TOKEN_UNIT);
24731
+ }
24732
+ async function creditBudget(env, bucketId) {
24733
+ const b = await tokenBudget(env, bucketId);
24734
+ return { credits_used: Math.ceil(b.used / CREDIT_TOKEN_UNIT), credits_limit: creditLimit(env, bucketId) };
24735
+ }
24736
+ function creditEstimate(opts) {
24737
+ const effortCredits = opts.effort === "low" ? 1 : opts.effort === "medium" ? 2 : 3;
24738
+ return effortCredits + Math.max(0, opts.toolTurns) + (opts.image ? 1 : 0);
24739
+ }
24728
24740
 
24729
24741
  // workers/worker_public/src/graph.ts
24730
24742
  function docNumberOf(nodeId) {
@@ -24816,6 +24828,8 @@ export {
24816
24828
  tokenBudget,
24817
24829
  chargeTokens,
24818
24830
  usageTotal,
24831
+ creditBudget,
24832
+ creditEstimate,
24819
24833
  graphExpand,
24820
24834
  editionNote
24821
24835
  };
@@ -34,6 +34,8 @@ import {
34
34
  citations,
35
35
  clientIp,
36
36
  contextNote,
37
+ creditBudget,
38
+ creditEstimate,
37
39
  editionNote,
38
40
  estimateTokens,
39
41
  experimentalSourceLabels,
@@ -63,7 +65,7 @@ import {
63
65
  tokenBudget,
64
66
  understandQuery,
65
67
  usageTotal
66
- } from "./chunk-2JUWER2A.js";
68
+ } from "./chunk-7AXXR7GD.js";
67
69
  import {
68
70
  embed,
69
71
  generateOnce,
@@ -76,7 +78,7 @@ import {
76
78
  json,
77
79
  readJson,
78
80
  validateQuery
79
- } from "./chunk-DJ65EF5G.js";
81
+ } from "./chunk-W7J5SFXJ.js";
80
82
  import {
81
83
  canonicalRefusal,
82
84
  exciseMisplacedPin,
@@ -1788,8 +1790,8 @@ function messageChars(messages) {
1788
1790
  }
1789
1791
  return n;
1790
1792
  }
1791
- async function chargeAnswerTokens(env, bucketId, stats, promptChars, answerChars) {
1792
- const total = usageTotal(stats?.usage) ?? estimateTokens(promptChars + answerChars);
1793
+ async function chargeAnswerTokens(env, bucketId, stats, promptChars, answerChars, extraTokens = 0) {
1794
+ const total = (usageTotal(stats?.usage) ?? estimateTokens(promptChars + answerChars)) + extraTokens;
1793
1795
  await chargeTokens(env, bucketId, total);
1794
1796
  const budget = await tokenBudget(env, bucketId);
1795
1797
  return { tokens_used: budget.used, token_limit: budget.limit };
@@ -1858,15 +1860,18 @@ async function handleAsk(env, ctx, req, tier, key) {
1858
1860
  const effort = requestEffort(env, member, body?.effort);
1859
1861
  const declaredEffort = body?.effort;
1860
1862
  const routeEffort = (route) => route === "fast" && !declaredEffort ? "low" : effort;
1861
- const limit = tier === "key" ? key.day_limit : tier === "member" || member ? num(env, "MEMBER_DAY_ASK", 300) : num(env, "ANON_DAY_ASK", 20);
1862
- const bucketId = tier === "key" ? `key:${key.id}` : member ? `sub:${member.sub}` : clientIp(req);
1863
+ const memberKey = tier === "key" ? key?.owner_sub ?? null : null;
1864
+ const limit = memberKey ? num(env, "MEMBER_DAY_ASK", 300) : tier === "key" ? key.day_limit : tier === "member" || member ? num(env, "MEMBER_DAY_ASK", 300) : num(env, "ANON_DAY_ASK", 20);
1865
+ const bucketId = memberKey ? `sub:${memberKey}` : tier === "key" ? `key:${key.id}` : member ? `sub:${member.sub}` : clientIp(req);
1863
1866
  const quota = await checkQuota(env, "ask", bucketId, limit, effort === "low" ? 1 : 2);
1867
+ const creditPreview = creditEstimate({ effort, toolTurns: body?.multi_turn !== false ? 3 : 0, image: false });
1864
1868
  if (!quota.ok) {
1865
1869
  return err(429, "quota_exceeded", `Daily question limit reached (${quota.limit}). Try again tomorrow.`);
1866
1870
  }
1867
1871
  const tokBudget = await tokenBudget(env, bucketId);
1868
1872
  if (tokBudget.used >= tokBudget.limit) {
1869
- return err(429, "quota_exceeded", `Daily token budget reached (${tokBudget.limit} tokens). Try again tomorrow.`);
1873
+ const cb = await creditBudget(env, bucketId);
1874
+ return err(429, "quota_exceeded", `Daily credits reached (${cb.credits_limit} credits, ${cb.credits_used} used). Try again tomorrow.`);
1870
1875
  }
1871
1876
  quota.tokens_used = tokBudget.used;
1872
1877
  quota.token_limit = tokBudget.limit;
@@ -1928,6 +1933,7 @@ async function handleAsk(env, ctx, req, tier, key) {
1928
1933
  const summary = overflow.length >= 2 ? await summarizeHistory(env, MODELS.understand, overflow) ?? void 0 : void 0;
1929
1934
  let retrieved;
1930
1935
  const fresh = freshRequested(body);
1936
+ const multiTurn = body?.multi_turn !== false;
1931
1937
  const gen = await corpusGen(env.CACHE);
1932
1938
  const withImage = effectiveImage || storedImage;
1933
1939
  const cached = fresh || contextual || declaredCtx || draftAct || apiCallIntent || withImage || ablate ? null : await cacheGet(env, gen, ns, q.query, q.lang, salt);
@@ -2107,7 +2113,7 @@ Certificates are issued per recommendation family \u2014 the register carries no
2107
2113
  context_applied: NO_CONTEXT,
2108
2114
  read: readAs(),
2109
2115
  ...ablate ? { ablate } : {},
2110
- quota: { ...quota, ...tq2 }
2116
+ quota: { ...quota, ...tq2, credits: await creditBudget(env, bucketId), credit_estimate: creditPreview }
2111
2117
  });
2112
2118
  }
2113
2119
  }
@@ -2143,8 +2149,8 @@ ${summary}` }] : [],
2143
2149
  }
2144
2150
  } catch {
2145
2151
  }
2146
- const tq2 = await chargeAnswerTokens(env, bucketId, stats, messageChars(messages2), full.length);
2147
- send({ type: "quota", quota: { ...quota, ...tq2 } });
2152
+ const tq2 = await chargeAnswerTokens(env, bucketId, stats, messageChars(messages2), full.length, probeTokensUsed);
2153
+ send({ type: "quota", quota: { ...quota, ...tq2, credits: await creditBudget(env, bucketId), credit_estimate: creditPreview } });
2148
2154
  send({ type: "done", model, query_hash: queryHash2, context_applied: NO_CONTEXT, read: readAs() });
2149
2155
  telemetry(env, ctx, tier, "ask", model, true, full.length, queryHash2, q.lang, void 0, telemetryMeta());
2150
2156
  controller.close();
@@ -2201,7 +2207,7 @@ ${summary}` }] : [],
2201
2207
  );
2202
2208
  }
2203
2209
  const tq2 = await chargeAnswerTokens(env, bucketId, void 0, q.query.length, verdict.answer.length);
2204
- return json({ answer: verdict.answer, citations: citations2, model, query_hash: queryHash2, follow_ups: [], context_applied: draftCtxApplied, read: readAs(), ...draftPayload ? { draft: draftPayload } : {}, ...ablate ? { ablate } : {}, quota: { ...quota, ...tq2 } });
2210
+ return json({ answer: verdict.answer, citations: citations2, model, query_hash: queryHash2, follow_ups: [], context_applied: draftCtxApplied, read: readAs(), ...draftPayload ? { draft: draftPayload } : {}, ...ablate ? { ablate } : {}, quota: { ...quota, ...tq2, credits: await creditBudget(env, bucketId), credit_estimate: creditPreview } });
2205
2211
  }
2206
2212
  if (apiCallIntent) {
2207
2213
  const callCtxApplied = declaredCtx ? appliedContext(declaredCtx, null) : NO_CONTEXT;
@@ -2229,7 +2235,7 @@ ${summary}` }] : [],
2229
2235
  );
2230
2236
  }
2231
2237
  const tq2 = await chargeAnswerTokens(env, bucketId, void 0, q.query.length, verdict.answer.length);
2232
- return json({ answer: verdict.answer, citations: [], model, query_hash: queryHash2, follow_ups: [], context_applied: callCtxApplied, read: readAs(), ...draftPayload ? { draft: draftPayload } : {}, ...ablate ? { ablate } : {}, quota: { ...quota, ...tq2 } });
2238
+ return json({ answer: verdict.answer, citations: [], model, query_hash: queryHash2, follow_ups: [], context_applied: callCtxApplied, read: readAs(), ...draftPayload ? { draft: draftPayload } : {}, ...ablate ? { ablate } : {}, quota: { ...quota, ...tq2, credits: await creditBudget(env, bucketId), credit_estimate: creditPreview } });
2233
2239
  }
2234
2240
  let liveRecords;
2235
2241
  let accountNote;
@@ -2513,7 +2519,8 @@ Answer account questions from these records ONLY: name the record when you use i
2513
2519
  );
2514
2520
  await attachFigureImages(env, messages, usedHits, q.query);
2515
2521
  const pendingTools = [];
2516
- if (/certificate|certified|certification/i.test(q.query) && env.DB) {
2522
+ let probeTokensUsed = 0;
2523
+ if (multiTurn && /certificate|certified|certification/i.test(q.query) && env.DB) {
2517
2524
  try {
2518
2525
  const imageTurn = typeof withImage === "string";
2519
2526
  if (imageTurn) mark("Reading the photograph to identify the instrument");
@@ -2529,6 +2536,7 @@ ${q.query}`.replace("\n\n\n\n", "\n\n") }];
2529
2536
  reasoning_effort: imageTurn ? "high" : "low"
2530
2537
  });
2531
2538
  const probeText = typeof probe?.response === "string" ? probe.response : probe?.choices?.[0]?.message?.content ?? "";
2539
+ probeTokensUsed += usageTotal(probe?.usage) ?? 0;
2532
2540
  const call = parseToolCall(probeText);
2533
2541
  if (call) {
2534
2542
  mark(`Searching the certificate register for \u201C${String(call.args?.query ?? "")}\u201D`);
@@ -2590,8 +2598,8 @@ ${q.query}`.replace("\n\n\n\n", "\n\n") }];
2590
2598
  }
2591
2599
  } catch {
2592
2600
  }
2593
- const tq2 = await chargeAnswerTokens(env, bucketId, stats, messageChars(messages), full.length);
2594
- send({ type: "quota", quota: { ...quota, ...tq2 } });
2601
+ const tq2 = await chargeAnswerTokens(env, bucketId, stats, messageChars(messages), full.length, probeTokensUsed);
2602
+ send({ type: "quota", quota: { ...quota, ...tq2, credits: await creditBudget(env, bucketId), credit_estimate: creditPreview } });
2595
2603
  if (withImage && register?.rows?.length && full.includes(refusalAnswer())) {
2596
2604
  console.log("blind-answer(stream): regenerating once");
2597
2605
  mark("Re-answering from the register results");
@@ -2810,8 +2818,8 @@ ${q.query}`.replace("\n\n\n\n", "\n\n") }];
2810
2818
  text: h.text.slice(0, 1200),
2811
2819
  ...h.metadata.table_selection ? { sel: h.metadata.table_selection } : {}
2812
2820
  }));
2813
- const tq = await chargeAnswerTokens(env, bucketId, void 0, messageChars(messages), answer.length);
2814
- return json({ ...out, context: contextOut, read: readAs(), quota: { ...quota, ...tq } }, 200, { ...corsHeaders(req), "server-timing": serverTiming() });
2821
+ const tq = await chargeAnswerTokens(env, bucketId, void 0, messageChars(messages), answer.length, probeTokensUsed);
2822
+ return json({ ...out, context: contextOut, read: readAs(), quota: { ...quota, ...tq, credits: await creditBudget(env, bucketId), credit_estimate: creditPreview } }, 200, { ...corsHeaders(req), "server-timing": serverTiming() });
2815
2823
  }
2816
2824
  function sseResponse(events, cors) {
2817
2825
  const encoder = new TextEncoder();
@@ -6,7 +6,7 @@ import {
6
6
  sessionFrom,
7
7
  telemetry,
8
8
  understandQuery
9
- } from "./chunk-2JUWER2A.js";
9
+ } from "./chunk-7AXXR7GD.js";
10
10
  import {
11
11
  portModelRunner
12
12
  } from "./chunk-TH5XGPD5.js";
@@ -16,7 +16,7 @@ import {
16
16
  json,
17
17
  readJson,
18
18
  validateQuery
19
- } from "./chunk-DJ65EF5G.js";
19
+ } from "./chunk-W7J5SFXJ.js";
20
20
  import {
21
21
  entitlementScope,
22
22
  standardKeysFrom
@@ -123,7 +123,7 @@ async function authenticate(env, req) {
123
123
  if (!m) return null;
124
124
  const keyHash = await sha256Hex(m[1].trim());
125
125
  const row = await env.DB.prepare(
126
- "SELECT id, name, day_limit FROM api_keys WHERE key_hash = ?1 AND revoked = 0"
126
+ "SELECT id, name, day_limit, owner_sub FROM api_keys WHERE key_hash = ?1 AND revoked = 0"
127
127
  ).bind(keyHash).first();
128
128
  return row ?? null;
129
129
  }
package/dist/quota.d.ts CHANGED
@@ -23,3 +23,17 @@ export declare function tokenBudget(env: Env, bucketId: string): Promise<{
23
23
  }>;
24
24
  export declare function chargeTokens(env: Env, bucketId: string, tokens: number): Promise<void>;
25
25
  export declare function usageTotal(usage: unknown): number | null;
26
+ export declare const CREDIT_TOKEN_UNIT = 1000;
27
+ export declare function creditLimit(env: Env, bucketId: string): number;
28
+ export declare function creditBudget(env: Env, bucketId: string): Promise<{
29
+ credits_used: number;
30
+ credits_limit: number;
31
+ }>;
32
+ /** The credit-price preview for an ask: effort, tool turns armed, and
33
+ * multimodality each add real consumption — the estimate shown BEFORE
34
+ * generation so users see what a mode costs. Honest, not exact. */
35
+ export declare function creditEstimate(opts: {
36
+ effort: string;
37
+ toolTurns: number;
38
+ image: boolean;
39
+ }): number;
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  handleSearch
3
- } from "./chunk-P6IRYZLL.js";
4
- import "./chunk-2JUWER2A.js";
3
+ } from "./chunk-MWOY24B6.js";
4
+ import "./chunk-7AXXR7GD.js";
5
5
  import "./chunk-TH5XGPD5.js";
6
- import "./chunk-DJ65EF5G.js";
6
+ import "./chunk-W7J5SFXJ.js";
7
7
  import "./chunk-A4A26BCV.js";
8
8
  import "./chunk-4NXH5ZI6.js";
9
9
  import "./chunk-VT7DR6NQ.js";
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  authenticate
3
- } from "../../chunk-DJ65EF5G.js";
3
+ } from "../../chunk-W7J5SFXJ.js";
4
4
  import "../../chunk-VT7DR6NQ.js";
5
5
  import {
6
6
  P,
@@ -4,7 +4,7 @@ import {
4
4
  handleMemories,
5
5
  scoreFaithfulness,
6
6
  scoreJudge
7
- } from "../../chunk-ZHHORWM6.js";
7
+ } from "../../chunk-AIL7MJS3.js";
8
8
  import {
9
9
  TOOLS_REGISTRY,
10
10
  bindModelNode,
@@ -12,7 +12,7 @@ import {
12
12
  } from "../../chunk-J6LBR6GI.js";
13
13
  import {
14
14
  handleSearch
15
- } from "../../chunk-P6IRYZLL.js";
15
+ } from "../../chunk-MWOY24B6.js";
16
16
  import {
17
17
  buildMessages,
18
18
  citations,
@@ -34,7 +34,7 @@ import {
34
34
  sessionFrom,
35
35
  telemetry,
36
36
  understandQuery
37
- } from "../../chunk-2JUWER2A.js";
37
+ } from "../../chunk-7AXXR7GD.js";
38
38
  import {
39
39
  cfBlobs,
40
40
  embed,
@@ -49,7 +49,7 @@ import {
49
49
  readJson,
50
50
  validateQuery,
51
51
  withCors
52
- } from "../../chunk-DJ65EF5G.js";
52
+ } from "../../chunk-W7J5SFXJ.js";
53
53
  import {
54
54
  canonicalRefusal
55
55
  } from "../../chunk-A4A26BCV.js";
@@ -710,20 +710,21 @@ async function handleCreateKey(env, req) {
710
710
  const body = await readJson(req);
711
711
  if (!body?.name || typeof body.name !== "string") return err(400, "invalid_input", "name is required");
712
712
  const dayLimit = Number.isFinite(Number(body.day_limit)) && Number(body.day_limit) > 0 ? Number(body.day_limit) : num(env, "KEY_DAY_ASK_DEFAULT", 2e3);
713
+ const memberSub = typeof body?.member_sub === "string" && body.member_sub.trim() ? body.member_sub.trim().slice(0, 64) : null;
713
714
  const raw = `${P().publisher.id}_${[...crypto.getRandomValues(new Uint8Array(24))].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
714
715
  const id = crypto.randomUUID();
715
716
  const keyHash = await sha256Hex(raw);
716
717
  await env.DB.prepare(
717
- "INSERT INTO api_keys (id, name, key_hash, day_limit, created_at, revoked) VALUES (?1,?2,?3,?4,?5,0)"
718
- ).bind(id, body.name, keyHash, dayLimit, (/* @__PURE__ */ new Date()).toISOString()).run();
719
- return json({ id, name: body.name, day_limit: dayLimit, key: raw, note: "Store this key now \u2014 it is not retrievable again." });
718
+ "INSERT INTO api_keys (id, name, key_hash, day_limit, owner_sub, created_at, revoked) VALUES (?1,?2,?3,?4,?5,?6,0)"
719
+ ).bind(id, body.name, keyHash, dayLimit, memberSub, (/* @__PURE__ */ new Date()).toISOString()).run();
720
+ return json({ id, name: body.name, day_limit: dayLimit, ...memberSub ? { member_sub: memberSub } : {}, key: raw, note: "Store this key now \u2014 it is not retrievable again." + (memberSub ? " This key draws from its member's shared daily credit cap." : "") });
720
721
  }
721
722
  async function handleListKeys(env, req) {
722
723
  if (!env.ADMIN_TOKEN) return err(501, "admin_disabled", "ADMIN_TOKEN secret is not configured");
723
724
  const auth = req.headers.get("authorization") ?? "";
724
725
  if (auth !== `Bearer ${env.ADMIN_TOKEN}`) return err(401, "unauthorized", "Invalid admin token");
725
726
  const rows = await env.DB.prepare(
726
- "SELECT id, name, day_limit, created_at, revoked FROM api_keys ORDER BY created_at DESC"
727
+ "SELECT id, name, day_limit, owner_sub, created_at, revoked FROM api_keys ORDER BY created_at DESC"
727
728
  ).all();
728
729
  return json({ keys: rows.results, ...corsHeaders(req) });
729
730
  }
@@ -1049,7 +1050,7 @@ async function callToolOnce(env, ctx, _req, tier, key, name, args, t0) {
1049
1050
  const r = await runTool({ ...env, DB: env.DB }, { name, args }, "mcp");
1050
1051
  payload = r ? { tool: r.name, query: r.query, result: r.output } : { error: { message: `tool ${name} returned nothing for the given arguments` } };
1051
1052
  } else {
1052
- const res = name === "ask" ? await (await import("../../ask-RAQHKSPN.js")).handleAsk(env, ctx, inner, tier, key) : await (await import("../../search-OVUHVV35.js")).handleSearch(env, ctx, inner, tier, key);
1053
+ const res = name === "ask" ? await (await import("../../ask-5D2TYRXH.js")).handleAsk(env, ctx, inner, tier, key) : await (await import("../../search-6LP755Q7.js")).handleSearch(env, ctx, inner, tier, key);
1053
1054
  payload = await res.json().catch(() => ({ error: { message: "tool transport failed", status: res.status } }));
1054
1055
  }
1055
1056
  telemetry(env, ctx, tier, `mcp:${name}`, null, !payload?.error, JSON.stringify(payload).length, await sha256Hex(`${name}:${JSON.stringify(args ?? {})}`), void 0, void 0, { durationMs: Date.now() - t0 });
package/package.json CHANGED
@@ -27,7 +27,7 @@
27
27
  "playwright": "^1.62.1",
28
28
  "yaml": "^2.9.1"
29
29
  },
30
- "version": "0.2.126",
30
+ "version": "0.2.130",
31
31
  "description": "The Konneal engine: the publisher-agnostic build pipeline and API plane for standards intelligence (retrieval, answer contract, verdicts, evaluation).",
32
32
  "license": "BSD-3-Clause",
33
33
  "type": "module",
@@ -0,0 +1,6 @@
1
+ -- API keys draw from their OWNER MEMBER's credit cap (the owner's
2
+ -- 2026-10-12 direction): a member-issued key is not a separate budget
3
+ -- tier — key traffic and the member's web traffic share ONE daily
4
+ -- allowance (sub:<owner> bucket). Keys without an owner (service
5
+ -- keys) keep the per-key cap.
6
+ ALTER TABLE api_keys ADD COLUMN owner_sub TEXT;
@@ -3,6 +3,7 @@ CREATE TABLE IF NOT EXISTS api_keys (
3
3
  name TEXT NOT NULL,
4
4
  key_hash TEXT NOT NULL UNIQUE,
5
5
  day_limit INTEGER NOT NULL DEFAULT 2000,
6
+ owner_sub TEXT,
6
7
  created_at TEXT NOT NULL,
7
8
  revoked INTEGER NOT NULL DEFAULT 0
8
9
  );
@@ -440,15 +440,18 @@ export async function handleCreateKey(env: Env, req: Request): Promise<Response>
440
440
  const body = await readJson(req);
441
441
  if (!body?.name || typeof body.name !== "string") return err(400, "invalid_input", "name is required");
442
442
  const dayLimit = Number.isFinite(Number(body.day_limit)) && Number(body.day_limit) > 0 ? Number(body.day_limit) : num(env as any, "KEY_DAY_ASK_DEFAULT", 2000);
443
+ // the owner member (2026-10-12): a key minted for a member draws from
444
+ // HER credit cap — one shared daily allowance across key and web
445
+ const memberSub = typeof body?.member_sub === "string" && body.member_sub.trim() ? body.member_sub.trim().slice(0, 64) : null;
443
446
  const raw = `${P().publisher.id}_${[...crypto.getRandomValues(new Uint8Array(24))].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
444
447
  const id = crypto.randomUUID();
445
448
  const keyHash = await sha256Hex(raw);
446
449
  await env.DB.prepare(
447
- "INSERT INTO api_keys (id, name, key_hash, day_limit, created_at, revoked) VALUES (?1,?2,?3,?4,?5,0)",
450
+ "INSERT INTO api_keys (id, name, key_hash, day_limit, owner_sub, created_at, revoked) VALUES (?1,?2,?3,?4,?5,?6,0)",
448
451
  )
449
- .bind(id, body.name, keyHash, dayLimit, new Date().toISOString())
452
+ .bind(id, body.name, keyHash, dayLimit, memberSub, new Date().toISOString())
450
453
  .run();
451
- return json({ id, name: body.name, day_limit: dayLimit, key: raw, note: "Store this key now — it is not retrievable again." });
454
+ return json({ id, name: body.name, day_limit: dayLimit, ...(memberSub ? { member_sub: memberSub } : {}), key: raw, note: "Store this key now — it is not retrievable again." + (memberSub ? " This key draws from its member's shared daily credit cap." : "") });
452
455
  }
453
456
 
454
457
  export async function handleListKeys(env: Env, req: Request): Promise<Response> {
@@ -456,7 +459,7 @@ export async function handleListKeys(env: Env, req: Request): Promise<Response>
456
459
  const auth = req.headers.get("authorization") ?? "";
457
460
  if (auth !== `Bearer ${env.ADMIN_TOKEN}`) return err(401, "unauthorized", "Invalid admin token");
458
461
  const rows = await env.DB.prepare(
459
- "SELECT id, name, day_limit, created_at, revoked FROM api_keys ORDER BY created_at DESC",
462
+ "SELECT id, name, day_limit, owner_sub, created_at, revoked FROM api_keys ORDER BY created_at DESC",
460
463
  ).all();
461
464
  return json({ keys: rows.results, ...corsHeaders(req) });
462
465
  }
@@ -42,7 +42,7 @@ import speculativeVerifyPrompt from "../prompts/speculative-verify.md";
42
42
  import type { Env } from "./env";
43
43
  export type { Env };
44
44
  import { json, err, corsHeaders, readJson, validateQuery, type ApiKey } from "./lib/http";
45
- import { chargeTokens, checkQuota, clientIp, estimateTokens, telemetry, tokenBudget, usageTotal } from "./quota";
45
+ import { chargeTokens, checkQuota, clientIp, creditBudget, creditEstimate, estimateTokens, telemetry, tokenBudget, usageTotal } from "./quota";
46
46
  import { graphExpand, editionNote } from "./graph";
47
47
  import { registerNote, searchRegister, certificateLinks, registerAggregateIntent, registerAggregate, aggregateNote, aggregateBlock, queryFamily } from "./certificates";
48
48
  import { TOOL_DECLARATION, parseToolCall, runTool, toolNote } from "./tools";
@@ -276,8 +276,8 @@ function messageChars(messages: any[]): number {
276
276
  /** Post-generation true-up: the model's own usage when the stream
277
277
  * reported it, the character estimate otherwise — charged, then the
278
278
  * budget's fresh state returned for the quota payload. */
279
- async function chargeAnswerTokens(env: Env, bucketId: string, stats: { usage?: unknown } | undefined, promptChars: number, answerChars: number): Promise<{ tokens_used: number; token_limit: number }> {
280
- const total = usageTotal(stats?.usage) ?? estimateTokens(promptChars + answerChars);
279
+ async function chargeAnswerTokens(env: Env, bucketId: string, stats: { usage?: unknown } | undefined, promptChars: number, answerChars: number, extraTokens = 0): Promise<{ tokens_used: number; token_limit: number }> {
280
+ const total = (usageTotal(stats?.usage) ?? estimateTokens(promptChars + answerChars)) + extraTokens;
281
281
  await chargeTokens(env, bucketId, total);
282
282
  const budget = await tokenBudget(env, bucketId);
283
283
  return { tokens_used: budget.used, token_limit: budget.limit };
@@ -409,18 +409,31 @@ async function handleAsk(
409
409
  const routeEffort = (route: "fast" | "deep") =>
410
410
  route === "fast" && !declaredEffort ? ("low" as const) : effort;
411
411
 
412
+ // the owner-member law (2026-10-12): a member-owned API key draws
413
+ // from HER credit cap — key traffic and web traffic share ONE daily
414
+ // bucket (sub:<owner>), so a key can never multiply an allowance.
415
+ // Ownerless service keys keep the per-key bucket.
416
+ const memberKey = tier === "key" ? key?.owner_sub ?? null : null;
412
417
  const limit =
413
- tier === "key" ? key!.day_limit : tier === "member" || member ? num(env as any, "MEMBER_DAY_ASK", 300) : num(env as any, "ANON_DAY_ASK", 20);
414
- const bucketId = tier === "key" ? `key:${key!.id}` : member ? `sub:${member.sub}` : clientIp(req);
418
+ memberKey ? num(env as any, "MEMBER_DAY_ASK", 300)
419
+ : tier === "key" ? key!.day_limit
420
+ : tier === "member" || member ? num(env as any, "MEMBER_DAY_ASK", 300)
421
+ : num(env as any, "ANON_DAY_ASK", 20);
422
+ const bucketId = memberKey ? `sub:${memberKey}` : tier === "key" ? `key:${key!.id}` : member ? `sub:${member.sub}` : clientIp(req);
415
423
  const quota = await checkQuota(env, "ask", bucketId, limit, effort === "low" ? 1 : 2);
424
+ const creditPreview = creditEstimate({ effort, toolTurns: body?.multi_turn !== false ? 3 : 0, image: false });
416
425
  if (!quota.ok) {
417
426
  return err(429, "quota_exceeded", `Daily question limit reached (${quota.limit}). Try again tomorrow.`);
418
427
  }
419
428
  // the real allowance is the day's TOKENS (the 2026-09-29 shift): the
420
429
  // request cap above is the anti-abuse backstop, this is the budget
421
430
  const tokBudget = await tokenBudget(env, bucketId);
431
+ // the credit preview (2026-10-11): what this ask will cost, shown in
432
+ // the refusal when the day's credits are spent — effort + armed tool
433
+ // turns + multimodality, one honest number
422
434
  if (tokBudget.used >= tokBudget.limit) {
423
- return err(429, "quota_exceeded", `Daily token budget reached (${tokBudget.limit} tokens). Try again tomorrow.`);
435
+ const cb = await creditBudget(env, bucketId);
436
+ return err(429, "quota_exceeded", `Daily credits reached (${cb.credits_limit} credits, ${cb.credits_used} used). Try again tomorrow.`);
424
437
  }
425
438
  (quota as any).tokens_used = tokBudget.used;
426
439
  (quota as any).token_limit = tokBudget.limit;
@@ -533,6 +546,10 @@ async function handleAsk(
533
546
  // / image, not the query text alone. The fresh parse is answercache's
534
547
  // single freshRequested, honored at every answer-cache read below.
535
548
  const fresh = freshRequested(body);
549
+ // multi-turn agent (2026-10-11): the tool loop is ON by default — the
550
+ // model may chain tools; a caller opts OUT for the cheap single-pass
551
+ // (and the credit estimate prices the difference)
552
+ const multiTurn = body?.multi_turn !== false;
536
553
  // the corpus-generation stamp (KV sys:corpus_gen) namespaces both
537
554
  // answer caches; corpus surgery bumps it (scripts/invalidate_answer_
538
555
  // cache.py) and old-generation entries miss (oimlsmart/rag#72)
@@ -800,7 +817,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
800
817
  context_applied: NO_CONTEXT,
801
818
  read: readAs(),
802
819
  ...(ablate ? { ablate } : {}),
803
- quota: { ...quota, ...tq },
820
+ quota: { ...quota, ...tq, credits: await creditBudget(env, bucketId), credit_estimate: creditPreview },
804
821
  });
805
822
  }
806
823
  }
@@ -843,8 +860,8 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
843
860
  } catch {
844
861
  // stream ended prematurely — deliver what we have
845
862
  }
846
- const tq = await chargeAnswerTokens(env, bucketId, stats, messageChars(messages), full.length);
847
- send({ type: "quota", quota: { ...quota, ...tq } });
863
+ const tq = await chargeAnswerTokens(env, bucketId, stats, messageChars(messages), full.length, probeTokensUsed);
864
+ send({ type: "quota", quota: { ...quota, ...tq, credits: await creditBudget(env, bucketId), credit_estimate: creditPreview } });
848
865
  send({ type: "done", model, query_hash: queryHash, context_applied: NO_CONTEXT, read: readAs() });
849
866
  telemetry(env, ctx, tier, "ask", model, true, full.length, queryHash, q.lang, undefined, telemetryMeta());
850
867
  controller.close();
@@ -917,7 +934,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
917
934
  );
918
935
  }
919
936
  const tq = await chargeAnswerTokens(env, bucketId, undefined, q.query.length, verdict.answer.length);
920
- return json({ answer: verdict.answer, citations, model, query_hash: queryHash, follow_ups: [], context_applied: draftCtxApplied, read: readAs(), ...(draftPayload ? { draft: draftPayload } : {}), ...(ablate ? { ablate } : {}), quota: { ...quota, ...tq } });
937
+ return json({ answer: verdict.answer, citations, model, query_hash: queryHash, follow_ups: [], context_applied: draftCtxApplied, read: readAs(), ...(draftPayload ? { draft: draftPayload } : {}), ...(ablate ? { ablate } : {}), quota: { ...quota, ...tq, credits: await creditBudget(env, bucketId), credit_estimate: creditPreview } });
921
938
  }
922
939
 
923
940
  // ── The api_call draft (TODO.ai-platform/09) — the operations
@@ -955,7 +972,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
955
972
  );
956
973
  }
957
974
  const tq = await chargeAnswerTokens(env, bucketId, undefined, q.query.length, verdict.answer.length);
958
- return json({ answer: verdict.answer, citations: [], model, query_hash: queryHash, follow_ups: [], context_applied: callCtxApplied, read: readAs(), ...(draftPayload ? { draft: draftPayload } : {}), ...(ablate ? { ablate } : {}), quota: { ...quota, ...tq } });
975
+ return json({ answer: verdict.answer, citations: [], model, query_hash: queryHash, follow_ups: [], context_applied: callCtxApplied, read: readAs(), ...(draftPayload ? { draft: draftPayload } : {}), ...(ablate ? { ablate } : {}), quota: { ...quota, ...tq, credits: await creditBudget(env, bucketId), credit_estimate: creditPreview } });
959
976
  }
960
977
 
961
978
  // (declared before the retrieval try: the account block, the refusal
@@ -1392,12 +1409,15 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
1392
1409
  // invocations record into pendingTools; the stream emits them as its
1393
1410
  // first events (the UI shows what the agent consulted).
1394
1411
  const pendingTools: { name: string; query: string; output: string }[] = [];
1412
+ // the agent's own consumption (2026-10-11): every tool probe is a real
1413
+ // model call — its usage joins the charge so credits price the true cost
1414
+ let probeTokensUsed = 0;
1395
1415
  // On an IMAGE turn the probe CARRIES THE IMAGE (2026-10-03, the owner's
1396
1416
  // architecture): the model that reads the plate is the model that issues
1397
1417
  // the tool call — one reader, one tool, no parallel extraction racing a
1398
1418
  // second vision pass that drifts hour to hour. The old probe dropped the
1399
1419
  // image-bearing message, which is why the private bridge had to exist.
1400
- if (/certificate|certified|certification/i.test(q.query) && env.DB) {
1420
+ if (multiTurn && /certificate|certified|certification/i.test(q.query) && env.DB) {
1401
1421
  try {
1402
1422
  const imageTurn = typeof withImage === "string";
1403
1423
  if (imageTurn) mark("Reading the photograph to identify the instrument");
@@ -1409,6 +1429,7 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
1409
1429
  reasoning_effort: imageTurn ? "high" : "low",
1410
1430
  });
1411
1431
  const probeText = typeof probe?.response === "string" ? probe.response : probe?.choices?.[0]?.message?.content ?? "";
1432
+ probeTokensUsed += usageTotal((probe as any)?.usage) ?? 0;
1412
1433
  const call = parseToolCall(probeText);
1413
1434
  if (call) {
1414
1435
  mark(`Searching the certificate register for “${String(call.args?.query ?? "")}”`);
@@ -1486,8 +1507,8 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
1486
1507
  } catch {
1487
1508
  // stream ended prematurely — deliver what we have
1488
1509
  }
1489
- const tq = await chargeAnswerTokens(env, bucketId, stats, messageChars(messages), full.length);
1490
- send({ type: "quota", quota: { ...quota, ...tq } });
1510
+ const tq = await chargeAnswerTokens(env, bucketId, stats, messageChars(messages), full.length, probeTokensUsed);
1511
+ send({ type: "quota", quota: { ...quota, ...tq, credits: await creditBudget(env, bucketId), credit_estimate: creditPreview } });
1491
1512
  // the blind-answer guard ON THE STREAM PATH (the JSON path has
1492
1513
  // had it since 0.2.80): an image rode the turn, the register
1493
1514
  // note carries rows, and the streamed text is the no-information
@@ -1801,8 +1822,8 @@ let convEntities: Array<{ entity: string; kind: string }> = [];
1801
1822
  text: h.text.slice(0, 1200),
1802
1823
  ...((h.metadata as any).table_selection ? { sel: (h.metadata as any).table_selection } : {}),
1803
1824
  }));
1804
- const tq = await chargeAnswerTokens(env, bucketId, undefined, messageChars(messages), answer.length);
1805
- return json({ ...out, context: contextOut, read: readAs(), quota: { ...quota, ...tq } }, 200, { ...corsHeaders(req), "server-timing": serverTiming() });
1825
+ const tq = await chargeAnswerTokens(env, bucketId, undefined, messageChars(messages), answer.length, probeTokensUsed);
1826
+ return json({ ...out, context: contextOut, read: readAs(), quota: { ...quota, ...tq, credits: await creditBudget(env, bucketId), credit_estimate: creditPreview } }, 200, { ...corsHeaders(req), "server-timing": serverTiming() });
1806
1827
  }
1807
1828
 
1808
1829
  function sseResponse(events: unknown[], cors: Record<string, string>): Response {
@@ -46,6 +46,11 @@ export interface ApiKey {
46
46
  id: string;
47
47
  name: string;
48
48
  day_limit: number;
49
+ /** the member whose credit cap this key draws from (the owner's
50
+ * 2026-10-12 law): a member-owned key shares the member's daily
51
+ * allowance — key and web traffic are ONE budget. Null = a service
52
+ * key with its own per-key cap. */
53
+ owner_sub?: string | null;
49
54
  }
50
55
 
51
56
  export async function authenticate(env: Pick<Env, "DB">, req: Request): Promise<ApiKey | null> {
@@ -54,7 +59,7 @@ export async function authenticate(env: Pick<Env, "DB">, req: Request): Promise<
54
59
  if (!m) return null;
55
60
  const keyHash = await sha256Hex(m[1].trim());
56
61
  const row = await env.DB.prepare(
57
- "SELECT id, name, day_limit FROM api_keys WHERE key_hash = ?1 AND revoked = 0",
62
+ "SELECT id, name, day_limit, owner_sub FROM api_keys WHERE key_hash = ?1 AND revoked = 0",
58
63
  )
59
64
  .bind(keyHash)
60
65
  .first<ApiKey>();
@@ -95,3 +95,29 @@ export function usageTotal(usage: unknown): number | null {
95
95
  const total = p + c;
96
96
  return Number.isFinite(total) && total > 0 ? total : null;
97
97
  }
98
+
99
+ // ── credits (2026-10-11, the owner's direction): the user-facing quota
100
+ // unit. 1 credit = CREDIT_TOKEN_UNIT tokens of actual consumption
101
+ // (default 1000 — a simple ask is ~1 credit, an elevated-effort answer
102
+ // a few, a multi-turn agent ask with tool probes what it truly cost).
103
+ // The KV token counters stay the source of truth; credits are their
104
+ // honest denomination. Every quota payload carries them so the UI shows
105
+ // one number users can reason about.
106
+ export const CREDIT_TOKEN_UNIT = 1000;
107
+
108
+ export function creditLimit(env: Env, bucketId: string): number {
109
+ return Math.floor(tokenLimit(env, bucketId) / CREDIT_TOKEN_UNIT);
110
+ }
111
+
112
+ export async function creditBudget(env: Env, bucketId: string): Promise<{ credits_used: number; credits_limit: number }> {
113
+ const b = await tokenBudget(env, bucketId);
114
+ return { credits_used: Math.ceil(b.used / CREDIT_TOKEN_UNIT), credits_limit: creditLimit(env, bucketId) };
115
+ }
116
+
117
+ /** The credit-price preview for an ask: effort, tool turns armed, and
118
+ * multimodality each add real consumption — the estimate shown BEFORE
119
+ * generation so users see what a mode costs. Honest, not exact. */
120
+ export function creditEstimate(opts: { effort: string; toolTurns: number; image: boolean }): number {
121
+ const effortCredits = opts.effort === "low" ? 1 : opts.effort === "medium" ? 2 : 3;
122
+ return effortCredits + Math.max(0, opts.toolTurns) + (opts.image ? 1 : 0);
123
+ }