@bytesbrains/cruise-worker 0.0.0-stage → 0.12.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.
Files changed (137) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +37 -2
  3. package/index.d.ts +14 -0
  4. package/index.mjs +41 -0
  5. package/migrations/0001_virtual_keys.sql +102 -0
  6. package/migrations/0002_seed_projects.sql +16 -0
  7. package/migrations/0003_budgets.sql +43 -0
  8. package/migrations/0004_tenant_isolation_and_integer_money.sql +49 -0
  9. package/migrations/0005_model_prices.sql +42 -0
  10. package/migrations/0006_budget_reservation.sql +38 -0
  11. package/migrations/0007_routing_table.sql +93 -0
  12. package/migrations/0008_measurement_provenance.sql +24 -0
  13. package/migrations/0009_conformance.sql +37 -0
  14. package/migrations/0010_conformance_key.sql +20 -0
  15. package/migrations/0011_correct_workers_ai_price.sql +30 -0
  16. package/migrations/0012_rate_limits.sql +28 -0
  17. package/migrations/0013_deepseek_and_mistral.sql +42 -0
  18. package/migrations/0014_cost_ledger.sql +104 -0
  19. package/migrations/0015_memberships.sql +33 -0
  20. package/migrations/0016_latency.sql +23 -0
  21. package/migrations/0017_ledger_provider_index.sql +8 -0
  22. package/migrations/0018_fallback_chains.sql +34 -0
  23. package/migrations/0019_rate_buckets.sql +27 -0
  24. package/migrations/0020_mistral_price_and_pin.sql +62 -0
  25. package/migrations/0021_mistral_price_date_is_utc.sql +35 -0
  26. package/migrations/0022_drop_unenforced_key_budget.sql +32 -0
  27. package/migrations/0023_model_limits.sql +48 -0
  28. package/migrations/0024_conditional_prices.sql +84 -0
  29. package/migrations/0025_catalogue_from_keyed_hosts.sql +172 -0
  30. package/migrations/0026_lanes.sql +83 -0
  31. package/migrations/0027_lane_alternatives.sql +36 -0
  32. package/migrations/0028_lanes_tenant_scope.sql +66 -0
  33. package/migrations/0029_lane_jobs.sql +146 -0
  34. package/migrations/0030_lane_requires.sql +38 -0
  35. package/migrations/0031_tenant_billable.sql +15 -0
  36. package/migrations/0032_cache_hit_billing.sql +35 -0
  37. package/migrations/0033_sell_markup.sql +37 -0
  38. package/migrations/0034_tiers_and_lane_quotas.sql +38 -0
  39. package/migrations/0035_wallet_scope.sql +68 -0
  40. package/migrations/0036_credit_grants.sql +53 -0
  41. package/migrations/0037_reconciliation.sql +45 -0
  42. package/migrations/0038_balance_service_keys.sql +35 -0
  43. package/migrations/0039_deepseek_peak_is_weekdays.sql +77 -0
  44. package/migrations/0040_glm_5_3_on_workers_ai.sql +63 -0
  45. package/migrations/0041_mistral_sweep_2026_08_29.sql +89 -0
  46. package/migrations/0042_provision_service_keys.sql +50 -0
  47. package/migrations/0043_tier_project_quota.sql +32 -0
  48. package/migrations/0044_audit_tenant_scope.sql +49 -0
  49. package/migrations/0045_audit_backfill_derivable.sql +42 -0
  50. package/migrations/0046_measurement_note.sql +24 -0
  51. package/migrations/0047_ledger_max_tokens.sql +21 -0
  52. package/migrations/0048_price_observations.sql +140 -0
  53. package/migrations/0049_openai_and_gemini.sql +239 -0
  54. package/migrations/0050_meta_direct_upstream.sql +127 -0
  55. package/migrations/0051_upstream_credential_and_operator.sql +74 -0
  56. package/migrations/0052_muse_spark_1_3.sql +71 -0
  57. package/migrations/0053_anthropic_groq_and_xai.sql +172 -0
  58. package/migrations/0054_tier_service_keys.sql +60 -0
  59. package/migrations/0055_output_defaults.sql +79 -0
  60. package/migrations/0056_model_modality.sql +37 -0
  61. package/migrations/0057_openai_image_models.sql +177 -0
  62. package/migrations/0058_price_components.sql +230 -0
  63. package/migrations/0059_speech.sql +151 -0
  64. package/migrations/0060_embeddings.sql +131 -0
  65. package/migrations/0061_data_retention_backfill.sql +121 -0
  66. package/migrations/0062_gemini_embedding_disabled.sql +125 -0
  67. package/migrations/0063_google_provider_path_prefix.sql +59 -0
  68. package/migrations/0064_economics_service_keys.sql +51 -0
  69. package/migrations/0065_lane_selection.sql +66 -0
  70. package/migrations/0066_tenant_default_selection.sql +42 -0
  71. package/migrations/0067_graduated_selection.sql +103 -0
  72. package/migrations/0068_cache_scope.sql +73 -0
  73. package/migrations/0069_lane_cache_settings.sql +50 -0
  74. package/migrations/0070_lane_member_quality_rank.sql +98 -0
  75. package/migrations/0071_key_cache_pool.sql +60 -0
  76. package/migrations/0072_cache_digest.sql +51 -0
  77. package/migrations/0073_lane_max_chain.sql +51 -0
  78. package/migrations/0074_hunter_builder_lanes.sql +149 -0
  79. package/migrations/0075_hunter_builder_frontline.sql +36 -0
  80. package/migrations/0076_deepseek_flash.sql +86 -0
  81. package/migrations/0077_gpt_6_astra.sql +35 -0
  82. package/migrations/0078_astra_deep_reasoning.sql +20 -0
  83. package/migrations/0079_gemini_flash_lite_extraction.sql +15 -0
  84. package/migrations/0080_priced_by.sql +24 -0
  85. package/migrations/0081_grok_imagine.sql +81 -0
  86. package/migrations/0082_markup_three_percent.sql +32 -0
  87. package/migrations/0083_markup_per_tenant_and_tier.sql +57 -0
  88. package/migrations/0084_coding_lanes_frontier.sql +93 -0
  89. package/migrations/0085_minute_period_and_demo_charge.sql +77 -0
  90. package/migrations/0086_project_sentry.sql +41 -0
  91. package/migrations/0087_lane_router.sql +71 -0
  92. package/migrations/0088_openai_frontier_coding_lanes.sql +54 -0
  93. package/migrations/0089_agentic_coding_drops_codestral.sql +44 -0
  94. package/migrations/0090_board_followups_2026_10.sql +196 -0
  95. package/migrations/0091_opus_5_5_coding_lanes.sql +43 -0
  96. package/migrations/0092_deepseek_peak_holidays.sql +83 -0
  97. package/migrations/0093_tool_choice_forced.sql +26 -0
  98. package/migrations/0094_board_followups_2026_10_08.sql +115 -0
  99. package/migrations/0095_request_compression.sql +43 -0
  100. package/migrations/0096_lane_compress.sql +36 -0
  101. package/migrations/0097_tenant_default_compress.sql +34 -0
  102. package/migrations/0098_ledger_session.sql +47 -0
  103. package/migrations/0099_wallet_low_water_backfill.sql +47 -0
  104. package/migrations/0100_rag_grounded_answers_lane.sql +92 -0
  105. package/migrations/0101_perplexity_agent_api.sql +125 -0
  106. package/migrations/0102_web_search_job_and_flag.sql +137 -0
  107. package/migrations/0103_openai_web_search_price.sql +44 -0
  108. package/migrations/0104_web_search_lane.sql +82 -0
  109. package/migrations/0105_web_search_lane_billed.sql +22 -0
  110. package/migrations/0106_reservations_journal.sql +64 -0
  111. package/migrations/0107_session_cap.sql +65 -0
  112. package/migrations/0108_ledger_attempts.sql +41 -0
  113. package/migrations/0109_gemini_retention.sql +39 -0
  114. package/migrations/0110_gemini_4_argon.sql +110 -0
  115. package/migrations/0111_gemini_4_argon_lanes.sql +80 -0
  116. package/migrations/0112_ledger_affinity.sql +42 -0
  117. package/migrations/0113_contributor_lane_namespace.sql +125 -0
  118. package/migrations/0114_meta_contributor_models.sql +55 -0
  119. package/migrations/0115_oss_chat_assistant_lane.sql +41 -0
  120. package/migrations/0116_promo_credits.sql +63 -0
  121. package/migrations/0117_oss_mirror_lanes.sql +85 -0
  122. package/migrations/0118_project_management_job.sql +112 -0
  123. package/migrations/0119_oss_project_management_lane.sql +29 -0
  124. package/migrations/0120_moonshot_k3_1_pro.sql +107 -0
  125. package/migrations/0121_jobs.sql +100 -0
  126. package/migrations/0122_batch_prices.sql +188 -0
  127. package/migrations/0123_batch_files.sql +50 -0
  128. package/migrations/0124_batch_run.sql +21 -0
  129. package/migrations/0125_anthropic_path_prefix.sql +22 -0
  130. package/package.json +37 -4
  131. package/worker/284906b51fc0a3173ea7d301234907f30e834f54-cruise-mark-64.png +0 -0
  132. package/worker/2c39de526314484775fdfc503fac50c437d21e4e-cruise-logo-240.png +0 -0
  133. package/worker/63fb63e2d8ae6d8b68228768ef6c91ab8f0c0100-cruise-logo-480.png +0 -0
  134. package/worker/VERSION +1 -0
  135. package/worker/b23bfbc792e8710d3b74284a0a1b8ab044c18897-logo-dark.png +0 -0
  136. package/worker/index.js +31451 -0
  137. package/wrangler.template.jsonc +48 -0
@@ -0,0 +1,125 @@
1
+ -- The Google embedding row cannot serve, and the first run that could ask
2
+ -- found out (#253, and 0060's own open question answered).
3
+ --
4
+ -- 0060 catalogued three embedding rows and said in as many words which part of
5
+ -- them was unmeasured: *"`/compat` is chat-only, so an embedding request takes
6
+ -- the gateway's provider path, which is documented for chat and implied for
7
+ -- the rest ... the first conformance run against a held credential is the
8
+ -- measurement."* That run happened on 2026-09-05 — dispatched, narrowed to the
9
+ -- six image and embedding rows by #250's `--modality`, run
10
+ -- `9031a8c6-5e62-4291-859d-f0bebedc91e4` — and the answer is:
11
+ --
12
+ -- openai/text-embedding-3-small embedding_vector pass 1536 dimensions
13
+ -- openai/text-embedding-3-large embedding_vector pass 3072 dimensions
14
+ -- google-ai-studio/gemini-embedding-001 embedding_vector FAIL HTTP 404
15
+ -- google-ai-studio/gemini-embedding-001 embedding_usage FAIL HTTP 404
16
+ --
17
+ -- Two of three, and the third is the one 0060 flagged as resting on an implied
18
+ -- behaviour rather than a documented one.
19
+ --
20
+ -- ## What the evidence says, and where it stops
21
+ --
22
+ -- **What is measured:** the OpenAI rows and the Google row take the *same*
23
+ -- route through the *same* Worker code — `providerPath` in `route-run.ts`
24
+ -- builds one URL shape for both — and one pair passes while the other 404s.
25
+ -- So the embedding route, the gateway, the credential and the request body are
26
+ -- all exonerated: whatever is wrong is specific to the `google-ai-studio` leg.
27
+ --
28
+ -- **What is inferred, and is not yet proven.** `providerPath` sends
29
+ --
30
+ -- {gateway}/v1/{account}/{gw}/{upstream}/v1/embeddings
31
+ --
32
+ -- so the tail `/v1/embeddings` is appended to whatever base the gateway's
33
+ -- provider path resolves to. For OpenAI that lands on `api.openai.com/v1/
34
+ -- embeddings`, which is the real endpoint. Google's OpenAI-compatible
35
+ -- embeddings endpoint is **not** at `/v1/embeddings` — the compatibility layer
36
+ -- 0060 cited (https://ai.google.dev/gemini-api/docs/openai) is published under
37
+ -- `/v1beta/openai/`, and `/v1/embeddings` on `generativelanguage.googleapis.com`
38
+ -- is not a path that exists. A 404 is exactly what that mismatch produces, and
39
+ -- exactly what we got.
40
+ --
41
+ -- That is a hypothesis with the right shape, not a finding. **What would settle
42
+ -- it** is reading the response body rather than its status: the run recorded
43
+ -- `detail` as empty, so nobody has yet seen whether the 404 came from Google
44
+ -- naming an unknown path or from the gateway naming an unknown model. Worth
45
+ -- fixing in the runner before the next such introduction — a status code alone
46
+ -- could not distinguish these two, and both were live possibilities here.
47
+ --
48
+ -- ## Why disabled rather than fixed here
49
+ --
50
+ -- If the inference is right the fix is a URL, and 0060 already named the shape
51
+ -- it would take: *"the fallback, as before, is a `base_url` on the upstream row
52
+ -- rather than code."* But `base_url` is per **upstream**, not per route, and
53
+ -- `isDirect` reads it for every modality — so setting one on `google-ai-studio`
54
+ -- would also take Google's five **chat** rows off `/compat` and send them
55
+ -- direct. Those rows serve today. A migration that fixes an embedding row by
56
+ -- moving working chat traffic onto an untested path is not a fix, it is a
57
+ -- larger change wearing one's clothes, and it needs its own measurement.
58
+ --
59
+ -- So: the row is disabled, the reason is written down, and the repair is #253's
60
+ -- to make deliberately.
61
+ --
62
+ -- ## Disabled, never deleted
63
+ --
64
+ -- The usual reason, and one more. `price_observations` carries the row's
65
+ -- 2026-09-05 first observation and `conformance_results` carries the two
66
+ -- failures; both reference this id and must outlive it. And the failure is the
67
+ -- most useful thing we know about this model — deleting the row would delete
68
+ -- the record that we tried.
69
+ --
70
+ -- ## This is also the record of a change already made by hand
71
+ --
72
+ -- Production was updated directly on 2026-09-05, minutes after the run, to stop
73
+ -- an unservable model being routable. This migration is that same change made
74
+ -- durable: it is why the production database already reads `enabled = 0`, and
75
+ -- it is what brings staging — where the row is enabled and has never been
76
+ -- measured, so harmless but drifting — back into agreement.
77
+
78
+ -- ## Two flags, because one of them is a false statement
79
+ --
80
+ -- `enabled = 0` is the decision: this row does not serve, so stop offering it.
81
+ --
82
+ -- Clearing the measurement is not a second decision, it is a correction. The
83
+ -- row asserted `measured_at = 2026-09-05` for a model that answered 404 to
84
+ -- every check that day — and `measured_at` is not a log of when somebody
85
+ -- looked, it is the **input to the gate**. `verificationOf` reads it and
86
+ -- `listRoutableModels` serves anything it calls verified, so the row as it
87
+ -- stood was one `enabled = 1` away from routing again, with nothing in it
88
+ -- saying why that would be wrong. A person re-enabling it — a migration, a
89
+ -- console, the same hand-run UPDATE that disabled it — would get a model the
90
+ -- gate believes in.
91
+ --
92
+ -- The first draft of this migration left the date, arguing it was evidence of
93
+ -- how it got there. That was wrong twice. The evidence is in
94
+ -- `conformance_results`, which keeps both failures and is untouched here, and
95
+ -- in this file. And #253 argues in as many words that a model whose every
96
+ -- check failed should read *unmeasured*, so that the existing
97
+ -- `model_unmeasured` refusal names the real situation. Writing the opposite
98
+ -- into the first migration after saying so was an inconsistency, and review of
99
+ -- PR #255 caught it.
100
+ --
101
+ -- What this does **not** do is answer #253. That is the general rule — should
102
+ -- a run ever stamp a model whose every check failed — and it belongs in the
103
+ -- runner, where the next such model will not have somebody watching. This
104
+ -- corrects one row.
105
+
106
+ UPDATE models
107
+ SET enabled = 0,
108
+ measured_at = NULL,
109
+ measured_by = NULL,
110
+ measurement_source = NULL
111
+ WHERE id = 'google-ai-studio/gemini-embedding-001';
112
+
113
+ -- ## What does not change
114
+ --
115
+ -- **The price row.** $0.15 per 1M input tokens is still what Google charges; a
116
+ -- model we cannot reach is not a model that became free, and
117
+ -- `price_observations` is append-only besides.
118
+ --
119
+ -- **`conformance_results`.** Both 404s stay. They are the reason this migration
120
+ -- exists and the only record of what was actually observed.
121
+ --
122
+ -- **The capability flags** (`usage_reported = 0` and the rest). They are
123
+ -- derived from the failures and mean nothing now that the row is unmeasured —
124
+ -- the gate stops at `measured_at` before it reads them — but rewriting them
125
+ -- would assert a second thing nobody checked.
@@ -0,0 +1,59 @@
1
+ -- The Google embedding row can serve after all; the path was ours (#253).
2
+ --
3
+ -- 0062 disabled `google-ai-studio/gemini-embedding-001` after both of its
4
+ -- checks answered HTTP 404, and inferred — carefully labelled as inference —
5
+ -- that the fault was the URL: Google's OpenAI-compatible endpoints live under
6
+ -- `/v1beta/openai/`, and the provider-path leg was sending something else.
7
+ -- The run's own record could not confirm it, because the 404 carried no body.
8
+ --
9
+ -- ## Measured, 2026-09-05
10
+ --
11
+ -- Three requests through the staging gateway, with the gateway token and a
12
+ -- Google key, sent by hand:
13
+ --
14
+ -- /google-ai-studio/embeddings 404, empty body,
15
+ -- content-type text/html,
16
+ -- server-timing gfet4t7
17
+ -- /google-ai-studio/v1beta/openai/embeddings 200, a 3072-value vector
18
+ -- /google-ai-studio/v1beta/models/…:embedContent 401 (a bearer header
19
+ -- Google's native API rejects;
20
+ -- irrelevant, and not pursued)
21
+ --
22
+ -- `gfet4t7` is Google's front end. So the gateway forwards whatever path it is
23
+ -- given after the provider segment, Google answered the first with an empty
24
+ -- 404 for a path it does not serve, and the second is the path. 0062's
25
+ -- inference was right about the shape and wrong about one detail: the leg
26
+ -- appends `/embeddings`, not `/v1/embeddings` — `providerPath` in
27
+ -- `route-run.ts` adds no version segment of its own — and for OpenAI that is
28
+ -- correct because the gateway's `openai` provider supplies `/v1` itself.
29
+ -- Google's provider supplies nothing, and Google needs `/v1beta/openai`.
30
+ --
31
+ -- ## A column, not a base_url, and not code
32
+ --
33
+ -- 0060 named `base_url` as the fallback, and PR #255 explained why it is not
34
+ -- one: `base_url` is per upstream and makes every route to it direct,
35
+ -- including the five chat rows that serve today over `/compat`. What differs
36
+ -- here is narrower — the gateway leg is fine, only the path under the
37
+ -- provider segment is Google's rather than OpenAI's — and it is a fact about
38
+ -- the upstream, like `kind` and `base_url` beside it. So it is a column the
39
+ -- Worker reads, not a branch on the upstream id in `providerPath`, which would
40
+ -- be the second place after `speech-elevenlabs.ts` that a host's shape lives
41
+ -- in code, and this one need not.
42
+ --
43
+ -- Chat is unaffected: `chat.ts` builds `/compat/chat/completions` and never
44
+ -- reads this. NULL for every other upstream, which is the value that means
45
+ -- "append the OpenAI-shaped path as before" and is what every measured row
46
+ -- was measured against.
47
+ ALTER TABLE upstreams ADD COLUMN provider_path_prefix TEXT;
48
+
49
+ UPDATE upstreams SET provider_path_prefix = '/v1beta/openai' WHERE id = 'google-ai-studio';
50
+
51
+ -- ## Re-enabled, still unmeasured
52
+ --
53
+ -- The reason 0062 gave for disabling is gone, so the row is offered to the
54
+ -- conformance run again. `measured_at` stays NULL: this migration measured a
55
+ -- URL by hand, not the model through Cruise, and the gate refuses an unmeasured
56
+ -- row whichever order this and the Worker deploy land in. If the run reaches
57
+ -- it before the deploy, it 404s again and `foldFlags` clears it with a note
58
+ -- (#253); after, it measures. Neither order routes traffic to a 404.
59
+ UPDATE models SET enabled = 1 WHERE id = 'google-ai-studio/gemini-embedding-001';
@@ -0,0 +1,51 @@
1
+ -- A service key that can read the platform's books and do nothing else (#249).
2
+ --
3
+ -- The gap: every route carrying a cost or revenue figure is scoped to the
4
+ -- caller's own tenant and opened by a Firebase token — a person in a
5
+ -- browser. `/admin/billing/statement` answers what one tenant was charged;
6
+ -- nothing answers what the platform's traffic cost at list, what it charged
7
+ -- for that traffic, and what it kept, across every tenant. The dashboard's
8
+ -- operator screen was asking, and there is also no route that lists tenants,
9
+ -- so it could not fan out either: it would have to already know every id.
10
+ --
11
+ -- `purpose` gains a fifth value on 0038's terms: one capability per key,
12
+ -- issued for one value and never two. `economics` reads one month's totals,
13
+ -- by upstream and by charge line, and has no other route. It mints nothing,
14
+ -- creates nothing, changes no tier, and reads no wallet.
15
+ --
16
+ -- Why not the `balance` key, which also only reads. 0038 and the wallet
17
+ -- route both go to some length to make "no list of every tenant's balance"
18
+ -- mean something — one tenant per call, and a tenant that is not there
19
+ -- answers like one with no wallet, so a leaked sweep key cannot harvest the
20
+ -- platform's books one id at a time. Opening a platform-wide read to that
21
+ -- key would hand it exactly the aggregate that discipline withholds. And the
22
+ -- figure this route carries that no other does is the **cost basis** — what
23
+ -- the platform pays upstream — which the issue names as commercial posture,
24
+ -- the same reason `/admin/upstreams` refuses a customer who is `owner` of
25
+ -- their own workspace. The holder of an `economics` key is an operator
26
+ -- screen; the holder of a `balance` key is a sweep. Different consumers,
27
+ -- different keys.
28
+ --
29
+ -- SQLite cannot widen a CHECK in place, so the table is rebuilt, and 0038's,
30
+ -- 0042's and 0054's note on why that is safe applies unchanged: the name and
31
+ -- every column stay identical, so code deployed before this migration and
32
+ -- code deployed after read exactly the same shape. Widening is the safe
33
+ -- direction on its own — no existing row can fail the new CHECK, and a Worker
34
+ -- deployed before this simply never matches an `economics` row, because
35
+ -- `authenticate` names the purposes it will accept in the lookup itself.
36
+ CREATE TABLE service_keys_new (
37
+ id TEXT PRIMARY KEY,
38
+ prefix TEXT NOT NULL UNIQUE,
39
+ key_hash TEXT NOT NULL,
40
+ purpose TEXT NOT NULL CHECK (purpose IN ('credits', 'balance', 'provision', 'tier', 'economics')),
41
+ created_by TEXT NOT NULL,
42
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
43
+ revoked_at TEXT,
44
+ last_used_at TEXT
45
+ );
46
+
47
+ INSERT INTO service_keys_new (id, prefix, key_hash, purpose, created_by, created_at, revoked_at, last_used_at)
48
+ SELECT id, prefix, key_hash, purpose, created_by, created_at, revoked_at, last_used_at FROM service_keys;
49
+
50
+ DROP TABLE service_keys;
51
+ ALTER TABLE service_keys_new RENAME TO service_keys;
@@ -0,0 +1,66 @@
1
+ -- How a lane chooses among the members that passed its gates (#202).
2
+ --
3
+ -- Since 0026 there has been exactly one rule — cheapest first — and it was
4
+ -- not a setting, it was `allocate()`. That rule is right for a bill and wrong
5
+ -- for learning: #19 built cost per *completed* request from the ledger, the
6
+ -- number lanes should rank on, and found `qwen3.8-27b` completing 28% of its
7
+ -- requests while winning on price. That number exists only for members that
8
+ -- got traffic, and the cheapest member gets essentially all of it, so every
9
+ -- other member's completion rate is measured on whatever trickle reached it
10
+ -- through a rate-limit cooldown. Price-first allocation biases the ledger
11
+ -- against ever learning anything else.
12
+ --
13
+ -- So a lane carries a **selection policy**, and a request may override it
14
+ -- (`x-cruise-selection`). Three values:
15
+ --
16
+ -- cheap what every lane has always done, byte for byte. The default.
17
+ -- random uniform over the eligible members.
18
+ -- weighted by each member's `weight`, renormalised over the eligible
19
+ -- members per request, so a member a gate rejected has its share
20
+ -- redistributed rather than leaving a chance of choosing nothing.
21
+ --
22
+ -- `random` and `weighted` are exploration policies, not toys: a lane running
23
+ -- 90/10 in favour of its cheapest member still learns something about the
24
+ -- other members every day, and 0027 already records what each of them would
25
+ -- have cost, so the cost half of the comparison is free. Only traffic produces
26
+ -- the behaviour half.
27
+ --
28
+ -- **Whatever the policy, it chooses among the survivors of the hard gates and
29
+ -- never around them.** Scope, measurement, the lane's own requirements, the
30
+ -- request's capabilities, the limits, billing: a `random` that could return a
31
+ -- member failing `no-training-on-data` would turn a privacy gate into a coin
32
+ -- flip. The policy decides which passing member goes *first*; the rest follow
33
+ -- cheapest-first, as the fallback chain always has.
34
+ --
35
+ -- One member the draw leaves out that the gates let through: a member the
36
+ -- provider rate-limited in the last few minutes, while anything else is in
37
+ -- the pool. The allocator ranks it last because the next request very likely
38
+ -- gets a 429 too, and a draw that picked it would spend an upstream deadline
39
+ -- learning nothing. So `random` is uniform over the survivors that are not
40
+ -- cooling down, and a 90/10 lane drifts from 90/10 while one is. A member
41
+ -- merely failing stays in — exploration is what finds out whether it still
42
+ -- is (`src/lanes-select.ts`).
43
+ --
44
+ -- The weight is REAL, and that is not the money rule slipping. A weight is not
45
+ -- a quantity of anything — it is a ratio the request normalises before use,
46
+ -- and `[0.2, 0.1, 0.5, 0.25, 0.4]` (which the issue says sums to 1.55 and in
47
+ -- fact sums to 1.45 — either way not one, which is why these are weights and
48
+ -- not probabilities) has to read back as written. An integer
49
+ -- scale would round what the operator typed. Positive, because a member that
50
+ -- may never be chosen is a member that should not be in the lane; the write
51
+ -- refuses zero and negative before the CHECK does.
52
+ --
53
+ -- The ledger says which policy ran, per request (NULL for a pinned model). A
54
+ -- random choice is not reproducible, so a month under `weighted` would be
55
+ -- unexplainable without it, and 0027's counterfactual could not be read: the
56
+ -- served member is `model`, the chosen one is `lane_alternatives` at position
57
+ -- 1, and they differ only when the chain fell over. Cheap now, archaeology
58
+ -- later — 0048's argument about prices.
59
+
60
+ ALTER TABLE lanes ADD COLUMN selection TEXT NOT NULL DEFAULT 'cheap'
61
+ CHECK (selection IN ('cheap', 'random', 'weighted'));
62
+
63
+ ALTER TABLE lane_members ADD COLUMN weight REAL NOT NULL DEFAULT 1 CHECK (weight > 0);
64
+
65
+ ALTER TABLE ledger ADD COLUMN selection TEXT
66
+ CHECK (selection IS NULL OR selection IN ('cheap', 'random', 'weighted'));
@@ -0,0 +1,42 @@
1
+ -- A tenant's own default selection policy, and the first column on `tenants`
2
+ -- that belongs to the tenant rather than to us (#288).
3
+ --
4
+ -- 0065 gave a lane a selection policy and defaulted it to `cheap`. That
5
+ -- default is a module constant — `DEFAULT_SELECTION` in `src/lanes-select.ts`
6
+ -- — so a tenant whose posture is exploration has to remember `selection` on
7
+ -- every create, and forgetting is silent: the lane serves cheapest-first and
8
+ -- looks exactly like a lane that meant to. The question that produced this was
9
+ -- a dashboard one, and the answer to most of it was "already works": a tenant
10
+ -- can hold `acme/review-cheap` and `acme/review-weighted` side by side today,
11
+ -- because only the lane *id* is unique and `job` is not, and each is listed by
12
+ -- `/v1/models` as its own model id. The default was the piece with nowhere to
13
+ -- live.
14
+ --
15
+ -- **Nullable, and NULL means "no preference".** Not `NOT NULL DEFAULT 'cheap'`
16
+ -- like the lane column: there the value is a fact about that lane, here the
17
+ -- absence is the fact — a tenant that has never expressed a posture is a
18
+ -- different thing from one that chose `cheap`, and only the first should start
19
+ -- following a changed platform default. It also needs no backfill, and the
20
+ -- read resolves NULL the way `tierOf` resolves an unknown tier: to the
21
+ -- platform value, failing closed on the behaviour every lane had before #202.
22
+ --
23
+ -- **Consulted at create, never at allocation.** `createLane` reads this once
24
+ -- and writes a concrete value into `lanes.selection`; nothing on the request
25
+ -- path looks here. Resolving it per request would mean changing this column
26
+ -- silently repoints every lane that never named a policy — lanes made years
27
+ -- apart, for unrelated reasons — and the ledger's own `selection` column, the
28
+ -- one that exists so a month under `weighted` can be explained at all, would
29
+ -- stop agreeing with the lane it describes. The audit row for `lane.create`
30
+ -- already records the policy, so the receipt says what a lane became and when.
31
+ --
32
+ -- **`weights` deliberately gets no equivalent.** A weight belongs to a member
33
+ -- list — one per member, in the members' order — so a tenant-wide default
34
+ -- weight is a number with nothing to attach to. `selection` is the half of the
35
+ -- pair that is a posture rather than a shape.
36
+ --
37
+ -- The CHECK repeats 0065's three values rather than referencing them, for the
38
+ -- same reason 0065 did: SQLite has no enum, and a policy this column allowed
39
+ -- that `parseSelection` rejects would be a row nothing can serve.
40
+
41
+ ALTER TABLE tenants ADD COLUMN default_selection TEXT
42
+ CHECK (default_selection IS NULL OR default_selection IN ('cheap', 'random', 'weighted'));
@@ -0,0 +1,103 @@
1
+ -- A fourth selection policy: `graduated`, which works its own weights out (#290).
2
+ --
3
+ -- `weighted` (0065) draws by weight and the weights are typed in by hand. That
4
+ -- is workable for `[0.9, 0.1]` on two members and unusable for eight — the
5
+ -- operator invents eight numbers, keeps them in the members' order, and redoes
6
+ -- them whenever the membership changes. So a lane that wants exploration in
7
+ -- practice gets `random`, which spends as much on the dearest member as on the
8
+ -- cheapest, or gets nothing. `graduated` derives each member's share from its
9
+ -- rank, so the best-ranked serves most requests, each next one fewer, and none
10
+ -- serves none.
11
+ --
12
+ -- The reason to want it is 0065's own argument with #19's evidence: cost per
13
+ -- *completed* request is what a lane should rank on, and it exists only for
14
+ -- members that got traffic. Under `cheap` the cheapest member takes nearly
15
+ -- every request, so every other member's completion rate is measured on a
16
+ -- trickle. `random` fixes the learning and breaks the bill; this fixes both.
17
+ --
18
+ -- **Harmonic, 1 / (rank + 1), not a halving.** The obvious reading of "80-20"
19
+ -- is geometric decay, and it starves the tail at the sizes the tiers allow
20
+ -- (0034: Core 8 members, Pro 20). Halving gives the twentieth member 0.0001%
21
+ -- of traffic — against the 3,997 ledger rows production held when this was
22
+ -- written, zero requests — while harmonic gives it 1.4%, about 56. A member
23
+ -- seen sixteen times has no usable completion rate either, so geometric would
24
+ -- fail this policy's own purpose while appearing to run.
25
+ --
26
+ -- **Rank means the allocator's position, not a fresh price sort.**
27
+ -- `lanes-allocate.ts` orders candidates on rate-limited, then failing, then
28
+ -- price, so a cheap member that is failing already sinks below a dearer
29
+ -- healthy one — the `qwen3.8-27b` case in that file's comment, 48 failures in
30
+ -- 74 requests and winning on price. Weighting by a price sort would hand that
31
+ -- member the largest share of the lane.
32
+ --
33
+ -- ── Why this adds columns instead of changing three ──────────────────────────
34
+ --
35
+ -- Three CHECK constraints enumerate the policies — `lanes.selection` and
36
+ -- `ledger.selection` from 0065, `tenants.default_selection` from 0066 — and
37
+ -- SQLite cannot alter a CHECK. Neither of the two obvious answers is safe here,
38
+ -- and the second one is the mistake this migration was rewritten to undo.
39
+ --
40
+ -- **Not a table rebuild** (0028's answer), because 0050 already recorded why:
41
+ -- D1 enforces foreign keys where SQLite does not, so `DROP TABLE tenants`
42
+ -- fails outright while `memberships` and `projects` reference it, and
43
+ -- `PRAGMA defer_foreign_keys` does not survive because each statement lands in
44
+ -- its own transaction. Rebuilding the children reaches `virtual_keys` through
45
+ -- `projects`.
46
+ --
47
+ -- **And not a column swap** — add, copy, drop, rename — which is what the first
48
+ -- version of this file did. It touches no table and needs no foreign key
49
+ -- deferred, and it is still wrong: between the `DROP` and the `RENAME` the
50
+ -- column `selection` does not exist, and because each statement is its own
51
+ -- transaction a request served in that gap faults on a column that is missing.
52
+ -- The file argued the window was sub-millisecond and therefore acceptable.
53
+ -- **That is the argument 0035 already lost.** The phase-5 deploy
54
+ -- recorded it serving 500s "for under a minute" and a review bot's traffic
55
+ -- landing inside — a window is not small enough until it is absent. Raised in
56
+ -- review of PR #291, and the reviewer was right.
57
+ --
58
+ -- So: expand and contract. This migration only **adds**, and every column it
59
+ -- adds is new, so no name the running Worker reads ever stops existing.
60
+ --
61
+ -- 0067 (here) add `*_policy` beside each old column, with the four-value
62
+ -- CHECK, and backfill it.
63
+ -- deploy the Worker reads and writes the new columns only. The old
64
+ -- ones keep the value they held at migration time, so a Worker
65
+ -- version still finishing a request reads valid data rather
66
+ -- than an error.
67
+ -- 0068 (later) drop the three old columns, once no Worker reads them. That
68
+ -- migration removes a column nothing queries, which is the one
69
+ -- shape of schema change that has no window at all.
70
+ --
71
+ -- **The new names are permanent**, because reclaiming the old ones would mean a
72
+ -- `RENAME` and a `RENAME` is the very gap this avoids. `selection_policy` is
73
+ -- the better name anyway — the prose in `lanes-select.ts` has called it a
74
+ -- selection policy since 0065, and bare `selection` never said what it held.
75
+ --
76
+ -- One inconsistency this accepts, deliberately: a lane created by the new
77
+ -- Worker writes `selection_policy` and leaves the old `selection` at its
78
+ -- DEFAULT, so a Worker version still serving mid-rollout would read `cheap`
79
+ -- for a lane that is really `graduated`. That is a stale read for the seconds a
80
+ -- version switch takes, not a fault, and it is the strictly better failure than
81
+ -- the missing column it replaces. It is also why the old columns keep their
82
+ -- values rather than being nulled.
83
+ --
84
+ -- **Deploy order is migrate first, then the Worker.** Adding a column is
85
+ -- invisible to code that does not name it, so the old Worker is unaffected;
86
+ -- new code naming a column that is not there is what would fail.
87
+
88
+ -- lanes.selection_policy — what a lane is configured to do.
89
+ ALTER TABLE lanes ADD COLUMN selection_policy TEXT NOT NULL DEFAULT 'cheap'
90
+ CHECK (selection_policy IN ('cheap', 'random', 'weighted', 'graduated'));
91
+ UPDATE lanes SET selection_policy = selection;
92
+
93
+ -- ledger.selection_policy — which policy actually ran, per request. NULL for a
94
+ -- pinned model, which is why this one's CHECK admits NULL.
95
+ ALTER TABLE ledger ADD COLUMN selection_policy TEXT
96
+ CHECK (selection_policy IS NULL OR selection_policy IN ('cheap', 'random', 'weighted', 'graduated'));
97
+ UPDATE ledger SET selection_policy = selection;
98
+
99
+ -- tenants.default_selection_policy — what a lane created without a policy is
100
+ -- stamped with (0066). NULL means the tenant has never chosen.
101
+ ALTER TABLE tenants ADD COLUMN default_selection_policy TEXT
102
+ CHECK (default_selection_policy IS NULL OR default_selection_policy IN ('cheap', 'random', 'weighted', 'graduated'));
103
+ UPDATE tenants SET default_selection_policy = default_selection;
@@ -0,0 +1,73 @@
1
+ -- What the cache did, kept beside what it charged (#276, epic #272).
2
+ --
3
+ -- #98 gave a hit a price and `cache_status` gave it a name, which was enough
4
+ -- to bill and not enough to explain. auto-mode.md §8 is blunt about why that
5
+ -- matters: "a router that saves 40% and cannot say why an answer changed
6
+ -- loses to a config file", and a cache is the same router problem seen from
7
+ -- the other side — a caller who receives a replay is entitled to know it was
8
+ -- one, from which model, and how old. The header half of that ships in the
9
+ -- same PR; these two columns are the half that survives the request.
10
+ --
11
+ -- ## `cache_scope`
12
+ --
13
+ -- Which scope actually served the answer: `model` today, `lane` once #278
14
+ -- lands, NULL on every row that was not a hit and on every row written before
15
+ -- this migration. Not derivable from anything already here — `cache_status`
16
+ -- says an answer came from a cache and cannot say *how wide* the cache was
17
+ -- allowed to be, and that is precisely the question the lane cache exists to
18
+ -- make answerable. `GET /admin/ledger?by=cache_scope` groups on it and
19
+ -- `npm run report` segments hits by it beside traffic class, never blended,
20
+ -- for the reason #6 gives about every other rate in that report: scheduled
21
+ -- pipelines re-run near-identical prompts by construction, so a blended
22
+ -- figure flatters us and generalises to nobody.
23
+ --
24
+ -- ## `cache_origin_model_id`
25
+ --
26
+ -- The model whose stored answer was replayed.
27
+ --
28
+ -- **It equals `model` today, and that is not a reason to leave it out.** The
29
+ -- two columns answer different questions and are only accidentally the same
30
+ -- one. `model` is the row the bill is priced from — `accounting.ts` reads it
31
+ -- to resolve a rate — and a hit must price from whichever model produced the
32
+ -- usage, so the two coincide at model scope by construction. `cache_origin_
33
+ -- model_id` is a fact about the *entry*: what the cache had. When #21 asks
34
+ -- whether the tier rule held — "a hit may be served only from an equal-or-
35
+ -- better model, never a worse one" — it is asking about the entry, and it is
36
+ -- asking after the fact, of rows written by a version of the code that no
37
+ -- longer exists. A question of that shape needs its own column, not an
38
+ -- inference from a column chosen for another purpose.
39
+ --
40
+ -- The violation it makes findable: a hit row whose origin ranks below the
41
+ -- model that would have served the request live. That is the worst failure
42
+ -- auto-mode.md §6 names, because it is invisible to the caller — a worse
43
+ -- answer reported as an upgrade — and the counter #281 puts on it reads this
44
+ -- column.
45
+ --
46
+ -- **A foreign key, where `model` is not one.** `ledger.model` has been bare
47
+ -- TEXT since 0014 and stays that way: it records what a request was served by
48
+ -- at the time, and the catalogue is allowed to move underneath a historical
49
+ -- row. The origin is different in kind — it is only ever a model this
50
+ -- platform stored an answer from, and the analysis above is a join against
51
+ -- `models`, so a value that joins to nothing is a row the analysis silently
52
+ -- drops rather than one it flags. "Disable, never delete" (CLAUDE.md) is what
53
+ -- makes the constraint affordable: a model that stops being routable keeps
54
+ -- its row, so the reference outlives the routing.
55
+ --
56
+ -- Both columns are NULL for every existing row, which is correct rather than
57
+ -- backfilled: nothing has ever been served from the Worker's cache before
58
+ -- #275, and the gateway hits recorded under `cache_status = 'HIT'` have no
59
+ -- scope and no known origin — they were the gateway's, keyed on a body that
60
+ -- already carried the model. Guessing `model` into them would manufacture a
61
+ -- fact.
62
+
63
+ ALTER TABLE ledger ADD COLUMN cache_scope TEXT
64
+ CHECK (cache_scope IS NULL OR cache_scope IN ('model', 'lane'));
65
+
66
+ ALTER TABLE ledger ADD COLUMN cache_origin_model_id TEXT REFERENCES models(id);
67
+
68
+ -- The tier-rule query (#21) and the scope report both start from the hits, and
69
+ -- the hits are a small minority of the table. Partial, so the index costs
70
+ -- nothing on the rows that will never satisfy it.
71
+ CREATE INDEX IF NOT EXISTS idx_ledger_cache_scope
72
+ ON ledger (cache_scope, at)
73
+ WHERE cache_scope IS NOT NULL;
@@ -0,0 +1,50 @@
1
+ -- A lane holds its own freshness (#277, epic #272). Spec: auto-mode.md §6.
2
+ --
3
+ -- "A code fix ages slowly; anything touching current events ages
4
+ -- immediately." There is no global freshness answer, which is why the lane is
5
+ -- the place to keep one: it is the object a customer already configures per
6
+ -- job, by someone who knows what that job's answers are worth an hour later.
7
+ --
8
+ -- ## Zero is off, and every lane starts there
9
+ --
10
+ -- `cache_ttl_seconds` defaults to 0 and 0 means the lane caches nothing. Not
11
+ -- a conservative default to be tuned later — the honest one. auto-mode.md §8
12
+ -- puts it plainly: the TTL is the customer's setting, not ours, and every
13
+ -- lever that could widen the cache to manufacture margin is either absent or
14
+ -- not ours to pull. A default of, say, sixty seconds would be us deciding on
15
+ -- their behalf that their answers keep, and it would apply to eight global
16
+ -- lanes and every tenant lane already written, none of whose owners were
17
+ -- asked.
18
+ --
19
+ -- ## The ceiling is thirty days, and it is the measurement window's
20
+ --
21
+ -- `MEASUREMENT_MAX_AGE_DAYS = 30` refuses a model measured longer ago than
22
+ -- that, because a model changes under a fixed id and an aged measurement is
23
+ -- unverified rather than last-known-good (`src/routing.ts`). A cached answer
24
+ -- is the same claim in the same direction: it asserts that this model, today,
25
+ -- would answer this way. Letting an entry outlive the measurement that says
26
+ -- the model is still the model it was would serve an answer from a version of
27
+ -- the model nothing has checked since. So the two horizons are the same
28
+ -- horizon, and `CACHE_MAX_TTL_SECONDS` in `src/cache-policy.ts` holds the
29
+ -- same figure for a pinned request, which has no lane to bound it.
30
+ --
31
+ -- ## `cache_scope` defaults to `model`
32
+ --
33
+ -- The narrow one. A lane-scoped hit crosses models within the lane and is
34
+ -- where most of the saving is (auto-mode.md §6), and it is also where the
35
+ -- tier rule lives — serve from a member ranking below the one that would have
36
+ -- answered and you report an upgrade while delivering a downgrade, invisibly.
37
+ -- That is a decision a lane's owner makes deliberately, and #278 is what
38
+ -- makes it servable at all: until then the platform's `CACHE_MODE` caps the
39
+ -- scope and `planCache` keys on the model regardless, so a lane set to `lane`
40
+ -- today is served, honestly, as `model`.
41
+ --
42
+ -- Both columns are NOT NULL with a default, so every existing lane reads back
43
+ -- a setting rather than a null anybody would have to interpret — and the
44
+ -- setting it reads back is "off", which is what it has been doing all along.
45
+
46
+ ALTER TABLE lanes ADD COLUMN cache_ttl_seconds INTEGER NOT NULL DEFAULT 0
47
+ CHECK (cache_ttl_seconds BETWEEN 0 AND 2592000);
48
+
49
+ ALTER TABLE lanes ADD COLUMN cache_scope TEXT NOT NULL DEFAULT 'model'
50
+ CHECK (cache_scope IN ('model', 'lane'));