pilotswarm-sdk 0.5.41 → 0.5.43

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 (149) hide show
  1. package/README.md +2 -1
  2. package/api/index.d.ts +22 -1
  3. package/api/index.js +5 -0
  4. package/api/src/api-client.js +20 -1
  5. package/api/src/http-api-transport.js +102 -1
  6. package/api/src/protocol.js +39 -4
  7. package/api/src/surface-contract.js +44 -0
  8. package/dist/agent-manager-tools.d.ts +2 -0
  9. package/dist/agent-manager-tools.d.ts.map +1 -1
  10. package/dist/agent-manager-tools.js +24 -15
  11. package/dist/agent-manager-tools.js.map +1 -1
  12. package/dist/agent-package-service.d.ts +9 -0
  13. package/dist/agent-package-service.d.ts.map +1 -1
  14. package/dist/agent-package-service.js +46 -2
  15. package/dist/agent-package-service.js.map +1 -1
  16. package/dist/client.d.ts +2 -0
  17. package/dist/client.d.ts.map +1 -1
  18. package/dist/client.js +82 -19
  19. package/dist/client.js.map +1 -1
  20. package/dist/cms-migrations.d.ts.map +1 -1
  21. package/dist/cms-migrations.js +3538 -0
  22. package/dist/cms-migrations.js.map +1 -1
  23. package/dist/cms.d.ts +25 -0
  24. package/dist/cms.d.ts.map +1 -1
  25. package/dist/cms.js +43 -4
  26. package/dist/cms.js.map +1 -1
  27. package/dist/index.d.ts +7 -2
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +5 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/managed-session.d.ts +9 -1
  32. package/dist/managed-session.d.ts.map +1 -1
  33. package/dist/managed-session.js +40 -8
  34. package/dist/managed-session.js.map +1 -1
  35. package/dist/management-client.d.ts +410 -1
  36. package/dist/management-client.d.ts.map +1 -1
  37. package/dist/management-client.js +1254 -78
  38. package/dist/management-client.js.map +1 -1
  39. package/dist/model-providers.d.ts +13 -3
  40. package/dist/model-providers.d.ts.map +1 -1
  41. package/dist/model-providers.js +50 -13
  42. package/dist/model-providers.js.map +1 -1
  43. package/dist/orchestration/index.d.ts +2 -2
  44. package/dist/orchestration/index.js +1 -1
  45. package/dist/orchestration/lifecycle.d.ts.map +1 -1
  46. package/dist/orchestration/lifecycle.js +6 -3
  47. package/dist/orchestration/lifecycle.js.map +1 -1
  48. package/dist/orchestration/queue.d.ts.map +1 -1
  49. package/dist/orchestration/queue.js +1 -0
  50. package/dist/orchestration/queue.js.map +1 -1
  51. package/dist/orchestration/runtime.d.ts +1 -1
  52. package/dist/orchestration/state.d.ts +20 -0
  53. package/dist/orchestration/state.d.ts.map +1 -1
  54. package/dist/orchestration/state.js +1 -0
  55. package/dist/orchestration/state.js.map +1 -1
  56. package/dist/orchestration/turn.d.ts.map +1 -1
  57. package/dist/orchestration/turn.js +134 -3
  58. package/dist/orchestration/turn.js.map +1 -1
  59. package/dist/orchestration-registry.d.ts.map +1 -1
  60. package/dist/orchestration-registry.js +4 -2
  61. package/dist/orchestration-registry.js.map +1 -1
  62. package/dist/orchestration-version.d.ts +1 -1
  63. package/dist/orchestration-version.js +1 -1
  64. package/dist/orchestration.d.ts +2 -2
  65. package/dist/orchestration.js +1 -1
  66. package/dist/orchestration_1_0_46.d.ts +1 -1
  67. package/dist/orchestration_1_0_47.d.ts +1 -1
  68. package/dist/orchestration_1_0_48.d.ts +1 -1
  69. package/dist/orchestration_1_0_49.d.ts +1 -1
  70. package/dist/orchestration_1_0_50.d.ts +1 -1
  71. package/dist/orchestration_1_0_68/agents.d.ts +61 -0
  72. package/dist/orchestration_1_0_68/agents.d.ts.map +1 -0
  73. package/dist/orchestration_1_0_68/agents.js +840 -0
  74. package/dist/orchestration_1_0_68/agents.js.map +1 -0
  75. package/dist/orchestration_1_0_68/index.d.ts +30 -0
  76. package/dist/orchestration_1_0_68/index.d.ts.map +1 -0
  77. package/dist/orchestration_1_0_68/index.js +13 -0
  78. package/dist/orchestration_1_0_68/index.js.map +1 -0
  79. package/dist/orchestration_1_0_68/lifecycle.d.ts +49 -0
  80. package/dist/orchestration_1_0_68/lifecycle.d.ts.map +1 -0
  81. package/dist/orchestration_1_0_68/lifecycle.js +944 -0
  82. package/dist/orchestration_1_0_68/lifecycle.js.map +1 -0
  83. package/dist/orchestration_1_0_68/queue.d.ts +7 -0
  84. package/dist/orchestration_1_0_68/queue.d.ts.map +1 -0
  85. package/dist/orchestration_1_0_68/queue.js +804 -0
  86. package/dist/orchestration_1_0_68/queue.js.map +1 -0
  87. package/dist/orchestration_1_0_68/runtime.d.ts +29 -0
  88. package/dist/orchestration_1_0_68/runtime.d.ts.map +1 -0
  89. package/dist/orchestration_1_0_68/runtime.js +228 -0
  90. package/dist/orchestration_1_0_68/runtime.js.map +1 -0
  91. package/dist/orchestration_1_0_68/state.d.ts +176 -0
  92. package/dist/orchestration_1_0_68/state.d.ts.map +1 -0
  93. package/dist/orchestration_1_0_68/state.js +158 -0
  94. package/dist/orchestration_1_0_68/state.js.map +1 -0
  95. package/dist/orchestration_1_0_68/turn.d.ts +34 -0
  96. package/dist/orchestration_1_0_68/turn.d.ts.map +1 -0
  97. package/dist/orchestration_1_0_68/turn.js +1211 -0
  98. package/dist/orchestration_1_0_68/turn.js.map +1 -0
  99. package/dist/orchestration_1_0_68/utils.d.ts +39 -0
  100. package/dist/orchestration_1_0_68/utils.d.ts.map +1 -0
  101. package/dist/orchestration_1_0_68/utils.js +316 -0
  102. package/dist/orchestration_1_0_68/utils.js.map +1 -0
  103. package/dist/provider-budgets.d.ts +127 -0
  104. package/dist/provider-budgets.d.ts.map +1 -0
  105. package/dist/provider-budgets.js +182 -0
  106. package/dist/provider-budgets.js.map +1 -0
  107. package/dist/provider-catalog.d.ts +125 -0
  108. package/dist/provider-catalog.d.ts.map +1 -0
  109. package/dist/provider-catalog.js +299 -0
  110. package/dist/provider-catalog.js.map +1 -0
  111. package/dist/provider-store.d.ts +383 -0
  112. package/dist/provider-store.d.ts.map +1 -0
  113. package/dist/provider-store.js +514 -0
  114. package/dist/provider-store.js.map +1 -0
  115. package/dist/provider-tools.d.ts +47 -0
  116. package/dist/provider-tools.d.ts.map +1 -0
  117. package/dist/provider-tools.js +585 -0
  118. package/dist/provider-tools.js.map +1 -0
  119. package/dist/session-manager.d.ts +23 -2
  120. package/dist/session-manager.d.ts.map +1 -1
  121. package/dist/session-manager.js +144 -6
  122. package/dist/session-manager.js.map +1 -1
  123. package/dist/session-proxy.d.ts +4 -3
  124. package/dist/session-proxy.d.ts.map +1 -1
  125. package/dist/session-proxy.js +202 -11
  126. package/dist/session-proxy.js.map +1 -1
  127. package/dist/system-agents.d.ts +12 -0
  128. package/dist/system-agents.d.ts.map +1 -1
  129. package/dist/system-agents.js +54 -12
  130. package/dist/system-agents.js.map +1 -1
  131. package/dist/types.d.ts +19 -2
  132. package/dist/types.d.ts.map +1 -1
  133. package/dist/types.js.map +1 -1
  134. package/dist/web/generated-op-methods.d.ts +208 -2
  135. package/dist/web/generated-op-methods.d.ts.map +1 -1
  136. package/dist/web/generated-op-methods.js +50 -0
  137. package/dist/web/generated-op-methods.js.map +1 -1
  138. package/dist/web/web-management-client.d.ts +112 -2
  139. package/dist/web/web-management-client.d.ts.map +1 -1
  140. package/dist/web/web-management-client.js +163 -0
  141. package/dist/web/web-management-client.js.map +1 -1
  142. package/dist/worker.d.ts +9 -0
  143. package/dist/worker.d.ts.map +1 -1
  144. package/dist/worker.js +124 -19
  145. package/dist/worker.js.map +1 -1
  146. package/package.json +3 -3
  147. package/plugins/mgmt/agents/token-manager.agent.md +183 -0
  148. package/plugins/system/agents/default.agent.md +21 -7
  149. package/plugins/system/skills/canvas-apps/SKILL.md +158 -0
@@ -252,6 +252,76 @@ export function CMS_MIGRATIONS(schema) {
252
252
  name: "canvas_share_links",
253
253
  sql: migration_0048_canvas_share_links(schema),
254
254
  },
255
+ {
256
+ version: "0049",
257
+ name: "provider_budgets",
258
+ sql: migration_0049_provider_budgets(schema),
259
+ },
260
+ {
261
+ version: "0050",
262
+ name: "provider_budget_procs",
263
+ sql: migration_0050_provider_budget_procs(schema),
264
+ },
265
+ {
266
+ version: "0051",
267
+ name: "provider_budget_runtime",
268
+ sql: migration_0051_provider_budget_runtime(schema),
269
+ },
270
+ {
271
+ version: "0052",
272
+ name: "provider_pause_liveness",
273
+ sql: migration_0052_provider_pause_liveness(schema),
274
+ },
275
+ {
276
+ version: "0053",
277
+ name: "provider_meters",
278
+ sql: migration_0053_provider_meters(schema),
279
+ },
280
+ {
281
+ version: "0054",
282
+ name: "provider_grid_owner",
283
+ sql: migration_0054_provider_grid_owner(schema),
284
+ },
285
+ {
286
+ version: "0055",
287
+ name: "provider_grid_owner_label",
288
+ sql: migration_0055_provider_grid_owner_label(schema),
289
+ },
290
+ {
291
+ version: "0056",
292
+ name: "provider_admission_defaults",
293
+ sql: migration_0056_provider_admission_defaults(schema),
294
+ },
295
+ {
296
+ version: "0057",
297
+ name: "provider_system_routing",
298
+ sql: migration_0057_provider_system_routing(schema),
299
+ },
300
+ {
301
+ version: "0058",
302
+ name: "legacy_github_provider_migration",
303
+ sql: migration_0058_legacy_github_provider_migration(schema),
304
+ },
305
+ {
306
+ version: "0059",
307
+ name: "provider_authoritative_routing",
308
+ sql: migration_0059_provider_authoritative_routing(schema),
309
+ },
310
+ {
311
+ version: "0060",
312
+ name: "provider_grid_metered_models",
313
+ sql: migration_0060_provider_grid_metered_models(schema),
314
+ },
315
+ {
316
+ version: "0061",
317
+ name: "provider_correctness_fixes",
318
+ sql: migration_0061_provider_correctness_fixes(schema),
319
+ },
320
+ {
321
+ version: "0062",
322
+ name: "provider_ledger_base",
323
+ sql: migration_0062_provider_ledger_base(schema),
324
+ },
255
325
  ];
256
326
  }
257
327
  // ─── Migration 0044: ms-space keyset for session paging ──────────
@@ -9209,4 +9279,3472 @@ CREATE UNIQUE INDEX IF NOT EXISTS canvas_share_links_token_hash
9209
9279
  ON ${s}.canvas_share_links (token_hash);
9210
9280
  `;
9211
9281
  }
9282
+ /**
9283
+ * 0049 — provider budgets. See docs/proposals/providers-and-budgets.md.
9284
+ *
9285
+ * A PROVIDER is a credential with a budget policy, and it is the only object:
9286
+ * shared (admin-made, anyone may spend, carries a per-person allowance) or
9287
+ * personal (user-made, owner-only). A session runs `provider:model` and is
9288
+ * charged to that provider. There are no pools, no payers, no grants and no
9289
+ * fallbacks — resolution scope is the whole access story: for user U the
9290
+ * namespace is every shared provider plus U's own, and another user's
9291
+ * personal provider is indistinguishable from a name that was never created.
9292
+ *
9293
+ * Three properties this schema is built around:
9294
+ *
9295
+ * 1. EXACTLY-ONCE ACCOUNTING. provider_usage_ledger PK (session_id,
9296
+ * turn_index) is the claim: counters move only when the ledger row is
9297
+ * first inserted, so an activity retry cannot double-charge.
9298
+ *
9299
+ * 2. HISTORY OUTLIVES THE PROVIDER. The ledger stores provider_name as a
9300
+ * plain string with NO foreign key. Deleting a provider (a hard DELETE —
9301
+ * a provider is there or not there, there is no retired state) cascades
9302
+ * its rules and counters and leaves every past row intact; re-creating
9303
+ * the name later reports under the one name.
9304
+ *
9305
+ * 3. WINDOWS ARE PLAIN UTC CALENDAR WINDOWS. Day = midnight UTC, week =
9306
+ * Monday 00:00 UTC (date_trunc('week') is Monday-based), month = the 1st.
9307
+ * There are no anchors, which is what keeps this pinned to
9308
+ * quota-windows.ts by arithmetic rather than by hope. date_trunc is
9309
+ * always applied to a UTC-projected PLAIN timestamp: date_trunc on a
9310
+ * timestamptz truncates in the SESSION timezone, which would move every
9311
+ * boundary on a connection whose TimeZone is not UTC.
9312
+ */
9313
+ function migration_0049_provider_budgets(schema) {
9314
+ const s = `"${schema}"`;
9315
+ return `
9316
+ -- ── tables ───────────────────────────────────────────────────────────
9317
+
9318
+ CREATE TABLE IF NOT EXISTS ${s}.provider_instances (
9319
+ name TEXT PRIMARY KEY,
9320
+ type_id TEXT NOT NULL,
9321
+ class TEXT NOT NULL CHECK (class IN ('shared','personal')),
9322
+ owner_user_id BIGINT REFERENCES ${s}.users(user_id) ON DELETE CASCADE,
9323
+ secret_ref JSONB NOT NULL DEFAULT '{}'::jsonb,
9324
+ base_url TEXT,
9325
+ allowance_pct SMALLINT NOT NULL DEFAULT 100 CHECK (allowance_pct BETWEEN 1 AND 100),
9326
+ hold_until_utc TIMESTAMPTZ,
9327
+ hold_indefinite BOOLEAN NOT NULL DEFAULT FALSE,
9328
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
9329
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
9330
+ -- A shared provider has no owner; a personal one always has one.
9331
+ CONSTRAINT provider_instances_owner_matches_class CHECK (
9332
+ (class = 'shared' AND owner_user_id IS NULL) OR
9333
+ (class = 'personal' AND owner_user_id IS NOT NULL)),
9334
+ -- An allowance divides a shared budget between people. A personal
9335
+ -- provider has exactly one person, so there is nothing to divide.
9336
+ CONSTRAINT provider_instances_allowance_shared_only CHECK (
9337
+ class = 'shared' OR allowance_pct = 100),
9338
+ -- No colon: a model reference is 'provider:model' and splits on the
9339
+ -- first one, so a colon in a name would make references ambiguous.
9340
+ CONSTRAINT provider_instances_name_shape CHECK (
9341
+ name ~ '^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$')
9342
+ );
9343
+
9344
+ CREATE INDEX IF NOT EXISTS provider_instances_owner
9345
+ ON ${s}.provider_instances (owner_user_id) WHERE owner_user_id IS NOT NULL;
9346
+
9347
+ CREATE TABLE IF NOT EXISTS ${s}.provider_budget_rules (
9348
+ rule_id TEXT PRIMARY KEY,
9349
+ provider_name TEXT NOT NULL REFERENCES ${s}.provider_instances(name) ON DELETE CASCADE,
9350
+ period TEXT NOT NULL CHECK (period IN ('day','week','month')),
9351
+ model_qualified TEXT,
9352
+ limit_tokens BIGINT NOT NULL CHECK (limit_tokens > 0),
9353
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
9354
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
9355
+ );
9356
+
9357
+ -- One limit per (period, scope). An overall limit and a per-model limit are
9358
+ -- different scopes and coexist; saving the same combination replaces it.
9359
+ CREATE UNIQUE INDEX IF NOT EXISTS provider_budget_rules_scope
9360
+ ON ${s}.provider_budget_rules (provider_name, period, COALESCE(model_qualified, '*'));
9361
+
9362
+ CREATE TABLE IF NOT EXISTS ${s}.provider_quota_counters (
9363
+ rule_id TEXT NOT NULL REFERENCES ${s}.provider_budget_rules(rule_id) ON DELETE CASCADE,
9364
+ window_key_utc TEXT NOT NULL,
9365
+ used_tokens BIGINT NOT NULL DEFAULT 0,
9366
+ window_start_utc TIMESTAMPTZ NOT NULL,
9367
+ resets_at_utc TIMESTAMPTZ NOT NULL,
9368
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
9369
+ PRIMARY KEY (rule_id, window_key_utc)
9370
+ );
9371
+
9372
+ -- The allowance's per-person mirror. Maintained for EVERY rule, not only
9373
+ -- those under a reduced allowance, so changing an allowance never needs a
9374
+ -- ledger scan to discover what each person had already spent.
9375
+ CREATE TABLE IF NOT EXISTS ${s}.provider_quota_counters_user (
9376
+ rule_id TEXT NOT NULL REFERENCES ${s}.provider_budget_rules(rule_id) ON DELETE CASCADE,
9377
+ user_id BIGINT NOT NULL,
9378
+ window_key_utc TEXT NOT NULL,
9379
+ used_tokens BIGINT NOT NULL DEFAULT 0,
9380
+ window_start_utc TIMESTAMPTZ NOT NULL,
9381
+ resets_at_utc TIMESTAMPTZ NOT NULL,
9382
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
9383
+ PRIMARY KEY (rule_id, user_id, window_key_utc)
9384
+ );
9385
+
9386
+ CREATE TABLE IF NOT EXISTS ${s}.provider_usage_ledger (
9387
+ session_id TEXT NOT NULL,
9388
+ turn_index INTEGER NOT NULL,
9389
+ provider_name TEXT,
9390
+ model_qualified TEXT,
9391
+ owner_user_id BIGINT,
9392
+ charge_class TEXT NOT NULL DEFAULT 'user'
9393
+ CHECK (charge_class IN ('user','system','unattributed')),
9394
+ tokens_input BIGINT NOT NULL DEFAULT 0,
9395
+ tokens_output BIGINT NOT NULL DEFAULT 0,
9396
+ tokens_cache_read BIGINT NOT NULL DEFAULT 0,
9397
+ tokens_cache_write BIGINT NOT NULL DEFAULT 0,
9398
+ tokens_total BIGINT NOT NULL DEFAULT 0,
9399
+ agent_id TEXT,
9400
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
9401
+ PRIMARY KEY (session_id, turn_index)
9402
+ );
9403
+
9404
+ CREATE INDEX IF NOT EXISTS provider_usage_ledger_created
9405
+ ON ${s}.provider_usage_ledger (created_at DESC);
9406
+ CREATE INDEX IF NOT EXISTS provider_usage_ledger_provider
9407
+ ON ${s}.provider_usage_ledger (provider_name, created_at DESC);
9408
+ CREATE INDEX IF NOT EXISTS provider_usage_ledger_owner
9409
+ ON ${s}.provider_usage_ledger (owner_user_id, created_at DESC);
9410
+
9411
+ CREATE TABLE IF NOT EXISTS ${s}.provider_cluster_settings (
9412
+ singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton),
9413
+ default_provider TEXT,
9414
+ default_model TEXT,
9415
+ default_reasoning TEXT,
9416
+ default_context TEXT,
9417
+ bootstrapped_at TIMESTAMPTZ,
9418
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
9419
+ );
9420
+ INSERT INTO ${s}.provider_cluster_settings (singleton) VALUES (TRUE)
9421
+ ON CONFLICT (singleton) DO NOTHING;
9422
+
9423
+ -- A person's default is a COLUMN, not a profile_settings key: the portal
9424
+ -- replaces profile_settings wholesale on every preference save, which would
9425
+ -- erase anything stored beside it.
9426
+ ALTER TABLE ${s}.users ADD COLUMN IF NOT EXISTS default_provider TEXT;
9427
+ ALTER TABLE ${s}.users ADD COLUMN IF NOT EXISTS default_model TEXT;
9428
+ ALTER TABLE ${s}.users ADD COLUMN IF NOT EXISTS default_reasoning TEXT;
9429
+ ALTER TABLE ${s}.users ADD COLUMN IF NOT EXISTS default_context TEXT;
9430
+
9431
+ ALTER TABLE ${s}.session_turn_metrics ADD COLUMN IF NOT EXISTS provider_name TEXT;
9432
+ ALTER TABLE ${s}.session_turn_metrics ADD COLUMN IF NOT EXISTS owner_user_id BIGINT;
9433
+ ALTER TABLE ${s}.session_turn_metrics ADD COLUMN IF NOT EXISTS charge_class TEXT;
9434
+
9435
+ -- The structured pause record. The paused-sessions reader reads THIS; it
9436
+ -- never parses wait_reason prose, which is written for a human.
9437
+ ALTER TABLE ${s}.sessions ADD COLUMN IF NOT EXISTS pause_state JSONB;
9438
+
9439
+ -- ── window bounds (parity-pinned to quota-windows.ts) ────────────────
9440
+
9441
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_window_bounds(
9442
+ p_kind TEXT, p_now TIMESTAMPTZ
9443
+ ) RETURNS TABLE(window_start TIMESTAMPTZ, resets_at TIMESTAMPTZ, window_key TEXT) AS $$
9444
+ DECLARE
9445
+ v_now TIMESTAMP := p_now AT TIME ZONE 'UTC';
9446
+ v_step INTERVAL := CASE p_kind WHEN 'day' THEN interval '1 day'
9447
+ WHEN 'week' THEN interval '7 days'
9448
+ ELSE interval '1 month' END;
9449
+ v_start TIMESTAMP;
9450
+ BEGIN
9451
+ IF p_kind NOT IN ('day','week','month') THEN
9452
+ RAISE EXCEPTION 'PROVIDER_INVALID: unknown period %', p_kind;
9453
+ END IF;
9454
+ v_start := date_trunc(p_kind, v_now);
9455
+ window_start := v_start AT TIME ZONE 'UTC';
9456
+ resets_at := (v_start + v_step) AT TIME ZONE 'UTC';
9457
+ window_key := to_char(v_start, 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"');
9458
+ RETURN NEXT;
9459
+ END;
9460
+ $$ LANGUAGE plpgsql IMMUTABLE;
9461
+
9462
+ -- ── helpers ──────────────────────────────────────────────────────────
9463
+
9464
+ -- Principal → user_id, WITHOUT creating the row. cms_register_user is the
9465
+ -- creating path; a read must not mint users as a side effect of looking.
9466
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_user_id(
9467
+ p_provider TEXT, p_subject TEXT
9468
+ ) RETURNS BIGINT AS $$
9469
+ SELECT u.user_id FROM ${s}.users u
9470
+ WHERE u.provider = p_provider AND u.subject = p_subject;
9471
+ $$ LANGUAGE sql STABLE;
9472
+
9473
+ -- The namespace rule, in one place: every shared provider, plus the
9474
+ -- viewer's own. A name outside it returns NO ROW — the same answer a name
9475
+ -- that was never created gets, which is what makes another user's personal
9476
+ -- provider indistinguishable from nonexistent.
9477
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_in_namespace(
9478
+ p_name TEXT, p_viewer BIGINT
9479
+ ) RETURNS TABLE(
9480
+ name TEXT, type_id TEXT, class TEXT, owner_user_id BIGINT,
9481
+ secret_ref JSONB, base_url TEXT, allowance_pct SMALLINT,
9482
+ hold_until_utc TIMESTAMPTZ, hold_indefinite BOOLEAN
9483
+ ) AS $$
9484
+ SELECT pi.name, pi.type_id, pi.class, pi.owner_user_id,
9485
+ pi.secret_ref, pi.base_url, pi.allowance_pct,
9486
+ pi.hold_until_utc, pi.hold_indefinite
9487
+ FROM ${s}.provider_instances pi
9488
+ WHERE pi.name = p_name
9489
+ AND (pi.class = 'shared'
9490
+ OR (p_viewer IS NOT NULL AND pi.owner_user_id = p_viewer));
9491
+ $$ LANGUAGE sql STABLE;
9492
+
9493
+ -- The per-person ceiling. GREATEST(1, ...) so a tiny limit under a small
9494
+ -- allowance still lets a first turn run: every pause in this system is
9495
+ -- "the turn that crossed completed, the next one waits", and a zero
9496
+ -- ceiling would be the one exception that blocks before any work at all.
9497
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_ceiling(
9498
+ p_limit BIGINT, p_pct SMALLINT
9499
+ ) RETURNS BIGINT AS $$
9500
+ -- Through numeric, not bigint. A limit near the top of bigint times a
9501
+ -- percentage overflows before the division brings it back down, and the
9502
+ -- error surfaces from the ADMISSION gate — which fails open — and from
9503
+ -- the status read, which then dies for every provider in the list, not
9504
+ -- just the one carrying the absurd number.
9505
+ SELECT GREATEST(1::BIGINT, ((p_limit::numeric * p_pct) / 100)::BIGINT);
9506
+ $$ LANGUAGE sql IMMUTABLE;
9507
+
9508
+ -- Split a model reference. 'azure-prod:gpt-5.4' → ('azure-prod','gpt-5.4').
9509
+ -- An unqualified reference has no provider and resolves to nothing: this
9510
+ -- model requires every session to name the provider that pays for it.
9511
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_split_ref(
9512
+ p_ref TEXT
9513
+ ) RETURNS TABLE(provider_name TEXT, model_name TEXT) AS $$
9514
+ SELECT CASE WHEN position(':' in COALESCE(p_ref,'')) > 1
9515
+ THEN split_part(p_ref, ':', 1) END,
9516
+ CASE WHEN position(':' in COALESCE(p_ref,'')) > 1
9517
+ THEN substring(p_ref from position(':' in p_ref) + 1) END;
9518
+ $$ LANGUAGE sql IMMUTABLE;
9519
+ `;
9520
+ }
9521
+ /**
9522
+ * 0050 — the read/write surface over 0049's tables.
9523
+ *
9524
+ * AUTHORITY, in one sentence: a shared provider is managed by cluster
9525
+ * admins, a personal provider by its owner, and everything else is open.
9526
+ * There are no grants and no per-provider roles to consult.
9527
+ *
9528
+ * REFUSALS TELL YOU AS MUCH AS YOU CAN ALREADY SEE. A name outside your
9529
+ * namespace is PROVIDER_NOT_FOUND — the same words a typo gets, because
9530
+ * saying "forbidden" would confirm that someone else's personal provider
9531
+ * exists. A name you CAN see but may not manage is PROVIDER_FORBIDDEN,
9532
+ * which tells you nothing you did not already know.
9533
+ */
9534
+ function migration_0050_provider_budget_procs(schema) {
9535
+ const s = `"${schema}"`;
9536
+ return `
9537
+ -- ── management: providers ────────────────────────────────────────────
9538
+
9539
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_create(
9540
+ p_name TEXT, p_type_id TEXT, p_class TEXT, p_owner BIGINT,
9541
+ p_secret JSONB, p_base_url TEXT, p_actor BIGINT, p_is_admin BOOLEAN
9542
+ ) RETURNS TABLE(name TEXT, type_id TEXT, class TEXT, owner_user_id BIGINT) AS $$
9543
+ DECLARE v_name TEXT := NULLIF(BTRIM(p_name), '');
9544
+ BEGIN
9545
+ IF v_name IS NULL THEN
9546
+ RAISE EXCEPTION 'PROVIDER_INVALID: a provider needs a name';
9547
+ END IF;
9548
+ IF p_class NOT IN ('shared','personal') THEN
9549
+ RAISE EXCEPTION 'PROVIDER_INVALID: class must be shared or personal';
9550
+ END IF;
9551
+ IF p_class = 'shared' AND NOT COALESCE(p_is_admin, FALSE) THEN
9552
+ RAISE EXCEPTION 'PROVIDER_FORBIDDEN: only an administrator can add a shared provider';
9553
+ END IF;
9554
+ IF p_class = 'personal' AND p_actor IS NULL THEN
9555
+ RAISE EXCEPTION 'PROVIDER_FORBIDDEN: sign in to add a provider of your own';
9556
+ END IF;
9557
+ -- A personal provider belongs to the person MAKING it, never to whoever
9558
+ -- the caller names. p_owner is an argument, and an argument is a wish:
9559
+ -- honouring it let anyone plant a credential and a base_url inside
9560
+ -- someone else's namespace, where that person's sessions would resolve
9561
+ -- it and spend through it.
9562
+ IF p_class = 'personal' AND p_owner IS DISTINCT FROM p_actor THEN
9563
+ RAISE EXCEPTION 'PROVIDER_FORBIDDEN: a provider of your own is created in your own name';
9564
+ END IF;
9565
+ -- The name is the identity every session reference resolves through, so
9566
+ -- a collision is reported as a collision. It says a name is taken and
9567
+ -- nothing else: not the class, not the owner, not the type.
9568
+ IF EXISTS (SELECT 1 FROM ${s}.provider_instances pi WHERE pi.name = v_name) THEN
9569
+ RAISE EXCEPTION 'PROVIDER_CONFLICT: the name "%" is already taken', v_name;
9570
+ END IF;
9571
+ INSERT INTO ${s}.provider_instances
9572
+ (name, type_id, class, owner_user_id, secret_ref, base_url)
9573
+ VALUES (v_name, p_type_id, p_class,
9574
+ CASE WHEN p_class = 'personal' THEN p_actor END,
9575
+ COALESCE(p_secret, '{}'::jsonb), NULLIF(BTRIM(p_base_url), ''));
9576
+ RETURN QUERY SELECT pi.name, pi.type_id, pi.class, pi.owner_user_id
9577
+ FROM ${s}.provider_instances pi WHERE pi.name = v_name;
9578
+ END;
9579
+ $$ LANGUAGE plpgsql VOLATILE;
9580
+
9581
+ -- May the actor manage this provider? Raises rather than returning false,
9582
+ -- so every mutation gets the same refusal wording for free.
9583
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_assert_manage(
9584
+ p_name TEXT, p_actor BIGINT, p_is_admin BOOLEAN
9585
+ ) RETURNS ${s}.provider_instances AS $$
9586
+ DECLARE v_row ${s}.provider_instances;
9587
+ BEGIN
9588
+ SELECT * INTO v_row FROM ${s}.provider_instances pi WHERE pi.name = p_name;
9589
+ IF NOT FOUND THEN
9590
+ RAISE EXCEPTION 'PROVIDER_NOT_FOUND: there is no provider named "%"', p_name;
9591
+ END IF;
9592
+
9593
+ -- A personal provider answers to its owner and to NOBODY else — an
9594
+ -- administrator included. Being an administrator is authority over the
9595
+ -- cluster's own credentials, not over a credential someone brought from
9596
+ -- home: an admin who could set limits on, hold, or delete a person's own
9597
+ -- key would be reaching into a namespace the whole design says is
9598
+ -- theirs. It reads as absent, the same as any name outside a namespace,
9599
+ -- so this refusal cannot be used to discover that it exists either.
9600
+ IF v_row.class = 'personal' THEN
9601
+ IF p_actor IS NULL OR v_row.owner_user_id IS DISTINCT FROM p_actor THEN
9602
+ RAISE EXCEPTION 'PROVIDER_NOT_FOUND: there is no provider named "%"', p_name;
9603
+ END IF;
9604
+ RETURN v_row;
9605
+ END IF;
9606
+
9607
+ IF NOT COALESCE(p_is_admin, FALSE) THEN
9608
+ RAISE EXCEPTION 'PROVIDER_FORBIDDEN: "%" is a shared provider; only an administrator can change it', p_name;
9609
+ END IF;
9610
+ RETURN v_row;
9611
+ END;
9612
+ $$ LANGUAGE plpgsql STABLE;
9613
+
9614
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_delete(
9615
+ p_name TEXT, p_actor BIGINT, p_is_admin BOOLEAN
9616
+ ) RETURNS BIGINT AS $$
9617
+ DECLARE
9618
+ v_row ${s}.provider_instances;
9619
+ v_waiting BIGINT;
9620
+ BEGIN
9621
+ v_row := ${s}.cms_provider_assert_manage(p_name, p_actor, p_is_admin);
9622
+ -- Sessions naming it are not touched: they resolve nothing at their next
9623
+ -- turn and wait, which is the whole no-fallback rule. Report how many so
9624
+ -- the caller can say it.
9625
+ SELECT count(*) INTO v_waiting
9626
+ FROM ${s}.sessions ss
9627
+ CROSS JOIN LATERAL ${s}.cms_provider_split_ref(ss.model) sp
9628
+ WHERE ss.deleted_at IS NULL
9629
+ AND ss.state NOT IN ('completed', 'failed', 'error', 'cancelled')
9630
+ AND sp.provider_name = p_name;
9631
+ -- Rules and counters cascade. The ledger keeps its rows: provider_name
9632
+ -- there is a record of what happened, not a reference to a live row.
9633
+ DELETE FROM ${s}.provider_instances pi WHERE pi.name = p_name;
9634
+ UPDATE ${s}.provider_cluster_settings SET default_provider = NULL, default_model = NULL,
9635
+ default_reasoning = NULL, default_context = NULL, updated_at = now()
9636
+ WHERE singleton AND default_provider = p_name;
9637
+ UPDATE ${s}.users u SET default_provider = NULL, default_model = NULL,
9638
+ default_reasoning = NULL, default_context = NULL
9639
+ WHERE u.default_provider = p_name;
9640
+ RETURN v_waiting;
9641
+ END;
9642
+ $$ LANGUAGE plpgsql VOLATILE;
9643
+
9644
+ -- ── management: limits, allowance, holds ─────────────────────────────
9645
+
9646
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_set_limit(
9647
+ p_name TEXT, p_period TEXT, p_model TEXT, p_tokens BIGINT,
9648
+ p_rule_id TEXT, p_actor BIGINT, p_is_admin BOOLEAN
9649
+ ) RETURNS TABLE(rule_id TEXT, seeded_tokens BIGINT) AS $$
9650
+ -- The OUT parameter rule_id would otherwise shadow the COLUMN of the same
9651
+ -- name, and an ON CONFLICT target that resolves to a variable is rejected
9652
+ -- as ambiguous. Inside this body a bare column name means the column.
9653
+ #variable_conflict use_column
9654
+ DECLARE
9655
+ v_model TEXT := NULLIF(BTRIM(p_model), '');
9656
+ v_rule TEXT;
9657
+ v_bounds RECORD;
9658
+ v_seed BIGINT;
9659
+ BEGIN
9660
+ PERFORM ${s}.cms_provider_assert_manage(p_name, p_actor, p_is_admin);
9661
+ IF p_period NOT IN ('day','week','month') THEN
9662
+ RAISE EXCEPTION 'PROVIDER_INVALID: period must be day, week or month';
9663
+ END IF;
9664
+ IF p_tokens IS NULL OR p_tokens <= 0 THEN
9665
+ RAISE EXCEPTION 'PROVIDER_INVALID: a limit must be a positive number of tokens';
9666
+ END IF;
9667
+ -- A limit scoped to one model matches the QUALIFIED reference a session
9668
+ -- runs, so a bare model name matches nothing — the limit saves, shows in
9669
+ -- the report as a live cap, and silently never fires. Refuse it and say
9670
+ -- what to write instead.
9671
+ IF v_model IS NOT NULL AND v_model NOT LIKE p_name || ':%' THEN
9672
+ RAISE EXCEPTION 'PROVIDER_INVALID: a limit on one model names it as "%:<model>", not "%"', p_name, v_model;
9673
+ END IF;
9674
+
9675
+ INSERT INTO ${s}.provider_budget_rules (rule_id, provider_name, period, model_qualified, limit_tokens)
9676
+ VALUES (p_rule_id, p_name, p_period, v_model, p_tokens)
9677
+ ON CONFLICT (provider_name, period, COALESCE(model_qualified, '*'))
9678
+ DO UPDATE SET limit_tokens = EXCLUDED.limit_tokens, updated_at = now()
9679
+ RETURNING ${s}.provider_budget_rules.rule_id INTO v_rule;
9680
+
9681
+ -- A new limit counts from what this window has ALREADY spent; it does
9682
+ -- not hand out a fresh allocation. The count is re-derived from the
9683
+ -- ledger rather than carried forward, which makes saving a limit twice
9684
+ -- idempotent.
9685
+ --
9686
+ -- ORDER MATTERS, and this is the whole reason the counter rows are
9687
+ -- claimed before the sum is taken. Re-deriving is a read followed by a
9688
+ -- write, and a turn that settles between the two used to have its charge
9689
+ -- ASSIGNED away — the settle incremented a row, then this UPDATE
9690
+ -- overwrote it with a total computed before that increment existed. A
9691
+ -- provider then ran past a hard cap with the gate still answering
9692
+ -- 'clear', and the two counters disagreed for the rest of the window.
9693
+ -- Claiming the rows first makes a concurrent settle either commit ahead
9694
+ -- of the sum (and be counted) or block until after it (and add on top).
9695
+ SELECT * INTO v_bounds FROM ${s}.cms_provider_window_bounds(p_period, now());
9696
+
9697
+ INSERT INTO ${s}.provider_quota_counters
9698
+ (rule_id, window_key_utc, used_tokens, window_start_utc, resets_at_utc)
9699
+ VALUES (v_rule, v_bounds.window_key, 0, v_bounds.window_start, v_bounds.resets_at)
9700
+ ON CONFLICT (rule_id, window_key_utc) DO UPDATE SET updated_at = now();
9701
+
9702
+ -- Only this provider's OWN history. The ledger keeps a name, not a
9703
+ -- reference, so a name that was deleted and made again would otherwise
9704
+ -- inherit the spend of the provider that used to hold it — and be born
9705
+ -- at a limit it never spent a token against. Its own creation instant is
9706
+ -- the line between the two.
9707
+ SELECT COALESCE(sum(l.tokens_total), 0) INTO v_seed
9708
+ FROM ${s}.provider_usage_ledger l
9709
+ JOIN ${s}.provider_instances pi ON pi.name = p_name
9710
+ WHERE l.provider_name = p_name
9711
+ AND l.charge_class = 'user'
9712
+ AND (v_model IS NULL OR l.model_qualified = v_model)
9713
+ AND l.created_at >= GREATEST(v_bounds.window_start, pi.created_at)
9714
+ AND l.created_at < v_bounds.resets_at;
9715
+
9716
+ UPDATE ${s}.provider_quota_counters c
9717
+ SET used_tokens = v_seed, updated_at = now()
9718
+ WHERE c.rule_id = v_rule AND c.window_key_utc = v_bounds.window_key;
9719
+
9720
+ -- The per-person mirror, same reasoning: claim every row that could be
9721
+ -- touched, then recompute. A person with no spend this window gets no
9722
+ -- row, which reads as zero.
9723
+ INSERT INTO ${s}.provider_quota_counters_user
9724
+ (rule_id, user_id, window_key_utc, used_tokens, window_start_utc, resets_at_utc)
9725
+ SELECT v_rule, l.owner_user_id, v_bounds.window_key, 0,
9726
+ v_bounds.window_start, v_bounds.resets_at
9727
+ FROM ${s}.provider_usage_ledger l
9728
+ JOIN ${s}.provider_instances pi ON pi.name = p_name
9729
+ WHERE l.provider_name = p_name
9730
+ AND l.charge_class = 'user'
9731
+ AND l.owner_user_id IS NOT NULL
9732
+ AND (v_model IS NULL OR l.model_qualified = v_model)
9733
+ AND l.created_at >= GREATEST(v_bounds.window_start, pi.created_at)
9734
+ AND l.created_at < v_bounds.resets_at
9735
+ GROUP BY l.owner_user_id
9736
+ ON CONFLICT (rule_id, user_id, window_key_utc) DO UPDATE SET updated_at = now();
9737
+
9738
+ UPDATE ${s}.provider_quota_counters_user cu
9739
+ SET used_tokens = agg.total, updated_at = now()
9740
+ FROM (
9741
+ SELECT l.owner_user_id AS uid, COALESCE(sum(l.tokens_total), 0) AS total
9742
+ FROM ${s}.provider_usage_ledger l
9743
+ JOIN ${s}.provider_instances pi ON pi.name = p_name
9744
+ WHERE l.provider_name = p_name
9745
+ AND l.charge_class = 'user'
9746
+ AND l.owner_user_id IS NOT NULL
9747
+ AND (v_model IS NULL OR l.model_qualified = v_model)
9748
+ AND l.created_at >= GREATEST(v_bounds.window_start, pi.created_at)
9749
+ AND l.created_at < v_bounds.resets_at
9750
+ GROUP BY l.owner_user_id
9751
+ ) agg
9752
+ WHERE cu.rule_id = v_rule AND cu.window_key_utc = v_bounds.window_key
9753
+ AND cu.user_id = agg.uid;
9754
+
9755
+ RETURN QUERY SELECT v_rule, v_seed;
9756
+ END;
9757
+ $$ LANGUAGE plpgsql VOLATILE;
9758
+
9759
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_remove_limit(
9760
+ p_name TEXT, p_period TEXT, p_model TEXT, p_actor BIGINT, p_is_admin BOOLEAN
9761
+ ) RETURNS BOOLEAN AS $$
9762
+ DECLARE v_deleted INTEGER;
9763
+ BEGIN
9764
+ PERFORM ${s}.cms_provider_assert_manage(p_name, p_actor, p_is_admin);
9765
+ -- Without this a mistyped period answered "nothing was there" while the
9766
+ -- limit it meant to remove stayed in force.
9767
+ IF p_period NOT IN ('day','week','month') THEN
9768
+ RAISE EXCEPTION 'PROVIDER_INVALID: period must be day, week or month';
9769
+ END IF;
9770
+ DELETE FROM ${s}.provider_budget_rules r
9771
+ WHERE r.provider_name = p_name AND r.period = p_period
9772
+ AND COALESCE(r.model_qualified, '*') = COALESCE(NULLIF(BTRIM(p_model), ''), '*');
9773
+ GET DIAGNOSTICS v_deleted = ROW_COUNT;
9774
+ RETURN v_deleted > 0;
9775
+ END;
9776
+ $$ LANGUAGE plpgsql VOLATILE;
9777
+
9778
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_set_allowance(
9779
+ p_name TEXT, p_pct SMALLINT, p_actor BIGINT, p_is_admin BOOLEAN
9780
+ ) RETURNS SMALLINT AS $$
9781
+ DECLARE v_row ${s}.provider_instances;
9782
+ BEGIN
9783
+ v_row := ${s}.cms_provider_assert_manage(p_name, p_actor, p_is_admin);
9784
+ IF v_row.class <> 'shared' THEN
9785
+ RAISE EXCEPTION 'PROVIDER_INVALID: "%" is your own provider; an allowance divides a shared budget between people', p_name;
9786
+ END IF;
9787
+ IF p_pct IS NULL OR p_pct < 1 OR p_pct > 100 THEN
9788
+ RAISE EXCEPTION 'PROVIDER_INVALID: an allowance is a percentage between 1 and 100';
9789
+ END IF;
9790
+ UPDATE ${s}.provider_instances pi
9791
+ SET allowance_pct = p_pct, updated_at = now()
9792
+ WHERE pi.name = p_name;
9793
+ RETURN p_pct;
9794
+ END;
9795
+ $$ LANGUAGE plpgsql VOLATILE;
9796
+
9797
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_set_hold(
9798
+ p_name TEXT, p_until TIMESTAMPTZ, p_indefinite BOOLEAN,
9799
+ p_actor BIGINT, p_is_admin BOOLEAN
9800
+ ) RETURNS BOOLEAN AS $$
9801
+ BEGIN
9802
+ PERFORM ${s}.cms_provider_assert_manage(p_name, p_actor, p_is_admin);
9803
+ UPDATE ${s}.provider_instances pi
9804
+ SET hold_until_utc = p_until,
9805
+ hold_indefinite = COALESCE(p_indefinite, FALSE),
9806
+ updated_at = now()
9807
+ WHERE pi.name = p_name;
9808
+ RETURN TRUE;
9809
+ END;
9810
+ $$ LANGUAGE plpgsql VOLATILE;
9811
+
9812
+ -- ── defaults ─────────────────────────────────────────────────────────
9813
+
9814
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_set_cluster_default(
9815
+ p_provider TEXT, p_model TEXT, p_reasoning TEXT, p_context TEXT, p_is_admin BOOLEAN
9816
+ ) RETURNS BOOLEAN AS $$
9817
+ DECLARE v_row ${s}.provider_instances;
9818
+ BEGIN
9819
+ IF NOT COALESCE(p_is_admin, FALSE) THEN
9820
+ RAISE EXCEPTION 'PROVIDER_FORBIDDEN: only an administrator can set the cluster default';
9821
+ END IF;
9822
+ -- Both halves or neither. A tuple with a provider and no model is not a
9823
+ -- default anybody can start a session from, and reporting success for it
9824
+ -- left the caller believing something had been set.
9825
+ IF NULLIF(BTRIM(COALESCE(p_provider, '')), '') IS NULL
9826
+ OR NULLIF(BTRIM(COALESCE(p_model, '')), '') IS NULL THEN
9827
+ RAISE EXCEPTION 'PROVIDER_INVALID: the cluster default needs a provider and a model';
9828
+ END IF;
9829
+ SELECT * INTO v_row FROM ${s}.provider_instances pi WHERE pi.name = p_provider;
9830
+ IF NOT FOUND THEN
9831
+ RAISE EXCEPTION 'PROVIDER_NOT_FOUND: there is no provider named "%"', p_provider;
9832
+ END IF;
9833
+ -- The cluster default is what system sessions run on. Machinery on one
9834
+ -- person's own credential would stop the moment they removed it.
9835
+ IF v_row.class <> 'shared' THEN
9836
+ RAISE EXCEPTION 'PROVIDER_INVALID: the cluster default must be a shared provider';
9837
+ END IF;
9838
+ -- Both halves must agree. A session runs the MODEL reference, so a tuple
9839
+ -- naming a shared provider beside somebody's personal model reference
9840
+ -- would put the machinery on a private credential — through the half
9841
+ -- nobody was checking.
9842
+ IF p_model NOT LIKE p_provider || ':%' THEN
9843
+ RAISE EXCEPTION 'PROVIDER_INVALID: the model must belong to "%": write it as "%:<model>"', p_provider, p_provider;
9844
+ END IF;
9845
+ UPDATE ${s}.provider_cluster_settings
9846
+ SET default_provider = p_provider, default_model = p_model,
9847
+ default_reasoning = NULLIF(BTRIM(COALESCE(p_reasoning, '')), ''),
9848
+ default_context = NULLIF(BTRIM(COALESCE(p_context, '')), ''),
9849
+ updated_at = now()
9850
+ WHERE singleton;
9851
+ RETURN TRUE;
9852
+ END;
9853
+ $$ LANGUAGE plpgsql VOLATILE;
9854
+
9855
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_set_user_default(
9856
+ p_actor BIGINT, p_provider TEXT, p_model TEXT, p_reasoning TEXT, p_context TEXT
9857
+ ) RETURNS BOOLEAN AS $$
9858
+ DECLARE v_name TEXT := NULLIF(BTRIM(COALESCE(p_provider, '')), '');
9859
+ BEGIN
9860
+ IF p_actor IS NULL THEN
9861
+ RAISE EXCEPTION 'PROVIDER_FORBIDDEN: sign in to set a default';
9862
+ END IF;
9863
+ IF v_name IS NOT NULL THEN
9864
+ -- Your default must be something you can actually run.
9865
+ IF NOT EXISTS (SELECT 1 FROM ${s}.cms_provider_in_namespace(v_name, p_actor)) THEN
9866
+ RAISE EXCEPTION 'PROVIDER_NOT_FOUND: there is no provider named "%"', v_name;
9867
+ END IF;
9868
+ IF NULLIF(BTRIM(COALESCE(p_model, '')), '') IS NULL THEN
9869
+ RAISE EXCEPTION 'PROVIDER_INVALID: a default needs a provider and a model';
9870
+ END IF;
9871
+ IF p_model NOT LIKE v_name || ':%' THEN
9872
+ RAISE EXCEPTION 'PROVIDER_INVALID: the model must belong to "%": write it as "%:<model>"', v_name, v_name;
9873
+ END IF;
9874
+ END IF;
9875
+ UPDATE ${s}.users u
9876
+ SET default_provider = v_name,
9877
+ default_model = CASE WHEN v_name IS NULL THEN NULL ELSE p_model END,
9878
+ default_reasoning = CASE WHEN v_name IS NULL THEN NULL ELSE NULLIF(BTRIM(COALESCE(p_reasoning, '')), '') END,
9879
+ default_context = CASE WHEN v_name IS NULL THEN NULL ELSE NULLIF(BTRIM(COALESCE(p_context, '')), '') END
9880
+ WHERE u.user_id = p_actor;
9881
+ RETURN TRUE;
9882
+ END;
9883
+ $$ LANGUAGE plpgsql VOLATILE;
9884
+
9885
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_get_defaults(
9886
+ p_actor BIGINT
9887
+ ) RETURNS TABLE(
9888
+ cluster_provider TEXT, cluster_model TEXT, cluster_reasoning TEXT, cluster_context TEXT,
9889
+ my_provider TEXT, my_model TEXT, my_reasoning TEXT, my_context TEXT
9890
+ ) AS $$
9891
+ SELECT c.default_provider, c.default_model, c.default_reasoning, c.default_context,
9892
+ u.default_provider, u.default_model, u.default_reasoning, u.default_context
9893
+ FROM ${s}.provider_cluster_settings c
9894
+ LEFT JOIN ${s}.users u ON u.user_id = p_actor
9895
+ WHERE c.singleton;
9896
+ $$ LANGUAGE sql STABLE;
9897
+
9898
+ -- ── bootstrap: the one-time deployment seed ──────────────────────────
9899
+ -- Claims the flag atomically, so several fresh pods racing at first boot
9900
+ -- seed exactly once. After the claim the declaration is never read again:
9901
+ -- an administrator who deletes a seeded provider has deleted it, and no
9902
+ -- restart brings it back.
9903
+
9904
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_bootstrap(
9905
+ p_instances JSONB, p_default JSONB
9906
+ ) RETURNS TABLE(claimed BOOLEAN, created INTEGER) AS $$
9907
+ DECLARE
9908
+ v_claimed BOOLEAN := FALSE;
9909
+ v_created INTEGER := 0;
9910
+ v_item JSONB;
9911
+ BEGIN
9912
+ UPDATE ${s}.provider_cluster_settings
9913
+ SET bootstrapped_at = now(), updated_at = now()
9914
+ WHERE singleton AND bootstrapped_at IS NULL;
9915
+ GET DIAGNOSTICS v_created = ROW_COUNT;
9916
+ v_claimed := v_created > 0;
9917
+ v_created := 0;
9918
+ IF NOT v_claimed THEN
9919
+ RETURN QUERY SELECT FALSE, 0;
9920
+ RETURN;
9921
+ END IF;
9922
+
9923
+ FOR v_item IN SELECT * FROM jsonb_array_elements(COALESCE(p_instances, '[]'::jsonb)) LOOP
9924
+ BEGIN
9925
+ PERFORM ${s}.cms_provider_create(
9926
+ v_item->>'name', v_item->>'typeId', 'shared', NULL,
9927
+ COALESCE(v_item->'secretRef', '{}'::jsonb), v_item->>'baseUrl',
9928
+ NULL, TRUE);
9929
+ v_created := v_created + 1;
9930
+ EXCEPTION WHEN OTHERS THEN
9931
+ RAISE NOTICE 'provider bootstrap skipped %: %', v_item->>'name', SQLERRM;
9932
+ END;
9933
+ END LOOP;
9934
+
9935
+ -- The default is NOT best-effort. It names what system sessions run, and
9936
+ -- a cluster that seeded without one has no machinery and no fallback for
9937
+ -- anybody who never set a default of their own. Raising rolls back the
9938
+ -- claim along with everything else, so the next pod to boot tries again
9939
+ -- and the operator sees the same loud error until the file is fixed —
9940
+ -- which is the opposite of the quiet, permanent half-seeded cluster a
9941
+ -- swallowed error leaves behind.
9942
+ IF p_default IS NOT NULL AND p_default->>'provider' IS NOT NULL THEN
9943
+ PERFORM ${s}.cms_provider_set_cluster_default(
9944
+ p_default->>'provider', p_default->>'model',
9945
+ p_default->>'reasoning', p_default->>'context', TRUE);
9946
+ END IF;
9947
+
9948
+ RETURN QUERY SELECT TRUE, v_created;
9949
+ END;
9950
+ $$ LANGUAGE plpgsql VOLATILE;
9951
+ `;
9952
+ }
9953
+ /**
9954
+ * 0052 — a pause record is a snapshot, so check it before reporting it.
9955
+ *
9956
+ * `sessions.pause_state` is written by the admission gate and cleared by the
9957
+ * same gate on the session's next turn. That makes it a CACHE of a decision,
9958
+ * and it is only as fresh as the session is busy. Release a hold on a session
9959
+ * that is slow, stopped, or waiting on a durable timer and the record sits
9960
+ * there unchanged — so the surface keeps naming a cause that no longer
9961
+ * exists, and the very action taken to fix it appears to have done nothing.
9962
+ * That was observed live: a released hold, and a session still reported as
9963
+ * held by it.
9964
+ *
9965
+ * The fix is to check the record against live truth at READ time. The
9966
+ * database already holds everything needed to say whether the recorded cause
9967
+ * still applies, and a stale record is then simply not reported.
9968
+ *
9969
+ * NOT the wake query. cms_provider_paused_for exists to find the sessions
9970
+ * that must be nudged when a cause disappears, and it finds them BY the
9971
+ * record — so filtering it on liveness would find nothing at the exact
9972
+ * moment it is needed, and the session would never be told to look again.
9973
+ * One reads the record as history to act on; the other reads it as a claim
9974
+ * about now. Only the second needs checking.
9975
+ */
9976
+ function migration_0052_provider_pause_liveness(schema) {
9977
+ const s = `"${schema}"`;
9978
+ return `
9979
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_pause_is_live(
9980
+ p_pause JSONB, p_owner BIGINT
9981
+ ) RETURNS BOOLEAN AS $$
9982
+ DECLARE
9983
+ v_kind TEXT := p_pause->>'kind';
9984
+ v_name TEXT := p_pause->>'provider';
9985
+ v_inst RECORD;
9986
+ v_rule RECORD;
9987
+ v_bounds RECORD;
9988
+ v_used BIGINT;
9989
+ v_you BIGINT;
9990
+ BEGIN
9991
+ IF p_pause IS NULL OR v_kind IS NULL THEN RETURN FALSE; END IF;
9992
+
9993
+ -- A name that does not resolve is exactly what this pause reports, so it
9994
+ -- is live while the name is still missing and dead the moment it is not.
9995
+ IF v_kind = 'no_provider' THEN
9996
+ RETURN NOT EXISTS (SELECT 1 FROM ${s}.cms_provider_in_namespace(COALESCE(v_name, ''), p_owner));
9997
+ END IF;
9998
+
9999
+ SELECT * INTO v_inst FROM ${s}.cms_provider_in_namespace(COALESCE(v_name, ''), p_owner);
10000
+ -- The provider went away under a session that was paused for some other
10001
+ -- reason. It is still stuck, so the record still stands.
10002
+ IF NOT FOUND THEN RETURN TRUE; END IF;
10003
+
10004
+ IF v_kind = 'hold' THEN
10005
+ RETURN COALESCE(v_inst.hold_indefinite, FALSE)
10006
+ OR (v_inst.hold_until_utc IS NOT NULL AND v_inst.hold_until_utc > now());
10007
+ END IF;
10008
+
10009
+ SELECT * INTO v_rule FROM ${s}.provider_budget_rules r
10010
+ WHERE r.rule_id = p_pause->>'ruleId';
10011
+ -- The limit that stopped it was removed.
10012
+ IF NOT FOUND THEN RETURN FALSE; END IF;
10013
+
10014
+ SELECT * INTO v_bounds FROM ${s}.cms_provider_window_bounds(v_rule.period, now());
10015
+ SELECT COALESCE(c.used_tokens, 0) INTO v_used
10016
+ FROM ${s}.provider_quota_counters c
10017
+ WHERE c.rule_id = v_rule.rule_id AND c.window_key_utc = v_bounds.window_key;
10018
+ v_used := COALESCE(v_used, 0);
10019
+
10020
+ IF v_kind = 'limit' THEN
10021
+ RETURN v_used >= v_rule.limit_tokens;
10022
+ END IF;
10023
+
10024
+ IF v_kind = 'allowance' THEN
10025
+ IF v_inst.allowance_pct >= 100 OR p_owner IS NULL THEN RETURN FALSE; END IF;
10026
+ SELECT COALESCE(cu.used_tokens, 0) INTO v_you
10027
+ FROM ${s}.provider_quota_counters_user cu
10028
+ WHERE cu.rule_id = v_rule.rule_id AND cu.user_id = p_owner
10029
+ AND cu.window_key_utc = v_bounds.window_key;
10030
+ RETURN COALESCE(v_you, 0) >= ${s}.cms_provider_ceiling(v_rule.limit_tokens, v_inst.allowance_pct);
10031
+ END IF;
10032
+
10033
+ -- A kind this version does not know about is reported rather than hidden:
10034
+ -- silence would be the one failure mode worse than a stale record.
10035
+ RETURN TRUE;
10036
+ END;
10037
+ $$ LANGUAGE plpgsql STABLE;
10038
+
10039
+ -- Report only the pauses whose cause still holds.
10040
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_list_paused(
10041
+ p_viewer BIGINT, p_is_admin BOOLEAN, p_limit INTEGER
10042
+ ) RETURNS TABLE(
10043
+ session_id TEXT, title TEXT, model TEXT, owner_user_id BIGINT,
10044
+ owner_email TEXT, state TEXT, pause_state JSONB, updated_at TIMESTAMPTZ
10045
+ ) AS $$
10046
+ SELECT ss.session_id, ss.title, ss.model, so.user_id, ou.email, ss.state,
10047
+ ss.pause_state, ss.updated_at
10048
+ FROM ${s}.sessions ss
10049
+ LEFT JOIN ${s}.session_owners so ON so.session_id = ss.session_id
10050
+ LEFT JOIN ${s}.users ou ON ou.user_id = so.user_id
10051
+ WHERE ss.deleted_at IS NULL
10052
+ AND ss.pause_state IS NOT NULL
10053
+ AND ss.state NOT IN ('completed', 'failed', 'error', 'cancelled')
10054
+ AND (COALESCE(p_is_admin, FALSE) OR so.user_id = p_viewer)
10055
+ AND ${s}.cms_provider_pause_is_live(ss.pause_state, so.user_id)
10056
+ ORDER BY ss.updated_at DESC
10057
+ LIMIT COALESCE(p_limit, 100);
10058
+ $$ LANGUAGE sql STABLE;
10059
+ `;
10060
+ }
10061
+ /**
10062
+ * 0051 — the runtime pair, plus the readers behind every surface.
10063
+ *
10064
+ * cms_provider_check_turn is THE admission call: one round trip that
10065
+ * resolves the payer, applies the hold, the limits and the allowance, and
10066
+ * records the structured pause. cms_provider_settle_turn is its
10067
+ * counterpart: one call that writes the ledger row and moves the counters,
10068
+ * exactly once, no matter how many times an activity retries.
10069
+ *
10070
+ * The decision lives HERE rather than in TypeScript on purpose. The
10071
+ * counters are in this database; splitting "read the numbers" from "judge
10072
+ * the numbers" across a process boundary buys a testable pure function and
10073
+ * pays for it with two implementations that can disagree about whether a
10074
+ * session may run. The TypeScript side keeps only presentation logic.
10075
+ */
10076
+ function migration_0051_provider_budget_runtime(schema) {
10077
+ const s = `"${schema}"`;
10078
+ return `
10079
+ -- ── admission ────────────────────────────────────────────────────────
10080
+
10081
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_check_turn(
10082
+ p_session_id TEXT, p_model TEXT
10083
+ ) RETURNS TABLE(
10084
+ verdict TEXT, provider_name TEXT, model_qualified TEXT,
10085
+ exempt BOOLEAN, pause JSONB, rules JSONB
10086
+ ) AS $$
10087
+ DECLARE
10088
+ v_sess RECORD;
10089
+ v_owner BIGINT;
10090
+ v_ref TEXT;
10091
+ v_split RECORD;
10092
+ v_inst RECORD;
10093
+ v_rule RECORD;
10094
+ v_bounds RECORD;
10095
+ v_used BIGINT;
10096
+ v_you BIGINT;
10097
+ v_ceiling BIGINT;
10098
+ v_rules JSONB := '[]'::jsonb;
10099
+ v_block JSONB := NULL;
10100
+ v_kind TEXT := NULL;
10101
+ v_reset TIMESTAMPTZ := NULL;
10102
+ v_pause JSONB := NULL;
10103
+ BEGIN
10104
+ SELECT ss.is_system, ss.model, ss.pause_state INTO v_sess
10105
+ FROM ${s}.sessions ss WHERE ss.session_id = p_session_id;
10106
+ IF NOT FOUND THEN
10107
+ -- Nothing to admit and nothing to charge. The gate's posture is
10108
+ -- fail-open, so say clear rather than inventing a refusal.
10109
+ RETURN QUERY SELECT 'clear'::TEXT, NULL::TEXT, NULL::TEXT, FALSE, NULL::JSONB, '[]'::jsonb;
10110
+ RETURN;
10111
+ END IF;
10112
+
10113
+ SELECT so.user_id INTO v_owner FROM ${s}.session_owners so
10114
+ WHERE so.session_id = p_session_id;
10115
+
10116
+ -- The catalog row is authoritative for the model, exactly as the turn
10117
+ -- runtime treats it; the argument is only the turn's own override.
10118
+ v_ref := COALESCE(NULLIF(BTRIM(COALESCE(p_model, '')), ''), v_sess.model);
10119
+ SELECT * INTO v_split FROM ${s}.cms_provider_split_ref(v_ref);
10120
+
10121
+ -- Assigned unconditionally. A RECORD that was never assigned raises
10122
+ -- "record is not assigned yet" the moment a field is read, and the read
10123
+ -- below sits behind an OR whose short-circuiting Postgres does not
10124
+ -- promise — so an unqualified model reference could throw instead of
10125
+ -- answering, and the gate's fail-open posture would run the turn.
10126
+ SELECT * INTO v_inst FROM ${s}.cms_provider_in_namespace(
10127
+ COALESCE(v_split.provider_name, ''), v_owner);
10128
+
10129
+ IF v_inst.name IS NULL THEN
10130
+ v_pause := jsonb_build_object(
10131
+ 'kind', 'no_provider',
10132
+ 'provider', v_split.provider_name,
10133
+ 'modelRef', v_ref);
10134
+ UPDATE ${s}.sessions ss SET pause_state = v_pause
10135
+ WHERE ss.session_id = p_session_id
10136
+ AND ss.pause_state IS DISTINCT FROM v_pause;
10137
+ RETURN QUERY SELECT 'no_provider'::TEXT, v_split.provider_name, v_ref, FALSE, v_pause, '[]'::jsonb;
10138
+ RETURN;
10139
+ END IF;
10140
+
10141
+ -- System sessions are never paused: a limit that could stop the
10142
+ -- machinery would leave nobody able to raise the limit that stopped it.
10143
+ -- They still resolve a provider, because their spend is still recorded.
10144
+ IF COALESCE(v_sess.is_system, FALSE) THEN
10145
+ IF v_sess.pause_state IS NOT NULL THEN
10146
+ UPDATE ${s}.sessions ss SET pause_state = NULL WHERE ss.session_id = p_session_id;
10147
+ END IF;
10148
+ RETURN QUERY SELECT 'clear'::TEXT, v_inst.name, v_ref, TRUE, NULL::JSONB, '[]'::jsonb;
10149
+ RETURN;
10150
+ END IF;
10151
+
10152
+ IF COALESCE(v_inst.hold_indefinite, FALSE)
10153
+ OR (v_inst.hold_until_utc IS NOT NULL AND v_inst.hold_until_utc > now()) THEN
10154
+ v_pause := jsonb_build_object(
10155
+ 'kind', 'hold',
10156
+ 'provider', v_inst.name,
10157
+ 'resetsAtUtc', CASE WHEN COALESCE(v_inst.hold_indefinite, FALSE)
10158
+ THEN NULL ELSE to_char(v_inst.hold_until_utc AT TIME ZONE 'UTC',
10159
+ 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') END);
10160
+ UPDATE ${s}.sessions ss SET pause_state = v_pause
10161
+ WHERE ss.session_id = p_session_id
10162
+ AND ss.pause_state IS DISTINCT FROM v_pause;
10163
+ RETURN QUERY SELECT 'paused'::TEXT, v_inst.name, v_ref, FALSE, v_pause, '[]'::jsonb;
10164
+ RETURN;
10165
+ END IF;
10166
+
10167
+ FOR v_rule IN
10168
+ SELECT r.* FROM ${s}.provider_budget_rules r
10169
+ WHERE r.provider_name = v_inst.name
10170
+ AND (r.model_qualified IS NULL OR r.model_qualified = v_ref)
10171
+ LOOP
10172
+ SELECT * INTO v_bounds FROM ${s}.cms_provider_window_bounds(v_rule.period, now());
10173
+ SELECT COALESCE(c.used_tokens, 0) INTO v_used
10174
+ FROM ${s}.provider_quota_counters c
10175
+ WHERE c.rule_id = v_rule.rule_id AND c.window_key_utc = v_bounds.window_key;
10176
+ v_used := COALESCE(v_used, 0);
10177
+
10178
+ v_ceiling := NULL;
10179
+ v_you := NULL;
10180
+ IF v_inst.allowance_pct < 100 AND v_owner IS NOT NULL THEN
10181
+ v_ceiling := ${s}.cms_provider_ceiling(v_rule.limit_tokens, v_inst.allowance_pct);
10182
+ SELECT COALESCE(cu.used_tokens, 0) INTO v_you
10183
+ FROM ${s}.provider_quota_counters_user cu
10184
+ WHERE cu.rule_id = v_rule.rule_id AND cu.user_id = v_owner
10185
+ AND cu.window_key_utc = v_bounds.window_key;
10186
+ v_you := COALESCE(v_you, 0);
10187
+ END IF;
10188
+
10189
+ v_rules := v_rules || jsonb_build_object(
10190
+ 'ruleId', v_rule.rule_id,
10191
+ 'providerName', v_inst.name,
10192
+ 'period', v_rule.period,
10193
+ 'modelQualified', v_rule.model_qualified,
10194
+ 'limitTokens', v_rule.limit_tokens,
10195
+ 'usedTokens', v_used,
10196
+ 'ceilingTokens', v_ceiling,
10197
+ 'yourUsedTokens', v_you,
10198
+ 'windowStartUtc', to_char(v_bounds.window_start AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
10199
+ 'resetsAtUtc', to_char(v_bounds.resets_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'));
10200
+
10201
+ IF v_used >= v_rule.limit_tokens THEN
10202
+ -- A limit stops everyone. It outranks an allowance in the
10203
+ -- report because raising the allowance would not help.
10204
+ IF v_kind IS DISTINCT FROM 'limit' THEN
10205
+ v_kind := 'limit';
10206
+ v_block := jsonb_build_object('ruleId', v_rule.rule_id, 'period', v_rule.period,
10207
+ 'modelQualified', v_rule.model_qualified,
10208
+ 'limitTokens', v_rule.limit_tokens, 'usedTokens', v_used);
10209
+ END IF;
10210
+ IF v_reset IS NULL OR v_bounds.resets_at > v_reset THEN v_reset := v_bounds.resets_at; END IF;
10211
+ ELSIF v_ceiling IS NOT NULL AND v_you >= v_ceiling THEN
10212
+ IF v_kind IS NULL THEN
10213
+ v_kind := 'allowance';
10214
+ v_block := jsonb_build_object('ruleId', v_rule.rule_id, 'period', v_rule.period,
10215
+ 'modelQualified', v_rule.model_qualified,
10216
+ 'limitTokens', v_rule.limit_tokens,
10217
+ 'ceilingTokens', v_ceiling, 'yourUsedTokens', v_you);
10218
+ END IF;
10219
+ IF v_reset IS NULL OR v_bounds.resets_at > v_reset THEN v_reset := v_bounds.resets_at; END IF;
10220
+ END IF;
10221
+ END LOOP;
10222
+
10223
+ IF v_kind IS NOT NULL THEN
10224
+ -- The LATEST reset among blocking rules: a session blocked by a
10225
+ -- daily and a monthly does not resume when the daily turns over.
10226
+ v_pause := v_block
10227
+ || jsonb_build_object('kind', v_kind, 'provider', v_inst.name,
10228
+ 'resetsAtUtc', to_char(v_reset AT TIME ZONE 'UTC',
10229
+ 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'));
10230
+ UPDATE ${s}.sessions ss SET pause_state = v_pause
10231
+ WHERE ss.session_id = p_session_id
10232
+ AND ss.pause_state IS DISTINCT FROM v_pause;
10233
+ RETURN QUERY SELECT 'paused'::TEXT, v_inst.name, v_ref, FALSE, v_pause, v_rules;
10234
+ RETURN;
10235
+ END IF;
10236
+
10237
+ IF v_sess.pause_state IS NOT NULL THEN
10238
+ UPDATE ${s}.sessions ss SET pause_state = NULL WHERE ss.session_id = p_session_id;
10239
+ END IF;
10240
+ RETURN QUERY SELECT 'clear'::TEXT, v_inst.name, v_ref, FALSE, NULL::JSONB, v_rules;
10241
+ END;
10242
+ $$ LANGUAGE plpgsql VOLATILE;
10243
+
10244
+ -- ── settlement ───────────────────────────────────────────────────────
10245
+ -- Exactly once. The ledger's (session_id, turn_index) primary key IS the
10246
+ -- claim: a second call for the same turn inserts nothing, moves no
10247
+ -- counter, and returns false. Everything downstream of accounting depends
10248
+ -- on this one ON CONFLICT.
10249
+
10250
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_settle_turn(
10251
+ p_session_id TEXT, p_turn_index INTEGER, p_provider TEXT, p_model TEXT,
10252
+ p_owner BIGINT, p_charge_class TEXT, p_agent_id TEXT,
10253
+ p_in BIGINT, p_out BIGINT, p_cache_read BIGINT, p_cache_write BIGINT
10254
+ ) RETURNS BOOLEAN AS $$
10255
+ DECLARE
10256
+ v_total BIGINT := COALESCE(p_in,0) + COALESCE(p_out,0)
10257
+ + COALESCE(p_cache_read,0) + COALESCE(p_cache_write,0);
10258
+ v_class TEXT := COALESCE(NULLIF(BTRIM(COALESCE(p_charge_class,'')), ''), 'user');
10259
+ v_rule RECORD;
10260
+ v_bounds RECORD;
10261
+ v_first INTEGER;
10262
+ BEGIN
10263
+ IF p_provider IS NULL THEN v_class := 'unattributed'; END IF;
10264
+
10265
+ INSERT INTO ${s}.provider_usage_ledger
10266
+ (session_id, turn_index, provider_name, model_qualified, owner_user_id,
10267
+ charge_class, tokens_input, tokens_output, tokens_cache_read,
10268
+ tokens_cache_write, tokens_total, agent_id)
10269
+ VALUES (p_session_id, p_turn_index, p_provider, p_model, p_owner,
10270
+ v_class, COALESCE(p_in,0), COALESCE(p_out,0), COALESCE(p_cache_read,0),
10271
+ COALESCE(p_cache_write,0), v_total, p_agent_id)
10272
+ ON CONFLICT (session_id, turn_index) DO NOTHING;
10273
+ GET DIAGNOSTICS v_first = ROW_COUNT;
10274
+ IF v_first = 0 THEN RETURN FALSE; END IF;
10275
+
10276
+ -- System spend is recorded and shown, but never consumes a budget that
10277
+ -- people plan around. Unattributed spend has no provider to consume.
10278
+ IF v_class <> 'user' OR p_provider IS NULL OR v_total <= 0 THEN
10279
+ RETURN TRUE;
10280
+ END IF;
10281
+
10282
+ -- FOR UPDATE: an administrator removing this limit at the same instant
10283
+ -- would otherwise let the loop reach an INSERT whose foreign key has
10284
+ -- just gone, and settlement would fail on a turn that was already paid
10285
+ -- for. Locking the rule makes the delete wait; the counters cascade away
10286
+ -- immediately afterwards, which is the right answer either way.
10287
+ FOR v_rule IN
10288
+ SELECT r.* FROM ${s}.provider_budget_rules r
10289
+ WHERE r.provider_name = p_provider
10290
+ AND (r.model_qualified IS NULL OR r.model_qualified = p_model)
10291
+ FOR UPDATE
10292
+ LOOP
10293
+ SELECT * INTO v_bounds FROM ${s}.cms_provider_window_bounds(v_rule.period, now());
10294
+ INSERT INTO ${s}.provider_quota_counters
10295
+ (rule_id, window_key_utc, used_tokens, window_start_utc, resets_at_utc)
10296
+ VALUES (v_rule.rule_id, v_bounds.window_key, v_total, v_bounds.window_start, v_bounds.resets_at)
10297
+ ON CONFLICT (rule_id, window_key_utc) DO UPDATE
10298
+ SET used_tokens = ${s}.provider_quota_counters.used_tokens + EXCLUDED.used_tokens,
10299
+ updated_at = now();
10300
+
10301
+ -- Kept for EVERY rule, not only those under a reduced allowance, so
10302
+ -- lowering an allowance never has to scan the ledger to find out
10303
+ -- what each person had already spent this window.
10304
+ IF p_owner IS NOT NULL THEN
10305
+ INSERT INTO ${s}.provider_quota_counters_user
10306
+ (rule_id, user_id, window_key_utc, used_tokens, window_start_utc, resets_at_utc)
10307
+ VALUES (v_rule.rule_id, p_owner, v_bounds.window_key, v_total,
10308
+ v_bounds.window_start, v_bounds.resets_at)
10309
+ ON CONFLICT (rule_id, user_id, window_key_utc) DO UPDATE
10310
+ SET used_tokens = ${s}.provider_quota_counters_user.used_tokens + EXCLUDED.used_tokens,
10311
+ updated_at = now();
10312
+ END IF;
10313
+ END LOOP;
10314
+ RETURN TRUE;
10315
+ END;
10316
+ $$ LANGUAGE plpgsql VOLATILE;
10317
+
10318
+ -- ── readers ──────────────────────────────────────────────────────────
10319
+
10320
+ -- The caller's namespace, plus (for an administrator) everyone else's
10321
+ -- personal providers, flagged. Two different questions — "what may I run?"
10322
+ -- and "what exists?" — answered by one row set with usable_by_me.
10323
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_list(
10324
+ p_viewer BIGINT, p_is_admin BOOLEAN
10325
+ ) RETURNS TABLE(
10326
+ name TEXT, type_id TEXT, class TEXT, owner_user_id BIGINT, owner_email TEXT,
10327
+ owner_display_name TEXT, base_url TEXT, allowance_pct SMALLINT,
10328
+ hold_until_utc TIMESTAMPTZ, hold_indefinite BOOLEAN, has_credential BOOLEAN,
10329
+ usable_by_me BOOLEAN, is_cluster_default BOOLEAN, is_my_default BOOLEAN,
10330
+ rule_count BIGINT, created_at TIMESTAMPTZ
10331
+ ) AS $$
10332
+ SELECT pi.name, pi.type_id, pi.class, pi.owner_user_id, ou.email, ou.display_name,
10333
+ pi.base_url, pi.allowance_pct, pi.hold_until_utc, pi.hold_indefinite,
10334
+ (pi.secret_ref IS NOT NULL AND pi.secret_ref <> '{}'::jsonb),
10335
+ (pi.class = 'shared' OR pi.owner_user_id = p_viewer),
10336
+ (cs.default_provider = pi.name),
10337
+ (vu.default_provider = pi.name),
10338
+ (SELECT count(*) FROM ${s}.provider_budget_rules r WHERE r.provider_name = pi.name),
10339
+ pi.created_at
10340
+ FROM ${s}.provider_instances pi
10341
+ LEFT JOIN ${s}.users ou ON ou.user_id = pi.owner_user_id
10342
+ LEFT JOIN ${s}.users vu ON vu.user_id = p_viewer
10343
+ CROSS JOIN ${s}.provider_cluster_settings cs
10344
+ WHERE cs.singleton
10345
+ AND (pi.class = 'shared'
10346
+ OR pi.owner_user_id = p_viewer
10347
+ OR COALESCE(p_is_admin, FALSE))
10348
+ ORDER BY (pi.class = 'shared') DESC, pi.name;
10349
+ $$ LANGUAGE sql STABLE;
10350
+
10351
+ -- Per-provider budget state: the limits, what the provider has spent
10352
+ -- against them, and the viewer's own usage and ceiling where an allowance
10353
+ -- applies. Shared totals are open to everyone who may spend — a limit
10354
+ -- people cannot measure themselves against is not one they can plan for.
10355
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_status(
10356
+ p_viewer BIGINT, p_is_admin BOOLEAN, p_names TEXT[]
10357
+ ) RETURNS TABLE(
10358
+ name TEXT, class TEXT, allowance_pct SMALLINT, hold_until_utc TIMESTAMPTZ,
10359
+ hold_indefinite BOOLEAN, rules JSONB
10360
+ ) AS $$
10361
+ SELECT pi.name, pi.class, pi.allowance_pct, pi.hold_until_utc, pi.hold_indefinite,
10362
+ COALESCE((
10363
+ SELECT jsonb_agg(jsonb_build_object(
10364
+ 'ruleId', r.rule_id,
10365
+ 'period', r.period,
10366
+ 'modelQualified', r.model_qualified,
10367
+ 'limitTokens', r.limit_tokens,
10368
+ 'usedTokens', COALESCE(c.used_tokens, 0),
10369
+ 'ceilingTokens', CASE WHEN pi.allowance_pct < 100
10370
+ THEN ${s}.cms_provider_ceiling(r.limit_tokens, pi.allowance_pct) END,
10371
+ 'yourUsedTokens', CASE WHEN p_viewer IS NOT NULL
10372
+ THEN COALESCE(cu.used_tokens, 0) END,
10373
+ 'windowStartUtc', to_char(wb.window_start AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
10374
+ 'resetsAtUtc', to_char(wb.resets_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'))
10375
+ ORDER BY r.period, COALESCE(r.model_qualified, ''))
10376
+ FROM ${s}.provider_budget_rules r
10377
+ CROSS JOIN LATERAL ${s}.cms_provider_window_bounds(r.period, now()) wb
10378
+ LEFT JOIN ${s}.provider_quota_counters c
10379
+ ON c.rule_id = r.rule_id AND c.window_key_utc = wb.window_key
10380
+ LEFT JOIN ${s}.provider_quota_counters_user cu
10381
+ ON cu.rule_id = r.rule_id AND cu.window_key_utc = wb.window_key
10382
+ AND cu.user_id = p_viewer
10383
+ WHERE r.provider_name = pi.name
10384
+ ), '[]'::jsonb)
10385
+ FROM ${s}.provider_instances pi
10386
+ WHERE (pi.class = 'shared' OR pi.owner_user_id = p_viewer OR COALESCE(p_is_admin, FALSE))
10387
+ AND (p_names IS NULL OR pi.name = ANY(p_names))
10388
+ ORDER BY (pi.class = 'shared') DESC, pi.name;
10389
+ $$ LANGUAGE sql STABLE;
10390
+
10391
+ -- Sessions waiting on a budget, read from the structured record the gate
10392
+ -- wrote. Row-scoped: an administrator sees the fleet, everyone else sees
10393
+ -- the sessions they own — including, deliberately, their own paused ones.
10394
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_list_paused(
10395
+ p_viewer BIGINT, p_is_admin BOOLEAN, p_limit INTEGER
10396
+ ) RETURNS TABLE(
10397
+ session_id TEXT, title TEXT, model TEXT, owner_user_id BIGINT,
10398
+ owner_email TEXT, state TEXT, pause_state JSONB, updated_at TIMESTAMPTZ
10399
+ ) AS $$
10400
+ SELECT ss.session_id, ss.title, ss.model, so.user_id, ou.email, ss.state,
10401
+ ss.pause_state, ss.updated_at
10402
+ FROM ${s}.sessions ss
10403
+ LEFT JOIN ${s}.session_owners so ON so.session_id = ss.session_id
10404
+ LEFT JOIN ${s}.users ou ON ou.user_id = so.user_id
10405
+ WHERE ss.deleted_at IS NULL
10406
+ AND ss.pause_state IS NOT NULL
10407
+ -- A session that ENDED while paused is not waiting for anything. Its
10408
+ -- pause record is history, and leaving it in this list kept finished
10409
+ -- work in "Paused now" for ever.
10410
+ AND ss.state NOT IN ('completed', 'failed', 'error', 'cancelled')
10411
+ AND (COALESCE(p_is_admin, FALSE) OR so.user_id = p_viewer)
10412
+ ORDER BY ss.updated_at DESC
10413
+ LIMIT COALESCE(p_limit, 100);
10414
+ $$ LANGUAGE sql STABLE;
10415
+
10416
+ -- ── usage reporting ──────────────────────────────────────────────────
10417
+ -- One filter shape, three questions. A non-administrator is clamped to
10418
+ -- their own rows: attribution is the one thing this design keeps private.
10419
+
10420
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_usage_totals(
10421
+ p_viewer BIGINT, p_is_admin BOOLEAN, p_days INTEGER,
10422
+ p_owner BIGINT, p_provider TEXT, p_model TEXT, p_session TEXT, p_class TEXT
10423
+ ) RETURNS TABLE(tokens_total BIGINT, turns BIGINT, sessions BIGINT) AS $$
10424
+ SELECT COALESCE(sum(l.tokens_total), 0), count(*)::BIGINT,
10425
+ count(DISTINCT l.session_id)::BIGINT
10426
+ FROM ${s}.provider_usage_ledger l
10427
+ WHERE l.created_at >= now() - (COALESCE(p_days, 7) || ' days')::interval
10428
+ AND (COALESCE(p_is_admin, FALSE)
10429
+ OR l.owner_user_id IS NOT DISTINCT FROM p_viewer
10430
+ -- A named SHARED provider's TOTAL is open to everyone who may
10431
+ -- spend from it: a limit people cannot measure themselves
10432
+ -- against is not a limit they can plan around.
10433
+ --
10434
+ -- Only the total. The moment an attribution filter is present
10435
+ -- the question stops being "what has this provider spent" and
10436
+ -- becomes "what did THAT PERSON spend on it" — which is
10437
+ -- admin-only, and which this disjunct happily answered until
10438
+ -- the two were separated: naming a shared provider opened every
10439
+ -- row, and p_owner then narrowed the open set to one person.
10440
+ -- User ids are small integers, so the whole cluster was
10441
+ -- enumerable in a loop.
10442
+ OR (p_provider IS NOT NULL AND p_owner IS NULL AND p_session IS NULL
10443
+ AND EXISTS (
10444
+ SELECT 1 FROM ${s}.provider_instances pi
10445
+ WHERE pi.name = p_provider AND pi.class = 'shared')))
10446
+ AND (p_owner IS NULL OR l.owner_user_id = p_owner)
10447
+ AND (p_provider IS NULL OR l.provider_name = p_provider)
10448
+ AND (p_model IS NULL OR l.model_qualified = p_model)
10449
+ AND (p_session IS NULL OR l.session_id = p_session)
10450
+ AND (p_class IS NULL OR l.charge_class = p_class);
10451
+ $$ LANGUAGE sql STABLE;
10452
+
10453
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_usage_daily(
10454
+ p_viewer BIGINT, p_is_admin BOOLEAN, p_days INTEGER,
10455
+ p_owner BIGINT, p_provider TEXT, p_model TEXT, p_session TEXT, p_class TEXT
10456
+ ) RETURNS TABLE(day_utc DATE, tokens_total BIGINT, turns BIGINT) AS $$
10457
+ SELECT (l.created_at AT TIME ZONE 'UTC')::date, COALESCE(sum(l.tokens_total), 0), count(*)::BIGINT
10458
+ FROM ${s}.provider_usage_ledger l
10459
+ WHERE l.created_at >= now() - (COALESCE(p_days, 7) || ' days')::interval
10460
+ AND (COALESCE(p_is_admin, FALSE)
10461
+ OR l.owner_user_id IS NOT DISTINCT FROM p_viewer
10462
+ -- A named SHARED provider's TOTAL is open to everyone who may
10463
+ -- spend from it: a limit people cannot measure themselves
10464
+ -- against is not a limit they can plan around.
10465
+ --
10466
+ -- Only the total. The moment an attribution filter is present
10467
+ -- the question stops being "what has this provider spent" and
10468
+ -- becomes "what did THAT PERSON spend on it" — which is
10469
+ -- admin-only, and which this disjunct happily answered until
10470
+ -- the two were separated: naming a shared provider opened every
10471
+ -- row, and p_owner then narrowed the open set to one person.
10472
+ -- User ids are small integers, so the whole cluster was
10473
+ -- enumerable in a loop.
10474
+ OR (p_provider IS NOT NULL AND p_owner IS NULL AND p_session IS NULL
10475
+ AND EXISTS (
10476
+ SELECT 1 FROM ${s}.provider_instances pi
10477
+ WHERE pi.name = p_provider AND pi.class = 'shared')))
10478
+ AND (p_owner IS NULL OR l.owner_user_id = p_owner)
10479
+ AND (p_provider IS NULL OR l.provider_name = p_provider)
10480
+ AND (p_model IS NULL OR l.model_qualified = p_model)
10481
+ AND (p_session IS NULL OR l.session_id = p_session)
10482
+ AND (p_class IS NULL OR l.charge_class = p_class)
10483
+ GROUP BY 1 ORDER BY 1;
10484
+ $$ LANGUAGE sql STABLE;
10485
+
10486
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_usage_breakdown(
10487
+ p_viewer BIGINT, p_is_admin BOOLEAN, p_days INTEGER,
10488
+ p_owner BIGINT, p_provider TEXT, p_model TEXT, p_session TEXT, p_class TEXT,
10489
+ p_dim TEXT, p_limit INTEGER
10490
+ ) RETURNS TABLE(key TEXT, label TEXT, tokens_total BIGINT, turns BIGINT) AS $$
10491
+ WITH rows AS (
10492
+ SELECT l.*,
10493
+ CASE p_dim
10494
+ WHEN 'session' THEN l.session_id
10495
+ WHEN 'provider' THEN COALESCE(l.provider_name, '(none)')
10496
+ WHEN 'model' THEN COALESCE(l.model_qualified, '(none)')
10497
+ WHEN 'agent' THEN COALESCE(l.agent_id, '(none)')
10498
+ WHEN 'user' THEN COALESCE(l.owner_user_id::TEXT,
10499
+ CASE WHEN l.charge_class = 'system'
10500
+ THEN '(system)' ELSE '(unowned)' END)
10501
+ ELSE COALESCE(l.provider_name, '(none)')
10502
+ END AS dim_key
10503
+ FROM ${s}.provider_usage_ledger l
10504
+ WHERE l.created_at >= now() - (COALESCE(p_days, 7) || ' days')::interval
10505
+ AND (COALESCE(p_is_admin, FALSE) OR l.owner_user_id IS NOT DISTINCT FROM p_viewer)
10506
+ AND (p_owner IS NULL OR l.owner_user_id = p_owner)
10507
+ AND (p_provider IS NULL OR l.provider_name = p_provider)
10508
+ AND (p_model IS NULL OR l.model_qualified = p_model)
10509
+ AND (p_session IS NULL OR l.session_id = p_session)
10510
+ AND (p_class IS NULL OR l.charge_class = p_class)
10511
+ )
10512
+ SELECT g.dim_key,
10513
+ CASE p_dim
10514
+ WHEN 'session' THEN COALESCE((SELECT ss.title FROM ${s}.sessions ss
10515
+ WHERE ss.session_id = g.dim_key), g.dim_key)
10516
+ WHEN 'user' THEN COALESCE((SELECT COALESCE(uu.display_name, uu.email, uu.subject)
10517
+ FROM ${s}.users uu
10518
+ WHERE uu.user_id::TEXT = g.dim_key), g.dim_key)
10519
+ ELSE g.dim_key
10520
+ END,
10521
+ sum(g.tokens_total)::BIGINT, count(*)::BIGINT
10522
+ FROM rows g
10523
+ GROUP BY g.dim_key
10524
+ ORDER BY 3 DESC
10525
+ LIMIT COALESCE(p_limit, 40);
10526
+ $$ LANGUAGE sql STABLE;
10527
+
10528
+ -- The wake query: which sessions are waiting on THIS provider. Called
10529
+ -- whenever the thing that paused them stops being true — a limit raised or
10530
+ -- removed, an allowance raised, a hold released, or the provider's name
10531
+ -- created again after it went missing.
10532
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_paused_for(
10533
+ p_name TEXT
10534
+ ) RETURNS TABLE(session_id TEXT) AS $$
10535
+ SELECT ss.session_id FROM ${s}.sessions ss
10536
+ WHERE ss.deleted_at IS NULL
10537
+ AND ss.pause_state IS NOT NULL
10538
+ AND ss.state NOT IN ('completed', 'failed', 'error', 'cancelled')
10539
+ AND ss.pause_state->>'provider' = p_name;
10540
+ $$ LANGUAGE sql STABLE;
10541
+ `;
10542
+ }
10543
+ /**
10544
+ * 0053 — a meter is not a limit.
10545
+ *
10546
+ * A usage counter used to exist only because a limit existed: its key was the
10547
+ * budget rule's id. A period with no limit therefore had no counter and no
10548
+ * number, and saving a limit had to rebuild the window's spend from the ledger
10549
+ * before it could enforce anything.
10550
+ *
10551
+ * The counter is now keyed by what it measures:
10552
+ *
10553
+ * provider_meters (provider_name, period, scope, window_key_utc)
10554
+ * provider_meters_user (provider_name, period, scope, window_key_utc, user_id)
10555
+ *
10556
+ * period day | week | month
10557
+ * scope a qualified model reference, or '*' for all models
10558
+ *
10559
+ * Every settled turn moves twelve rows — three periods x (all models, the
10560
+ * model that ran), for the provider's total and for the person who ran it —
10561
+ * whether or not a limit exists. Two things fall out of that:
10562
+ *
10563
+ * 1. NOTHING SEEDS. cms_provider_set_limit saves a limit and stops. The meter
10564
+ * the limit reads has been running since the provider's first turn.
10565
+ *
10566
+ * 2. THE LOST-UPDATE RACE IS GONE BY CONSTRUCTION. Seeding was a read and
10567
+ * then an assign, so a turn that settled between the two had its charge
10568
+ * overwritten — a hard limit stopped enforcing and the two counters
10569
+ * disagreed for the rest of the window. Every write to a meter is now an
10570
+ * increment, and there is no read-then-assign left to lose one.
10571
+ *
10572
+ * WINDOWS ARE NOW FIXED BY STRUCTURE. Day is 00:00 UTC, week is Monday 00:00
10573
+ * UTC, month is the 1st. That was already true — 0049 removed per-rule
10574
+ * anchors — but one meter key per (provider, period) makes it permanent:
10575
+ * every rule of a period on a provider reads the same row, so they must agree
10576
+ * on the window. If a billing cycle starting on the 15th is ever needed, the
10577
+ * anchor belongs on the PROVIDER, so all its month limits share one boundary.
10578
+ *
10579
+ * provider_quota_counters and provider_quota_counters_user are dropped.
10580
+ * Nothing is deployed, so there is no compatibility shape to keep.
10581
+ *
10582
+ * ORDER INSIDE THIS MIGRATION MATTERS: the new tables, then every procedure
10583
+ * that named the old ones, then the drop. A procedure still pointing at a
10584
+ * table being dropped is the one way this can fail halfway.
10585
+ */
10586
+ function migration_0053_provider_meters(schema) {
10587
+ const s = `"${schema}"`;
10588
+ return `
10589
+ -- ── the meters ───────────────────────────────────────────────────────
10590
+
10591
+ -- provider_name REFERENCES provider_instances so deleting a provider takes
10592
+ -- its meters with it, and a name created again starts from zero. That is the
10593
+ -- same rule the ledger already follows by holding a name rather than a
10594
+ -- reference: the history stays, the running total does not.
10595
+ CREATE TABLE IF NOT EXISTS ${s}.provider_meters (
10596
+ provider_name TEXT NOT NULL REFERENCES ${s}.provider_instances(name) ON DELETE CASCADE,
10597
+ period TEXT NOT NULL CHECK (period IN ('day','week','month')),
10598
+ scope TEXT NOT NULL,
10599
+ window_key_utc TEXT NOT NULL,
10600
+ used_tokens BIGINT NOT NULL DEFAULT 0,
10601
+ window_start_utc TIMESTAMPTZ NOT NULL,
10602
+ resets_at_utc TIMESTAMPTZ NOT NULL,
10603
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
10604
+ PRIMARY KEY (provider_name, period, scope, window_key_utc)
10605
+ );
10606
+
10607
+ -- The per-person mirror, kept for EVERY turn rather than only where an
10608
+ -- allowance is reduced, so lowering an allowance never has to scan the ledger
10609
+ -- to discover what each person had already spent this window.
10610
+ CREATE TABLE IF NOT EXISTS ${s}.provider_meters_user (
10611
+ provider_name TEXT NOT NULL REFERENCES ${s}.provider_instances(name) ON DELETE CASCADE,
10612
+ period TEXT NOT NULL CHECK (period IN ('day','week','month')),
10613
+ scope TEXT NOT NULL,
10614
+ window_key_utc TEXT NOT NULL,
10615
+ user_id BIGINT NOT NULL,
10616
+ used_tokens BIGINT NOT NULL DEFAULT 0,
10617
+ window_start_utc TIMESTAMPTZ NOT NULL,
10618
+ resets_at_utc TIMESTAMPTZ NOT NULL,
10619
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
10620
+ PRIMARY KEY (provider_name, period, scope, window_key_utc, user_id)
10621
+ );
10622
+
10623
+ -- ── settlement ───────────────────────────────────────────────────────
10624
+ -- Exactly once, unchanged: the ledger's (session_id, turn_index) primary key
10625
+ -- IS the claim. A second call for the same turn inserts nothing, moves no
10626
+ -- meter, and returns false. What changed below the claim is only WHICH rows
10627
+ -- move — meters keyed by what they measure, not by a rule that may not exist.
10628
+
10629
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_settle_turn(
10630
+ p_session_id TEXT, p_turn_index INTEGER, p_provider TEXT, p_model TEXT,
10631
+ p_owner BIGINT, p_charge_class TEXT, p_agent_id TEXT,
10632
+ p_in BIGINT, p_out BIGINT, p_cache_read BIGINT, p_cache_write BIGINT
10633
+ ) RETURNS BOOLEAN AS $$
10634
+ DECLARE
10635
+ v_total BIGINT := COALESCE(p_in,0) + COALESCE(p_out,0)
10636
+ + COALESCE(p_cache_read,0) + COALESCE(p_cache_write,0);
10637
+ v_class TEXT := COALESCE(NULLIF(BTRIM(COALESCE(p_charge_class,'')), ''), 'user');
10638
+ v_scope TEXT := COALESCE(NULLIF(BTRIM(COALESCE(p_model,'')), ''), '*');
10639
+ v_first INTEGER;
10640
+ BEGIN
10641
+ IF p_provider IS NULL THEN v_class := 'unattributed'; END IF;
10642
+
10643
+ INSERT INTO ${s}.provider_usage_ledger
10644
+ (session_id, turn_index, provider_name, model_qualified, owner_user_id,
10645
+ charge_class, tokens_input, tokens_output, tokens_cache_read,
10646
+ tokens_cache_write, tokens_total, agent_id)
10647
+ VALUES (p_session_id, p_turn_index, p_provider, p_model, p_owner,
10648
+ v_class, COALESCE(p_in,0), COALESCE(p_out,0), COALESCE(p_cache_read,0),
10649
+ COALESCE(p_cache_write,0), v_total, p_agent_id)
10650
+ ON CONFLICT (session_id, turn_index) DO NOTHING;
10651
+ GET DIAGNOSTICS v_first = ROW_COUNT;
10652
+ IF v_first = 0 THEN RETURN FALSE; END IF;
10653
+
10654
+ -- System spend is recorded and shown, but never consumes a budget that
10655
+ -- people plan around. Unattributed spend has no provider to consume.
10656
+ IF v_class <> 'user' OR p_provider IS NULL OR v_total <= 0 THEN
10657
+ RETURN TRUE;
10658
+ END IF;
10659
+
10660
+ -- A meter references its provider, so a name deleted while this turn was
10661
+ -- running has nothing left to move. Taking the parent row's key lock here
10662
+ -- makes a delete arriving NOW wait until this turn is counted, and a
10663
+ -- delete that already committed report itself as a missing row rather
10664
+ -- than as a foreign key violation on a turn that has already been paid
10665
+ -- for and written to the ledger.
10666
+ PERFORM 1 FROM ${s}.provider_instances pi
10667
+ WHERE pi.name = p_provider FOR KEY SHARE;
10668
+ IF NOT FOUND THEN RETURN TRUE; END IF;
10669
+
10670
+ -- Three periods x (all models, the model that ran). A turn that named no
10671
+ -- model has one scope, not two, so it writes three rows rather than six.
10672
+ --
10673
+ -- ORDER BY is not cosmetic: a multi-row upsert takes its row locks in the
10674
+ -- order the rows arrive, and two settles on one provider touch the same
10675
+ -- six keys. A fixed order is what stops them deadlocking each other.
10676
+ INSERT INTO ${s}.provider_meters
10677
+ (provider_name, period, scope, window_key_utc, used_tokens,
10678
+ window_start_utc, resets_at_utc)
10679
+ SELECT p_provider, per.period, sc.scope, wb.window_key, v_total,
10680
+ wb.window_start, wb.resets_at
10681
+ FROM (VALUES ('day'),('week'),('month')) AS per(period)
10682
+ CROSS JOIN LATERAL ${s}.cms_provider_window_bounds(per.period, now()) wb
10683
+ CROSS JOIN (SELECT DISTINCT v.s FROM (VALUES ('*'), (v_scope)) AS v(s)) AS sc(scope)
10684
+ ORDER BY per.period, sc.scope
10685
+ ON CONFLICT (provider_name, period, scope, window_key_utc) DO UPDATE
10686
+ SET used_tokens = ${s}.provider_meters.used_tokens + EXCLUDED.used_tokens,
10687
+ updated_at = now();
10688
+
10689
+ IF p_owner IS NOT NULL THEN
10690
+ INSERT INTO ${s}.provider_meters_user
10691
+ (provider_name, period, scope, window_key_utc, user_id, used_tokens,
10692
+ window_start_utc, resets_at_utc)
10693
+ SELECT p_provider, per.period, sc.scope, wb.window_key, p_owner, v_total,
10694
+ wb.window_start, wb.resets_at
10695
+ FROM (VALUES ('day'),('week'),('month')) AS per(period)
10696
+ CROSS JOIN LATERAL ${s}.cms_provider_window_bounds(per.period, now()) wb
10697
+ CROSS JOIN (SELECT DISTINCT v.s FROM (VALUES ('*'), (v_scope)) AS v(s)) AS sc(scope)
10698
+ ORDER BY per.period, sc.scope
10699
+ ON CONFLICT (provider_name, period, scope, window_key_utc, user_id) DO UPDATE
10700
+ SET used_tokens = ${s}.provider_meters_user.used_tokens + EXCLUDED.used_tokens,
10701
+ updated_at = now();
10702
+ END IF;
10703
+
10704
+ RETURN TRUE;
10705
+ END;
10706
+ $$ LANGUAGE plpgsql VOLATILE;
10707
+
10708
+ -- ── saving a limit saves a limit ─────────────────────────────────────
10709
+
10710
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_set_limit(
10711
+ p_name TEXT, p_period TEXT, p_model TEXT, p_tokens BIGINT,
10712
+ p_rule_id TEXT, p_actor BIGINT, p_is_admin BOOLEAN
10713
+ ) RETURNS TABLE(rule_id TEXT, seeded_tokens BIGINT) AS $$
10714
+ -- The OUT parameter rule_id would otherwise shadow the COLUMN of the same
10715
+ -- name, and an ON CONFLICT target that resolves to a variable is rejected as
10716
+ -- ambiguous. Inside this body a bare column name means the column.
10717
+ #variable_conflict use_column
10718
+ DECLARE
10719
+ v_model TEXT := NULLIF(BTRIM(p_model), '');
10720
+ v_rule TEXT;
10721
+ v_bounds RECORD;
10722
+ v_used BIGINT;
10723
+ BEGIN
10724
+ PERFORM ${s}.cms_provider_assert_manage(p_name, p_actor, p_is_admin);
10725
+ IF p_period NOT IN ('day','week','month') THEN
10726
+ RAISE EXCEPTION 'PROVIDER_INVALID: period must be day, week or month';
10727
+ END IF;
10728
+ IF p_tokens IS NULL OR p_tokens <= 0 THEN
10729
+ RAISE EXCEPTION 'PROVIDER_INVALID: a limit must be a positive number of tokens';
10730
+ END IF;
10731
+ -- A limit scoped to one model matches the QUALIFIED reference a session
10732
+ -- runs, so a bare model name matches nothing — the limit saves, shows in
10733
+ -- the report as a live cap, and silently never fires. Refuse it and say
10734
+ -- what to write instead.
10735
+ IF v_model IS NOT NULL AND v_model NOT LIKE p_name || ':%' THEN
10736
+ RAISE EXCEPTION 'PROVIDER_INVALID: a limit on one model names it as "%:<model>", not "%"', p_name, v_model;
10737
+ END IF;
10738
+
10739
+ INSERT INTO ${s}.provider_budget_rules (rule_id, provider_name, period, model_qualified, limit_tokens)
10740
+ VALUES (p_rule_id, p_name, p_period, v_model, p_tokens)
10741
+ ON CONFLICT (provider_name, period, COALESCE(model_qualified, '*'))
10742
+ DO UPDATE SET limit_tokens = EXCLUDED.limit_tokens, updated_at = now()
10743
+ RETURNING ${s}.provider_budget_rules.rule_id INTO v_rule;
10744
+
10745
+ -- Nothing is seeded and nothing is reset. The meter for this period and
10746
+ -- scope has been counting since the provider's first turn, so the limit
10747
+ -- simply starts being compared against a number that already exists.
10748
+ --
10749
+ -- What comes back is what this limit ALREADY counts, so the editor can
10750
+ -- say "sessions pause on their next turn" before the save rather than
10751
+ -- after it. It is a read: no write derives a counter from the ledger any
10752
+ -- more, which is what the settle-during-save race used to overwrite.
10753
+ SELECT * INTO v_bounds FROM ${s}.cms_provider_window_bounds(p_period, now());
10754
+ SELECT COALESCE(m.used_tokens, 0) INTO v_used
10755
+ FROM ${s}.provider_meters m
10756
+ WHERE m.provider_name = p_name
10757
+ AND m.period = p_period
10758
+ AND m.scope = COALESCE(v_model, '*')
10759
+ AND m.window_key_utc = v_bounds.window_key;
10760
+
10761
+ RETURN QUERY SELECT v_rule, COALESCE(v_used, 0);
10762
+ END;
10763
+ $$ LANGUAGE plpgsql VOLATILE;
10764
+
10765
+ -- ── admission, re-pointed ────────────────────────────────────────────
10766
+ -- Every verdict below is the one 0051 gave. The only change is where the
10767
+ -- number comes from: the meter at (provider, period, scope, window) instead
10768
+ -- of a counter keyed by the rule's id.
10769
+
10770
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_check_turn(
10771
+ p_session_id TEXT, p_model TEXT
10772
+ ) RETURNS TABLE(
10773
+ verdict TEXT, provider_name TEXT, model_qualified TEXT,
10774
+ exempt BOOLEAN, pause JSONB, rules JSONB
10775
+ ) AS $$
10776
+ DECLARE
10777
+ v_sess RECORD;
10778
+ v_owner BIGINT;
10779
+ v_ref TEXT;
10780
+ v_split RECORD;
10781
+ v_inst RECORD;
10782
+ v_rule RECORD;
10783
+ v_bounds RECORD;
10784
+ v_scope TEXT;
10785
+ v_used BIGINT;
10786
+ v_you BIGINT;
10787
+ v_ceiling BIGINT;
10788
+ v_rules JSONB := '[]'::jsonb;
10789
+ v_block JSONB := NULL;
10790
+ v_kind TEXT := NULL;
10791
+ v_reset TIMESTAMPTZ := NULL;
10792
+ v_pause JSONB := NULL;
10793
+ BEGIN
10794
+ SELECT ss.is_system, ss.model, ss.pause_state INTO v_sess
10795
+ FROM ${s}.sessions ss WHERE ss.session_id = p_session_id;
10796
+ IF NOT FOUND THEN
10797
+ -- Nothing to admit and nothing to charge. The gate's posture is
10798
+ -- fail-open, so say clear rather than inventing a refusal.
10799
+ RETURN QUERY SELECT 'clear'::TEXT, NULL::TEXT, NULL::TEXT, FALSE, NULL::JSONB, '[]'::jsonb;
10800
+ RETURN;
10801
+ END IF;
10802
+
10803
+ SELECT so.user_id INTO v_owner FROM ${s}.session_owners so
10804
+ WHERE so.session_id = p_session_id;
10805
+
10806
+ -- The catalog row is authoritative for the model, exactly as the turn
10807
+ -- runtime treats it; the argument is only the turn's own override.
10808
+ v_ref := COALESCE(NULLIF(BTRIM(COALESCE(p_model, '')), ''), v_sess.model);
10809
+ SELECT * INTO v_split FROM ${s}.cms_provider_split_ref(v_ref);
10810
+
10811
+ -- Assigned unconditionally. A RECORD that was never assigned raises
10812
+ -- "record is not assigned yet" the moment a field is read, and the read
10813
+ -- below sits behind an OR whose short-circuiting Postgres does not
10814
+ -- promise — so an unqualified model reference could throw instead of
10815
+ -- answering, and the gate's fail-open posture would run the turn.
10816
+ SELECT * INTO v_inst FROM ${s}.cms_provider_in_namespace(
10817
+ COALESCE(v_split.provider_name, ''), v_owner);
10818
+
10819
+ IF v_inst.name IS NULL THEN
10820
+ v_pause := jsonb_build_object(
10821
+ 'kind', 'no_provider',
10822
+ 'provider', v_split.provider_name,
10823
+ 'modelRef', v_ref);
10824
+ UPDATE ${s}.sessions ss SET pause_state = v_pause
10825
+ WHERE ss.session_id = p_session_id
10826
+ AND ss.pause_state IS DISTINCT FROM v_pause;
10827
+ RETURN QUERY SELECT 'no_provider'::TEXT, v_split.provider_name, v_ref, FALSE, v_pause, '[]'::jsonb;
10828
+ RETURN;
10829
+ END IF;
10830
+
10831
+ -- System sessions are never paused: a limit that could stop the
10832
+ -- machinery would leave nobody able to raise the limit that stopped it.
10833
+ -- They still resolve a provider, because their spend is still recorded.
10834
+ IF COALESCE(v_sess.is_system, FALSE) THEN
10835
+ IF v_sess.pause_state IS NOT NULL THEN
10836
+ UPDATE ${s}.sessions ss SET pause_state = NULL WHERE ss.session_id = p_session_id;
10837
+ END IF;
10838
+ RETURN QUERY SELECT 'clear'::TEXT, v_inst.name, v_ref, TRUE, NULL::JSONB, '[]'::jsonb;
10839
+ RETURN;
10840
+ END IF;
10841
+
10842
+ IF COALESCE(v_inst.hold_indefinite, FALSE)
10843
+ OR (v_inst.hold_until_utc IS NOT NULL AND v_inst.hold_until_utc > now()) THEN
10844
+ v_pause := jsonb_build_object(
10845
+ 'kind', 'hold',
10846
+ 'provider', v_inst.name,
10847
+ 'resetsAtUtc', CASE WHEN COALESCE(v_inst.hold_indefinite, FALSE)
10848
+ THEN NULL ELSE to_char(v_inst.hold_until_utc AT TIME ZONE 'UTC',
10849
+ 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') END);
10850
+ UPDATE ${s}.sessions ss SET pause_state = v_pause
10851
+ WHERE ss.session_id = p_session_id
10852
+ AND ss.pause_state IS DISTINCT FROM v_pause;
10853
+ RETURN QUERY SELECT 'paused'::TEXT, v_inst.name, v_ref, FALSE, v_pause, '[]'::jsonb;
10854
+ RETURN;
10855
+ END IF;
10856
+
10857
+ FOR v_rule IN
10858
+ SELECT r.* FROM ${s}.provider_budget_rules r
10859
+ WHERE r.provider_name = v_inst.name
10860
+ AND (r.model_qualified IS NULL OR r.model_qualified = v_ref)
10861
+ LOOP
10862
+ SELECT * INTO v_bounds FROM ${s}.cms_provider_window_bounds(v_rule.period, now());
10863
+ v_scope := COALESCE(v_rule.model_qualified, '*');
10864
+
10865
+ SELECT COALESCE(m.used_tokens, 0) INTO v_used
10866
+ FROM ${s}.provider_meters m
10867
+ WHERE m.provider_name = v_inst.name AND m.period = v_rule.period
10868
+ AND m.scope = v_scope AND m.window_key_utc = v_bounds.window_key;
10869
+ v_used := COALESCE(v_used, 0);
10870
+
10871
+ v_ceiling := NULL;
10872
+ v_you := NULL;
10873
+ IF v_inst.allowance_pct < 100 AND v_owner IS NOT NULL THEN
10874
+ v_ceiling := ${s}.cms_provider_ceiling(v_rule.limit_tokens, v_inst.allowance_pct);
10875
+ SELECT COALESCE(mu.used_tokens, 0) INTO v_you
10876
+ FROM ${s}.provider_meters_user mu
10877
+ WHERE mu.provider_name = v_inst.name AND mu.period = v_rule.period
10878
+ AND mu.scope = v_scope AND mu.window_key_utc = v_bounds.window_key
10879
+ AND mu.user_id = v_owner;
10880
+ v_you := COALESCE(v_you, 0);
10881
+ END IF;
10882
+
10883
+ v_rules := v_rules || jsonb_build_object(
10884
+ 'ruleId', v_rule.rule_id,
10885
+ 'providerName', v_inst.name,
10886
+ 'period', v_rule.period,
10887
+ 'modelQualified', v_rule.model_qualified,
10888
+ 'limitTokens', v_rule.limit_tokens,
10889
+ 'usedTokens', v_used,
10890
+ 'ceilingTokens', v_ceiling,
10891
+ 'yourUsedTokens', v_you,
10892
+ 'windowStartUtc', to_char(v_bounds.window_start AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
10893
+ 'resetsAtUtc', to_char(v_bounds.resets_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'));
10894
+
10895
+ IF v_used >= v_rule.limit_tokens THEN
10896
+ -- A limit stops everyone. It outranks an allowance in the
10897
+ -- report because raising the allowance would not help.
10898
+ IF v_kind IS DISTINCT FROM 'limit' THEN
10899
+ v_kind := 'limit';
10900
+ v_block := jsonb_build_object('ruleId', v_rule.rule_id, 'period', v_rule.period,
10901
+ 'modelQualified', v_rule.model_qualified,
10902
+ 'limitTokens', v_rule.limit_tokens, 'usedTokens', v_used);
10903
+ END IF;
10904
+ IF v_reset IS NULL OR v_bounds.resets_at > v_reset THEN v_reset := v_bounds.resets_at; END IF;
10905
+ ELSIF v_ceiling IS NOT NULL AND v_you >= v_ceiling THEN
10906
+ IF v_kind IS NULL THEN
10907
+ v_kind := 'allowance';
10908
+ v_block := jsonb_build_object('ruleId', v_rule.rule_id, 'period', v_rule.period,
10909
+ 'modelQualified', v_rule.model_qualified,
10910
+ 'limitTokens', v_rule.limit_tokens,
10911
+ 'ceilingTokens', v_ceiling, 'yourUsedTokens', v_you);
10912
+ END IF;
10913
+ IF v_reset IS NULL OR v_bounds.resets_at > v_reset THEN v_reset := v_bounds.resets_at; END IF;
10914
+ END IF;
10915
+ END LOOP;
10916
+
10917
+ IF v_kind IS NOT NULL THEN
10918
+ -- The LATEST reset among blocking rules: a session blocked by a
10919
+ -- daily and a monthly does not resume when the daily turns over.
10920
+ v_pause := v_block
10921
+ || jsonb_build_object('kind', v_kind, 'provider', v_inst.name,
10922
+ 'resetsAtUtc', to_char(v_reset AT TIME ZONE 'UTC',
10923
+ 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'));
10924
+ UPDATE ${s}.sessions ss SET pause_state = v_pause
10925
+ WHERE ss.session_id = p_session_id
10926
+ AND ss.pause_state IS DISTINCT FROM v_pause;
10927
+ RETURN QUERY SELECT 'paused'::TEXT, v_inst.name, v_ref, FALSE, v_pause, v_rules;
10928
+ RETURN;
10929
+ END IF;
10930
+
10931
+ IF v_sess.pause_state IS NOT NULL THEN
10932
+ UPDATE ${s}.sessions ss SET pause_state = NULL WHERE ss.session_id = p_session_id;
10933
+ END IF;
10934
+ RETURN QUERY SELECT 'clear'::TEXT, v_inst.name, v_ref, FALSE, NULL::JSONB, v_rules;
10935
+ END;
10936
+ $$ LANGUAGE plpgsql VOLATILE;
10937
+
10938
+ -- ── the readers, re-pointed ──────────────────────────────────────────
10939
+
10940
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_status(
10941
+ p_viewer BIGINT, p_is_admin BOOLEAN, p_names TEXT[]
10942
+ ) RETURNS TABLE(
10943
+ name TEXT, class TEXT, allowance_pct SMALLINT, hold_until_utc TIMESTAMPTZ,
10944
+ hold_indefinite BOOLEAN, rules JSONB
10945
+ ) AS $$
10946
+ SELECT pi.name, pi.class, pi.allowance_pct, pi.hold_until_utc, pi.hold_indefinite,
10947
+ COALESCE((
10948
+ SELECT jsonb_agg(jsonb_build_object(
10949
+ 'ruleId', r.rule_id,
10950
+ 'period', r.period,
10951
+ 'modelQualified', r.model_qualified,
10952
+ 'limitTokens', r.limit_tokens,
10953
+ 'usedTokens', COALESCE(m.used_tokens, 0),
10954
+ 'ceilingTokens', CASE WHEN pi.allowance_pct < 100
10955
+ THEN ${s}.cms_provider_ceiling(r.limit_tokens, pi.allowance_pct) END,
10956
+ 'yourUsedTokens', CASE WHEN p_viewer IS NOT NULL
10957
+ THEN COALESCE(mu.used_tokens, 0) END,
10958
+ 'windowStartUtc', to_char(wb.window_start AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
10959
+ 'resetsAtUtc', to_char(wb.resets_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'))
10960
+ ORDER BY r.period, COALESCE(r.model_qualified, ''))
10961
+ FROM ${s}.provider_budget_rules r
10962
+ CROSS JOIN LATERAL ${s}.cms_provider_window_bounds(r.period, now()) wb
10963
+ LEFT JOIN ${s}.provider_meters m
10964
+ ON m.provider_name = r.provider_name AND m.period = r.period
10965
+ AND m.scope = COALESCE(r.model_qualified, '*')
10966
+ AND m.window_key_utc = wb.window_key
10967
+ LEFT JOIN ${s}.provider_meters_user mu
10968
+ ON mu.provider_name = r.provider_name AND mu.period = r.period
10969
+ AND mu.scope = COALESCE(r.model_qualified, '*')
10970
+ AND mu.window_key_utc = wb.window_key
10971
+ AND mu.user_id = p_viewer
10972
+ WHERE r.provider_name = pi.name
10973
+ ), '[]'::jsonb)
10974
+ FROM ${s}.provider_instances pi
10975
+ WHERE (pi.class = 'shared' OR pi.owner_user_id = p_viewer OR COALESCE(p_is_admin, FALSE))
10976
+ AND (p_names IS NULL OR pi.name = ANY(p_names))
10977
+ ORDER BY (pi.class = 'shared') DESC, pi.name;
10978
+ $$ LANGUAGE sql STABLE;
10979
+
10980
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_pause_is_live(
10981
+ p_pause JSONB, p_owner BIGINT
10982
+ ) RETURNS BOOLEAN AS $$
10983
+ DECLARE
10984
+ v_kind TEXT := p_pause->>'kind';
10985
+ v_name TEXT := p_pause->>'provider';
10986
+ v_inst RECORD;
10987
+ v_rule RECORD;
10988
+ v_bounds RECORD;
10989
+ v_scope TEXT;
10990
+ v_used BIGINT;
10991
+ v_you BIGINT;
10992
+ BEGIN
10993
+ IF p_pause IS NULL OR v_kind IS NULL THEN RETURN FALSE; END IF;
10994
+
10995
+ -- A name that does not resolve is exactly what this pause reports, so it
10996
+ -- is live while the name is still missing and dead the moment it is not.
10997
+ IF v_kind = 'no_provider' THEN
10998
+ RETURN NOT EXISTS (SELECT 1 FROM ${s}.cms_provider_in_namespace(COALESCE(v_name, ''), p_owner));
10999
+ END IF;
11000
+
11001
+ SELECT * INTO v_inst FROM ${s}.cms_provider_in_namespace(COALESCE(v_name, ''), p_owner);
11002
+ -- The provider went away under a session that was paused for some other
11003
+ -- reason. It is still stuck, so the record still stands.
11004
+ IF NOT FOUND THEN RETURN TRUE; END IF;
11005
+
11006
+ IF v_kind = 'hold' THEN
11007
+ RETURN COALESCE(v_inst.hold_indefinite, FALSE)
11008
+ OR (v_inst.hold_until_utc IS NOT NULL AND v_inst.hold_until_utc > now());
11009
+ END IF;
11010
+
11011
+ SELECT * INTO v_rule FROM ${s}.provider_budget_rules r
11012
+ WHERE r.rule_id = p_pause->>'ruleId';
11013
+ -- The limit that stopped it was removed.
11014
+ IF NOT FOUND THEN RETURN FALSE; END IF;
11015
+
11016
+ SELECT * INTO v_bounds FROM ${s}.cms_provider_window_bounds(v_rule.period, now());
11017
+ v_scope := COALESCE(v_rule.model_qualified, '*');
11018
+ SELECT COALESCE(m.used_tokens, 0) INTO v_used
11019
+ FROM ${s}.provider_meters m
11020
+ WHERE m.provider_name = v_rule.provider_name AND m.period = v_rule.period
11021
+ AND m.scope = v_scope AND m.window_key_utc = v_bounds.window_key;
11022
+ v_used := COALESCE(v_used, 0);
11023
+
11024
+ IF v_kind = 'limit' THEN
11025
+ RETURN v_used >= v_rule.limit_tokens;
11026
+ END IF;
11027
+
11028
+ IF v_kind = 'allowance' THEN
11029
+ IF v_inst.allowance_pct >= 100 OR p_owner IS NULL THEN RETURN FALSE; END IF;
11030
+ SELECT COALESCE(mu.used_tokens, 0) INTO v_you
11031
+ FROM ${s}.provider_meters_user mu
11032
+ WHERE mu.provider_name = v_rule.provider_name AND mu.period = v_rule.period
11033
+ AND mu.scope = v_scope AND mu.window_key_utc = v_bounds.window_key
11034
+ AND mu.user_id = p_owner;
11035
+ RETURN COALESCE(v_you, 0) >= ${s}.cms_provider_ceiling(v_rule.limit_tokens, v_inst.allowance_pct);
11036
+ END IF;
11037
+
11038
+ -- A kind this version does not know about is reported rather than hidden:
11039
+ -- silence would be the one failure mode worse than a stale record.
11040
+ RETURN TRUE;
11041
+ END;
11042
+ $$ LANGUAGE plpgsql STABLE;
11043
+
11044
+ -- ── the table, in one call ───────────────────────────────────────────
11045
+ --
11046
+ -- One row per line the screen draws: a provider, then one row for each model
11047
+ -- it has a limit on. Every row carries the same three cells — day, week,
11048
+ -- month — so a model limit reads exactly like the provider above it, which is
11049
+ -- what makes "the model is capped while the provider still has room" visible.
11050
+ --
11051
+ -- Each cell carries FOUR numbers, because the screen shows two of them at a
11052
+ -- time and must never mix them up:
11053
+ --
11054
+ -- usedTokens / quotaTokens what everyone spent, against the limit
11055
+ -- yourUsedTokens / yourQuotaTokens what you spent, against your share
11056
+ --
11057
+ -- A null quota is no limit for that period, and the used number beside it is
11058
+ -- still real: the meter runs whether or not anybody capped it. A null
11059
+ -- yourUsedTokens means nobody is signed in — which is not the same as zero,
11060
+ -- and the screen must not draw it as zero.
11061
+
11062
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_usage_grid(
11063
+ p_viewer BIGINT, p_is_admin BOOLEAN
11064
+ ) RETURNS TABLE(
11065
+ provider_name TEXT, row_kind TEXT, scope TEXT, class TEXT,
11066
+ allowance_pct SMALLINT, hold_until_utc TIMESTAMPTZ, hold_indefinite BOOLEAN,
11067
+ model_row_count INTEGER, periods JSONB
11068
+ ) AS $$
11069
+ WITH visible AS (
11070
+ SELECT pi.name, pi.class, pi.allowance_pct, pi.hold_until_utc, pi.hold_indefinite,
11071
+ (SELECT count(DISTINCT r.model_qualified)::INTEGER
11072
+ FROM ${s}.provider_budget_rules r
11073
+ WHERE r.provider_name = pi.name AND r.model_qualified IS NOT NULL) AS model_rows
11074
+ FROM ${s}.provider_instances pi
11075
+ WHERE pi.class = 'shared' OR pi.owner_user_id = p_viewer OR COALESCE(p_is_admin, FALSE)
11076
+ ),
11077
+ grid_rows AS (
11078
+ SELECT v.*, 'provider'::TEXT AS row_kind, '*'::TEXT AS scope FROM visible v
11079
+ UNION ALL
11080
+ SELECT v.*, 'model'::TEXT, mr.model_qualified
11081
+ FROM visible v
11082
+ JOIN (SELECT DISTINCT r.provider_name, r.model_qualified
11083
+ FROM ${s}.provider_budget_rules r
11084
+ WHERE r.model_qualified IS NOT NULL) mr ON mr.provider_name = v.name
11085
+ ),
11086
+ windows AS (
11087
+ SELECT per.period, wb.window_start, wb.resets_at, wb.window_key
11088
+ FROM (VALUES ('day'),('week'),('month')) AS per(period)
11089
+ CROSS JOIN LATERAL ${s}.cms_provider_window_bounds(per.period, now()) wb
11090
+ )
11091
+ SELECT g.name, g.row_kind, g.scope, g.class, g.allowance_pct,
11092
+ g.hold_until_utc, g.hold_indefinite,
11093
+ CASE WHEN g.row_kind = 'provider' THEN g.model_rows ELSE 0 END,
11094
+ jsonb_object_agg(w.period, jsonb_build_object(
11095
+ 'ruleId', r.rule_id,
11096
+ 'quotaTokens', r.limit_tokens,
11097
+ 'usedTokens', COALESCE(m.used_tokens, 0),
11098
+ 'yourQuotaTokens', CASE
11099
+ WHEN r.limit_tokens IS NULL OR p_viewer IS NULL THEN NULL
11100
+ WHEN g.allowance_pct < 100
11101
+ THEN ${s}.cms_provider_ceiling(r.limit_tokens, g.allowance_pct)
11102
+ ELSE r.limit_tokens END,
11103
+ 'yourUsedTokens', CASE WHEN p_viewer IS NULL THEN NULL
11104
+ ELSE COALESCE(mu.used_tokens, 0) END,
11105
+ 'windowStartUtc', to_char(w.window_start AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
11106
+ 'resetsAtUtc', to_char(w.resets_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')))
11107
+ FROM grid_rows g
11108
+ CROSS JOIN windows w
11109
+ LEFT JOIN ${s}.provider_budget_rules r
11110
+ ON r.provider_name = g.name AND r.period = w.period
11111
+ AND COALESCE(r.model_qualified, '*') = g.scope
11112
+ LEFT JOIN ${s}.provider_meters m
11113
+ ON m.provider_name = g.name AND m.period = w.period
11114
+ AND m.scope = g.scope AND m.window_key_utc = w.window_key
11115
+ LEFT JOIN ${s}.provider_meters_user mu
11116
+ ON mu.provider_name = g.name AND mu.period = w.period
11117
+ AND mu.scope = g.scope AND mu.window_key_utc = w.window_key
11118
+ AND mu.user_id = p_viewer
11119
+ GROUP BY g.name, g.row_kind, g.scope, g.class, g.allowance_pct,
11120
+ g.hold_until_utc, g.hold_indefinite, g.model_rows
11121
+ ORDER BY (g.class = 'shared') DESC, g.name, (g.row_kind = 'model'), g.scope;
11122
+ $$ LANGUAGE sql STABLE;
11123
+
11124
+ -- ── the counters keyed by a rule are gone ────────────────────────────
11125
+ -- Last, so nothing above is still pointing at them.
11126
+
11127
+ DROP TABLE IF EXISTS ${s}.provider_quota_counters_user;
11128
+ DROP TABLE IF EXISTS ${s}.provider_quota_counters;
11129
+ `;
11130
+ }
11131
+ // ─── Migration 0054: the grid says whose row it is ───────────────
11132
+ //
11133
+ // An administrator sees every personal provider, and the grid said nothing
11134
+ // about whose they were. The screen then treated one of somebody else's
11135
+ // providers as an ordinary row of its own: unmarked, priced into the reader's
11136
+ // own headroom, called "your provider" by the Delete sheet — and then refused
11137
+ // by the database, correctly, when they acted on it.
11138
+ //
11139
+ // Two booleans fix all of it at the source, because the answer is the
11140
+ // database's to give and not the client's to guess:
11141
+ //
11142
+ // owned_by_me this row is yours (or it is shared, which is everyone's)
11143
+ // manageable you may change it — admin on a shared one, owner on a
11144
+ // personal one, exactly what cms_provider_assert_manage
11145
+ // already enforces
11146
+ //
11147
+ // The RETURNS TABLE widens, which CREATE OR REPLACE cannot do, so the old
11148
+ // function is dropped first. Its argument list is unchanged.
11149
+ function migration_0054_provider_grid_owner(schema) {
11150
+ const s = schema;
11151
+ return `
11152
+ DROP FUNCTION IF EXISTS ${s}.cms_provider_usage_grid(BIGINT, BOOLEAN);
11153
+
11154
+ CREATE FUNCTION ${s}.cms_provider_usage_grid(
11155
+ p_viewer BIGINT, p_is_admin BOOLEAN
11156
+ ) RETURNS TABLE(
11157
+ provider_name TEXT, row_kind TEXT, scope TEXT, class TEXT,
11158
+ allowance_pct SMALLINT, hold_until_utc TIMESTAMPTZ, hold_indefinite BOOLEAN,
11159
+ model_row_count INTEGER, owned_by_me BOOLEAN, manageable BOOLEAN, periods JSONB
11160
+ ) AS $$
11161
+ WITH visible AS (
11162
+ SELECT pi.name, pi.class, pi.allowance_pct, pi.hold_until_utc, pi.hold_indefinite,
11163
+ -- Shared is everyone's, so it is nobody's to be marked as.
11164
+ (pi.class = 'shared' OR pi.owner_user_id IS NOT DISTINCT FROM p_viewer) AS owned_by_me,
11165
+ (CASE WHEN pi.class = 'shared' THEN COALESCE(p_is_admin, FALSE)
11166
+ ELSE pi.owner_user_id IS NOT DISTINCT FROM p_viewer END) AS manageable,
11167
+ (SELECT count(DISTINCT r.model_qualified)::INTEGER
11168
+ FROM ${s}.provider_budget_rules r
11169
+ WHERE r.provider_name = pi.name AND r.model_qualified IS NOT NULL) AS model_rows
11170
+ FROM ${s}.provider_instances pi
11171
+ WHERE pi.class = 'shared' OR pi.owner_user_id = p_viewer OR COALESCE(p_is_admin, FALSE)
11172
+ ),
11173
+ grid_rows AS (
11174
+ SELECT v.*, 'provider'::TEXT AS row_kind, '*'::TEXT AS scope FROM visible v
11175
+ UNION ALL
11176
+ SELECT v.*, 'model'::TEXT, mr.model_qualified
11177
+ FROM visible v
11178
+ JOIN (SELECT DISTINCT r.provider_name, r.model_qualified
11179
+ FROM ${s}.provider_budget_rules r
11180
+ WHERE r.model_qualified IS NOT NULL) mr ON mr.provider_name = v.name
11181
+ ),
11182
+ windows AS (
11183
+ SELECT per.period, wb.window_start, wb.resets_at, wb.window_key
11184
+ FROM (VALUES ('day'),('week'),('month')) AS per(period)
11185
+ CROSS JOIN LATERAL ${s}.cms_provider_window_bounds(per.period, now()) wb
11186
+ )
11187
+ SELECT g.name, g.row_kind, g.scope, g.class, g.allowance_pct,
11188
+ g.hold_until_utc, g.hold_indefinite,
11189
+ CASE WHEN g.row_kind = 'provider' THEN g.model_rows ELSE 0 END,
11190
+ g.owned_by_me, g.manageable,
11191
+ jsonb_object_agg(w.period, jsonb_build_object(
11192
+ 'ruleId', r.rule_id,
11193
+ 'quotaTokens', r.limit_tokens,
11194
+ 'usedTokens', COALESCE(m.used_tokens, 0),
11195
+ -- Somebody else's personal provider has no share for YOU: its
11196
+ -- whole budget is theirs, and pricing it as the reader's own
11197
+ -- headroom is the arithmetic that made an admin's table lie.
11198
+ 'yourQuotaTokens', CASE
11199
+ WHEN r.limit_tokens IS NULL OR p_viewer IS NULL THEN NULL
11200
+ WHEN NOT g.owned_by_me THEN NULL
11201
+ WHEN g.allowance_pct < 100
11202
+ THEN ${s}.cms_provider_ceiling(r.limit_tokens, g.allowance_pct)
11203
+ ELSE r.limit_tokens END,
11204
+ 'yourUsedTokens', CASE WHEN p_viewer IS NULL THEN NULL
11205
+ ELSE COALESCE(mu.used_tokens, 0) END,
11206
+ 'windowStartUtc', to_char(w.window_start AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
11207
+ 'resetsAtUtc', to_char(w.resets_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')))
11208
+ FROM grid_rows g
11209
+ CROSS JOIN windows w
11210
+ LEFT JOIN ${s}.provider_budget_rules r
11211
+ ON r.provider_name = g.name AND r.period = w.period
11212
+ AND COALESCE(r.model_qualified, '*') = g.scope
11213
+ LEFT JOIN ${s}.provider_meters m
11214
+ ON m.provider_name = g.name AND m.period = w.period
11215
+ AND m.scope = g.scope AND m.window_key_utc = w.window_key
11216
+ LEFT JOIN ${s}.provider_meters_user mu
11217
+ ON mu.provider_name = g.name AND mu.period = w.period
11218
+ AND mu.scope = g.scope AND mu.window_key_utc = w.window_key
11219
+ AND mu.user_id = p_viewer
11220
+ GROUP BY g.name, g.row_kind, g.scope, g.class, g.allowance_pct,
11221
+ g.hold_until_utc, g.hold_indefinite, g.model_rows,
11222
+ g.owned_by_me, g.manageable
11223
+ ORDER BY (g.class = 'shared') DESC, g.name, (g.row_kind = 'model'), g.scope;
11224
+ $$ LANGUAGE sql STABLE;
11225
+
11226
+ -- ── a recycled name does not inherit the old provider's chart ────────
11227
+ --
11228
+ -- The meters are keyed to provider_instances ON DELETE CASCADE, so deleting
11229
+ -- and re-creating a name starts them at zero. The LEDGER has no such key —
11230
+ -- it keeps the name on purpose, so history outlives the provider — and the
11231
+ -- chart read it by name alone. So a fresh provider's row said 0 while the
11232
+ -- chart under it drew the previous holder's spend, across owners and across
11233
+ -- shared/personal, under one heading.
11234
+ --
11235
+ -- Bounded at the current instance's created_at when a provider is NAMED: the
11236
+ -- chart then describes the provider now on screen, which is the row it sits
11237
+ -- under. An unfiltered report is untouched and still carries the whole
11238
+ -- history, which is where that history belongs.
11239
+
11240
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_usage_daily(
11241
+ p_viewer BIGINT, p_is_admin BOOLEAN, p_days INTEGER,
11242
+ p_owner BIGINT, p_provider TEXT, p_model TEXT, p_session TEXT, p_class TEXT
11243
+ ) RETURNS TABLE(day_utc DATE, tokens_total BIGINT, turns BIGINT) AS $$
11244
+ SELECT (l.created_at AT TIME ZONE 'UTC')::date, COALESCE(sum(l.tokens_total), 0), count(*)::BIGINT
11245
+ FROM ${s}.provider_usage_ledger l
11246
+ WHERE l.created_at >= now() - (COALESCE(p_days, 7) || ' days')::interval
11247
+ AND (COALESCE(p_is_admin, FALSE)
11248
+ OR l.owner_user_id IS NOT DISTINCT FROM p_viewer
11249
+ OR (p_provider IS NOT NULL AND p_owner IS NULL AND p_session IS NULL
11250
+ AND EXISTS (
11251
+ SELECT 1 FROM ${s}.provider_instances pi
11252
+ WHERE pi.name = p_provider AND pi.class = 'shared')))
11253
+ AND (p_owner IS NULL OR l.owner_user_id = p_owner)
11254
+ AND (p_provider IS NULL OR l.provider_name = p_provider)
11255
+ -- Nothing from before THIS provider took the name.
11256
+ AND (p_provider IS NULL OR l.created_at >= COALESCE(
11257
+ (SELECT pi.created_at FROM ${s}.provider_instances pi WHERE pi.name = p_provider),
11258
+ '-infinity'::timestamptz))
11259
+ AND (p_model IS NULL OR l.model_qualified = p_model)
11260
+ AND (p_session IS NULL OR l.session_id = p_session)
11261
+ AND (p_class IS NULL OR l.charge_class = p_class)
11262
+ GROUP BY 1 ORDER BY 1;
11263
+ $$ LANGUAGE sql STABLE;
11264
+ `;
11265
+ }
11266
+ // ─── Migration 0055: a user provider says WHOSE it is ────────────────
11267
+ //
11268
+ // 0054 gave the grid `owned_by_me`, which answers "may I touch this" but not
11269
+ // "whose is it". On an administrator's screen every user provider then read
11270
+ // as an anonymous row: `carol-azure USER` says nothing an admin can act on,
11271
+ // and two people's providers are told apart only by whatever they happened to
11272
+ // name them.
11273
+ //
11274
+ // So the grid carries the owner's display name (falling back to the email,
11275
+ // then the id — a user row can be sparse). Only for a provider that has an
11276
+ // owner: a shared provider is everybody's and has no name to print.
11277
+ //
11278
+ // RETURNS TABLE widens again, so DROP + CREATE. Argument list unchanged.
11279
+ function migration_0055_provider_grid_owner_label(schema) {
11280
+ const s = schema;
11281
+ return `
11282
+ DROP FUNCTION IF EXISTS ${s}.cms_provider_usage_grid(BIGINT, BOOLEAN);
11283
+
11284
+ CREATE FUNCTION ${s}.cms_provider_usage_grid(
11285
+ p_viewer BIGINT, p_is_admin BOOLEAN
11286
+ ) RETURNS TABLE(
11287
+ provider_name TEXT, row_kind TEXT, scope TEXT, class TEXT,
11288
+ allowance_pct SMALLINT, hold_until_utc TIMESTAMPTZ, hold_indefinite BOOLEAN,
11289
+ model_row_count INTEGER, owned_by_me BOOLEAN, manageable BOOLEAN,
11290
+ owner_label TEXT, periods JSONB
11291
+ ) AS $$
11292
+ WITH visible AS (
11293
+ SELECT pi.name, pi.class, pi.allowance_pct, pi.hold_until_utc, pi.hold_indefinite,
11294
+ (pi.class = 'shared' OR pi.owner_user_id IS NOT DISTINCT FROM p_viewer) AS owned_by_me,
11295
+ (CASE WHEN pi.class = 'shared' THEN COALESCE(p_is_admin, FALSE)
11296
+ ELSE pi.owner_user_id IS NOT DISTINCT FROM p_viewer END) AS manageable,
11297
+ -- Whose it is. A shared provider is everybody's, so it has no
11298
+ -- owner to name; a user row can be sparse, so fall back down
11299
+ -- to something that still identifies a person.
11300
+ (CASE WHEN pi.class = 'shared' OR pi.owner_user_id IS NULL THEN NULL
11301
+ ELSE COALESCE(
11302
+ NULLIF(BTRIM(u.display_name), ''),
11303
+ NULLIF(BTRIM(u.email), ''),
11304
+ 'user ' || pi.owner_user_id::text) END) AS owner_label,
11305
+ (SELECT count(DISTINCT r.model_qualified)::INTEGER
11306
+ FROM ${s}.provider_budget_rules r
11307
+ WHERE r.provider_name = pi.name AND r.model_qualified IS NOT NULL) AS model_rows
11308
+ FROM ${s}.provider_instances pi
11309
+ LEFT JOIN ${s}.users u ON u.user_id = pi.owner_user_id
11310
+ WHERE pi.class = 'shared' OR pi.owner_user_id = p_viewer OR COALESCE(p_is_admin, FALSE)
11311
+ ),
11312
+ grid_rows AS (
11313
+ SELECT v.*, 'provider'::TEXT AS row_kind, '*'::TEXT AS scope FROM visible v
11314
+ UNION ALL
11315
+ SELECT v.*, 'model'::TEXT, mr.model_qualified
11316
+ FROM visible v
11317
+ JOIN (SELECT DISTINCT r.provider_name, r.model_qualified
11318
+ FROM ${s}.provider_budget_rules r
11319
+ WHERE r.model_qualified IS NOT NULL) mr ON mr.provider_name = v.name
11320
+ ),
11321
+ windows AS (
11322
+ SELECT per.period, wb.window_start, wb.resets_at, wb.window_key
11323
+ FROM (VALUES ('day'),('week'),('month')) AS per(period)
11324
+ CROSS JOIN LATERAL ${s}.cms_provider_window_bounds(per.period, now()) wb
11325
+ )
11326
+ SELECT g.name, g.row_kind, g.scope, g.class, g.allowance_pct,
11327
+ g.hold_until_utc, g.hold_indefinite,
11328
+ CASE WHEN g.row_kind = 'provider' THEN g.model_rows ELSE 0 END,
11329
+ g.owned_by_me, g.manageable,
11330
+ CASE WHEN g.row_kind = 'provider' THEN g.owner_label ELSE NULL END,
11331
+ jsonb_object_agg(w.period, jsonb_build_object(
11332
+ 'ruleId', r.rule_id,
11333
+ 'quotaTokens', r.limit_tokens,
11334
+ 'usedTokens', COALESCE(m.used_tokens, 0),
11335
+ 'yourQuotaTokens', CASE
11336
+ WHEN r.limit_tokens IS NULL OR p_viewer IS NULL THEN NULL
11337
+ WHEN NOT g.owned_by_me THEN NULL
11338
+ WHEN g.allowance_pct < 100
11339
+ THEN ${s}.cms_provider_ceiling(r.limit_tokens, g.allowance_pct)
11340
+ ELSE r.limit_tokens END,
11341
+ 'yourUsedTokens', CASE WHEN p_viewer IS NULL THEN NULL
11342
+ ELSE COALESCE(mu.used_tokens, 0) END,
11343
+ 'windowStartUtc', to_char(w.window_start AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
11344
+ 'resetsAtUtc', to_char(w.resets_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')))
11345
+ FROM grid_rows g
11346
+ CROSS JOIN windows w
11347
+ LEFT JOIN ${s}.provider_budget_rules r
11348
+ ON r.provider_name = g.name AND r.period = w.period
11349
+ AND COALESCE(r.model_qualified, '*') = g.scope
11350
+ LEFT JOIN ${s}.provider_meters m
11351
+ ON m.provider_name = g.name AND m.period = w.period
11352
+ AND m.scope = g.scope AND m.window_key_utc = w.window_key
11353
+ LEFT JOIN ${s}.provider_meters_user mu
11354
+ ON mu.provider_name = g.name AND mu.period = w.period
11355
+ AND mu.scope = g.scope AND mu.window_key_utc = w.window_key
11356
+ AND mu.user_id = p_viewer
11357
+ GROUP BY g.name, g.row_kind, g.scope, g.class, g.allowance_pct,
11358
+ g.hold_until_utc, g.hold_indefinite, g.model_rows,
11359
+ g.owned_by_me, g.manageable, g.owner_label
11360
+ ORDER BY (g.class = 'shared') DESC, g.name, (g.row_kind = 'model'), g.scope;
11361
+ $$ LANGUAGE sql STABLE;
11362
+ `;
11363
+ }
11364
+ /**
11365
+ * 0060 — model rows describe spend, not only limits.
11366
+ *
11367
+ * provider_meters already records all user-charged model usage for day, week,
11368
+ * and month. The grid previously sourced model rows only from limit rules, so
11369
+ * an uncapped Luna/Sol model was invisible even while its spend rolled into the
11370
+ * provider total. Include every model metered in a current window, plus every
11371
+ * model with a rule (including an unused one).
11372
+ */
11373
+ function migration_0060_provider_grid_metered_models(schema) {
11374
+ const s = schema;
11375
+ return `
11376
+ DROP FUNCTION IF EXISTS ${s}.cms_provider_usage_grid(BIGINT, BOOLEAN);
11377
+
11378
+ CREATE FUNCTION ${s}.cms_provider_usage_grid(
11379
+ p_viewer BIGINT, p_is_admin BOOLEAN
11380
+ ) RETURNS TABLE(
11381
+ provider_name TEXT, row_kind TEXT, scope TEXT, class TEXT,
11382
+ allowance_pct SMALLINT, hold_until_utc TIMESTAMPTZ, hold_indefinite BOOLEAN,
11383
+ model_row_count INTEGER, owned_by_me BOOLEAN, manageable BOOLEAN,
11384
+ owner_label TEXT, periods JSONB
11385
+ ) AS $$
11386
+ WITH windows AS (
11387
+ SELECT per.period, wb.window_start, wb.resets_at, wb.window_key
11388
+ FROM (VALUES ('day'),('week'),('month')) AS per(period)
11389
+ CROSS JOIN LATERAL ${s}.cms_provider_window_bounds(per.period, now()) wb
11390
+ ),
11391
+ model_scopes AS (
11392
+ SELECT r.provider_name, r.model_qualified AS scope
11393
+ FROM ${s}.provider_budget_rules r
11394
+ WHERE r.model_qualified IS NOT NULL
11395
+ UNION
11396
+ SELECT m.provider_name, m.scope
11397
+ FROM ${s}.provider_meters m
11398
+ JOIN windows w ON w.period = m.period AND w.window_key = m.window_key_utc
11399
+ WHERE m.scope <> '*'
11400
+ ),
11401
+ visible AS (
11402
+ SELECT pi.name, pi.class, pi.allowance_pct, pi.hold_until_utc, pi.hold_indefinite,
11403
+ (pi.class = 'shared' OR pi.owner_user_id IS NOT DISTINCT FROM p_viewer) AS owned_by_me,
11404
+ (CASE WHEN pi.class = 'shared' THEN COALESCE(p_is_admin, FALSE)
11405
+ ELSE pi.owner_user_id IS NOT DISTINCT FROM p_viewer END) AS manageable,
11406
+ (CASE WHEN pi.class = 'shared' OR pi.owner_user_id IS NULL THEN NULL
11407
+ ELSE COALESCE(
11408
+ NULLIF(BTRIM(u.display_name), ''),
11409
+ NULLIF(BTRIM(u.email), ''),
11410
+ 'user ' || pi.owner_user_id::text) END) AS owner_label,
11411
+ (SELECT count(*)::INTEGER FROM model_scopes ms WHERE ms.provider_name = pi.name) AS model_rows
11412
+ FROM ${s}.provider_instances pi
11413
+ LEFT JOIN ${s}.users u ON u.user_id = pi.owner_user_id
11414
+ WHERE pi.class = 'shared' OR pi.owner_user_id = p_viewer OR COALESCE(p_is_admin, FALSE)
11415
+ ),
11416
+ grid_rows AS (
11417
+ SELECT v.*, 'provider'::TEXT AS row_kind, '*'::TEXT AS scope FROM visible v
11418
+ UNION ALL
11419
+ SELECT v.*, 'model'::TEXT, ms.scope
11420
+ FROM visible v
11421
+ JOIN model_scopes ms ON ms.provider_name = v.name
11422
+ )
11423
+ SELECT g.name, g.row_kind, g.scope, g.class, g.allowance_pct,
11424
+ g.hold_until_utc, g.hold_indefinite,
11425
+ CASE WHEN g.row_kind = 'provider' THEN g.model_rows ELSE 0 END,
11426
+ g.owned_by_me, g.manageable,
11427
+ CASE WHEN g.row_kind = 'provider' THEN g.owner_label ELSE NULL END,
11428
+ jsonb_object_agg(w.period, jsonb_build_object(
11429
+ 'ruleId', r.rule_id,
11430
+ 'quotaTokens', r.limit_tokens,
11431
+ 'usedTokens', COALESCE(m.used_tokens, 0),
11432
+ 'yourQuotaTokens', CASE
11433
+ WHEN r.limit_tokens IS NULL OR p_viewer IS NULL THEN NULL
11434
+ WHEN NOT g.owned_by_me THEN NULL
11435
+ WHEN g.allowance_pct < 100
11436
+ THEN ${s}.cms_provider_ceiling(r.limit_tokens, g.allowance_pct)
11437
+ ELSE r.limit_tokens END,
11438
+ 'yourUsedTokens', CASE WHEN p_viewer IS NULL THEN NULL
11439
+ ELSE COALESCE(mu.used_tokens, 0) END,
11440
+ 'windowStartUtc', to_char(w.window_start AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
11441
+ 'resetsAtUtc', to_char(w.resets_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')))
11442
+ FROM grid_rows g
11443
+ CROSS JOIN windows w
11444
+ LEFT JOIN ${s}.provider_budget_rules r
11445
+ ON r.provider_name = g.name AND r.period = w.period
11446
+ AND COALESCE(r.model_qualified, '*') = g.scope
11447
+ LEFT JOIN ${s}.provider_meters m
11448
+ ON m.provider_name = g.name AND m.period = w.period
11449
+ AND m.scope = g.scope AND m.window_key_utc = w.window_key
11450
+ LEFT JOIN ${s}.provider_meters_user mu
11451
+ ON mu.provider_name = g.name AND mu.period = w.period
11452
+ AND mu.scope = g.scope AND mu.window_key_utc = w.window_key
11453
+ AND mu.user_id = p_viewer
11454
+ GROUP BY g.name, g.row_kind, g.scope, g.class, g.allowance_pct,
11455
+ g.hold_until_utc, g.hold_indefinite, g.model_rows,
11456
+ g.owned_by_me, g.manageable, g.owner_label
11457
+ ORDER BY (g.class = 'shared') DESC, g.name, (g.row_kind = 'model'), g.scope;
11458
+ $$ LANGUAGE sql STABLE;
11459
+ `;
11460
+ }
11461
+ /**
11462
+ * 0056 — admission resolves the same effective default as session execution.
11463
+ *
11464
+ * Sessions created without an explicit model intentionally store NULL; the
11465
+ * worker resolves that to the owner's default, then the cluster default. The
11466
+ * provider gate runs before the worker, so it must use the same precedence or
11467
+ * every default-model session is parked as `no_provider` forever.
11468
+ */
11469
+ function migration_0056_provider_admission_defaults(schema) {
11470
+ const s = `"${schema}"`;
11471
+ return `
11472
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_check_turn(
11473
+ p_session_id TEXT, p_model TEXT
11474
+ ) RETURNS TABLE(
11475
+ verdict TEXT, provider_name TEXT, model_qualified TEXT,
11476
+ exempt BOOLEAN, pause JSONB, rules JSONB
11477
+ ) AS $$
11478
+ DECLARE
11479
+ v_sess RECORD;
11480
+ v_owner BIGINT;
11481
+ v_ref TEXT;
11482
+ v_split RECORD;
11483
+ v_inst RECORD;
11484
+ v_rule RECORD;
11485
+ v_bounds RECORD;
11486
+ v_scope TEXT;
11487
+ v_used BIGINT;
11488
+ v_you BIGINT;
11489
+ v_ceiling BIGINT;
11490
+ v_rules JSONB := '[]'::jsonb;
11491
+ v_block JSONB := NULL;
11492
+ v_kind TEXT := NULL;
11493
+ v_reset TIMESTAMPTZ := NULL;
11494
+ v_pause JSONB := NULL;
11495
+ BEGIN
11496
+ SELECT ss.is_system, ss.model, ss.pause_state INTO v_sess
11497
+ FROM ${s}.sessions ss WHERE ss.session_id = p_session_id;
11498
+ IF NOT FOUND THEN
11499
+ RETURN QUERY SELECT 'clear'::TEXT, NULL::TEXT, NULL::TEXT, FALSE, NULL::JSONB, '[]'::jsonb;
11500
+ RETURN;
11501
+ END IF;
11502
+
11503
+ SELECT so.user_id INTO v_owner FROM ${s}.session_owners so
11504
+ WHERE so.session_id = p_session_id;
11505
+
11506
+ SELECT COALESCE(
11507
+ NULLIF(BTRIM(COALESCE(p_model, '')), ''),
11508
+ NULLIF(BTRIM(COALESCE(v_sess.model, '')), ''),
11509
+ NULLIF(BTRIM(COALESCE(u.default_model, '')), ''),
11510
+ NULLIF(BTRIM(COALESCE(cs.default_model, '')), ''))
11511
+ INTO v_ref
11512
+ FROM ${s}.provider_cluster_settings cs
11513
+ LEFT JOIN ${s}.users u ON u.user_id = v_owner
11514
+ WHERE cs.singleton;
11515
+ SELECT * INTO v_split FROM ${s}.cms_provider_split_ref(v_ref);
11516
+
11517
+ SELECT * INTO v_inst FROM ${s}.cms_provider_in_namespace(
11518
+ COALESCE(v_split.provider_name, ''), v_owner);
11519
+
11520
+ IF v_inst.name IS NULL THEN
11521
+ v_pause := jsonb_build_object(
11522
+ 'kind', 'no_provider',
11523
+ 'provider', v_split.provider_name,
11524
+ 'modelRef', v_ref);
11525
+ UPDATE ${s}.sessions ss SET pause_state = v_pause
11526
+ WHERE ss.session_id = p_session_id
11527
+ AND ss.pause_state IS DISTINCT FROM v_pause;
11528
+ RETURN QUERY SELECT 'no_provider'::TEXT, v_split.provider_name, v_ref, FALSE, v_pause, '[]'::jsonb;
11529
+ RETURN;
11530
+ END IF;
11531
+
11532
+ IF COALESCE(v_sess.is_system, FALSE) THEN
11533
+ IF v_sess.pause_state IS NOT NULL THEN
11534
+ UPDATE ${s}.sessions ss SET pause_state = NULL WHERE ss.session_id = p_session_id;
11535
+ END IF;
11536
+ RETURN QUERY SELECT 'clear'::TEXT, v_inst.name, v_ref, TRUE, NULL::JSONB, '[]'::jsonb;
11537
+ RETURN;
11538
+ END IF;
11539
+
11540
+ IF COALESCE(v_inst.hold_indefinite, FALSE)
11541
+ OR (v_inst.hold_until_utc IS NOT NULL AND v_inst.hold_until_utc > now()) THEN
11542
+ v_pause := jsonb_build_object(
11543
+ 'kind', 'hold',
11544
+ 'provider', v_inst.name,
11545
+ 'resetsAtUtc', CASE WHEN COALESCE(v_inst.hold_indefinite, FALSE)
11546
+ THEN NULL ELSE to_char(v_inst.hold_until_utc AT TIME ZONE 'UTC',
11547
+ 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') END);
11548
+ UPDATE ${s}.sessions ss SET pause_state = v_pause
11549
+ WHERE ss.session_id = p_session_id
11550
+ AND ss.pause_state IS DISTINCT FROM v_pause;
11551
+ RETURN QUERY SELECT 'paused'::TEXT, v_inst.name, v_ref, FALSE, v_pause, '[]'::jsonb;
11552
+ RETURN;
11553
+ END IF;
11554
+
11555
+ FOR v_rule IN
11556
+ SELECT r.* FROM ${s}.provider_budget_rules r
11557
+ WHERE r.provider_name = v_inst.name
11558
+ AND (r.model_qualified IS NULL OR r.model_qualified = v_ref)
11559
+ LOOP
11560
+ SELECT * INTO v_bounds FROM ${s}.cms_provider_window_bounds(v_rule.period, now());
11561
+ v_scope := COALESCE(v_rule.model_qualified, '*');
11562
+
11563
+ SELECT COALESCE(m.used_tokens, 0) INTO v_used
11564
+ FROM ${s}.provider_meters m
11565
+ WHERE m.provider_name = v_inst.name AND m.period = v_rule.period
11566
+ AND m.scope = v_scope AND m.window_key_utc = v_bounds.window_key;
11567
+ v_used := COALESCE(v_used, 0);
11568
+
11569
+ v_ceiling := NULL;
11570
+ v_you := NULL;
11571
+ IF v_inst.allowance_pct < 100 AND v_owner IS NOT NULL THEN
11572
+ v_ceiling := ${s}.cms_provider_ceiling(v_rule.limit_tokens, v_inst.allowance_pct);
11573
+ SELECT COALESCE(mu.used_tokens, 0) INTO v_you
11574
+ FROM ${s}.provider_meters_user mu
11575
+ WHERE mu.provider_name = v_inst.name AND mu.period = v_rule.period
11576
+ AND mu.scope = v_scope AND mu.window_key_utc = v_bounds.window_key
11577
+ AND mu.user_id = v_owner;
11578
+ v_you := COALESCE(v_you, 0);
11579
+ END IF;
11580
+
11581
+ v_rules := v_rules || jsonb_build_object(
11582
+ 'ruleId', v_rule.rule_id,
11583
+ 'providerName', v_inst.name,
11584
+ 'period', v_rule.period,
11585
+ 'modelQualified', v_rule.model_qualified,
11586
+ 'limitTokens', v_rule.limit_tokens,
11587
+ 'usedTokens', v_used,
11588
+ 'ceilingTokens', v_ceiling,
11589
+ 'yourUsedTokens', v_you,
11590
+ 'windowStartUtc', to_char(v_bounds.window_start AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'),
11591
+ 'resetsAtUtc', to_char(v_bounds.resets_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'));
11592
+
11593
+ IF v_used >= v_rule.limit_tokens THEN
11594
+ IF v_kind IS DISTINCT FROM 'limit' THEN
11595
+ v_kind := 'limit';
11596
+ v_block := jsonb_build_object('ruleId', v_rule.rule_id, 'period', v_rule.period,
11597
+ 'modelQualified', v_rule.model_qualified,
11598
+ 'limitTokens', v_rule.limit_tokens, 'usedTokens', v_used);
11599
+ END IF;
11600
+ IF v_reset IS NULL OR v_bounds.resets_at > v_reset THEN v_reset := v_bounds.resets_at; END IF;
11601
+ ELSIF v_ceiling IS NOT NULL AND v_you >= v_ceiling THEN
11602
+ IF v_kind IS NULL THEN
11603
+ v_kind := 'allowance';
11604
+ v_block := jsonb_build_object('ruleId', v_rule.rule_id, 'period', v_rule.period,
11605
+ 'modelQualified', v_rule.model_qualified,
11606
+ 'limitTokens', v_rule.limit_tokens,
11607
+ 'ceilingTokens', v_ceiling, 'yourUsedTokens', v_you);
11608
+ END IF;
11609
+ IF v_reset IS NULL OR v_bounds.resets_at > v_reset THEN v_reset := v_bounds.resets_at; END IF;
11610
+ END IF;
11611
+ END LOOP;
11612
+
11613
+ IF v_kind IS NOT NULL THEN
11614
+ v_pause := v_block
11615
+ || jsonb_build_object('kind', v_kind, 'provider', v_inst.name,
11616
+ 'resetsAtUtc', to_char(v_reset AT TIME ZONE 'UTC',
11617
+ 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'));
11618
+ UPDATE ${s}.sessions ss SET pause_state = v_pause
11619
+ WHERE ss.session_id = p_session_id
11620
+ AND ss.pause_state IS DISTINCT FROM v_pause;
11621
+ RETURN QUERY SELECT 'paused'::TEXT, v_inst.name, v_ref, FALSE, v_pause, v_rules;
11622
+ RETURN;
11623
+ END IF;
11624
+
11625
+ IF v_sess.pause_state IS NOT NULL THEN
11626
+ UPDATE ${s}.sessions ss SET pause_state = NULL WHERE ss.session_id = p_session_id;
11627
+ END IF;
11628
+ RETURN QUERY SELECT 'clear'::TEXT, v_inst.name, v_ref, FALSE, NULL::JSONB, v_rules;
11629
+ END;
11630
+ $$ LANGUAGE plpgsql VOLATILE;
11631
+ `;
11632
+ }
11633
+ /**
11634
+ * 0057 — runtime provider routing for system sessions.
11635
+ *
11636
+ * Ordinary-session defaults remain on the existing cluster/user columns.
11637
+ * System machinery gets an independent default plus persistent per-agent
11638
+ * overrides. A personal provider remains private to its owner; an admin owner
11639
+ * may explicitly allow only system sessions to consume it.
11640
+ */
11641
+ function migration_0057_provider_system_routing(schema) {
11642
+ const s = `"${schema}"`;
11643
+ return `
11644
+ ALTER TABLE ${s}.provider_instances
11645
+ ADD COLUMN IF NOT EXISTS display_name TEXT,
11646
+ ADD COLUMN IF NOT EXISTS system_use_enabled BOOLEAN NOT NULL DEFAULT FALSE,
11647
+ ADD COLUMN IF NOT EXISTS system_use_enabled_by BIGINT REFERENCES ${s}.users(user_id),
11648
+ ADD COLUMN IF NOT EXISTS system_use_enabled_at TIMESTAMPTZ;
11649
+
11650
+ ALTER TABLE ${s}.provider_cluster_settings
11651
+ ADD COLUMN IF NOT EXISTS system_default_provider TEXT,
11652
+ ADD COLUMN IF NOT EXISTS system_default_model TEXT,
11653
+ ADD COLUMN IF NOT EXISTS system_default_reasoning TEXT,
11654
+ ADD COLUMN IF NOT EXISTS system_default_context TEXT,
11655
+ ADD COLUMN IF NOT EXISTS system_default_updated_by BIGINT,
11656
+ ADD COLUMN IF NOT EXISTS system_default_updated_at TIMESTAMPTZ;
11657
+
11658
+ CREATE TABLE IF NOT EXISTS ${s}.system_agent_model_overrides (
11659
+ agent_id TEXT PRIMARY KEY,
11660
+ provider_name TEXT NOT NULL REFERENCES ${s}.provider_instances(name),
11661
+ model_qualified TEXT NOT NULL,
11662
+ reasoning_effort TEXT,
11663
+ context_tier TEXT,
11664
+ updated_by BIGINT NOT NULL REFERENCES ${s}.users(user_id),
11665
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
11666
+ );
11667
+
11668
+ CREATE INDEX IF NOT EXISTS system_agent_model_overrides_provider
11669
+ ON ${s}.system_agent_model_overrides(provider_name);
11670
+
11671
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_assert_system_eligible(
11672
+ p_name TEXT, p_actor BIGINT
11673
+ ) RETURNS ${s}.provider_instances AS $$
11674
+ DECLARE v_row ${s}.provider_instances;
11675
+ BEGIN
11676
+ SELECT * INTO v_row FROM ${s}.provider_instances pi WHERE pi.name = p_name;
11677
+ IF NOT FOUND THEN
11678
+ RAISE EXCEPTION 'PROVIDER_NOT_FOUND: there is no provider named "%"', p_name;
11679
+ END IF;
11680
+ IF v_row.class = 'shared' THEN
11681
+ RETURN v_row;
11682
+ END IF;
11683
+ IF p_actor IS NULL OR v_row.owner_user_id IS DISTINCT FROM p_actor THEN
11684
+ RAISE EXCEPTION 'PROVIDER_NOT_FOUND: there is no provider named "%"', p_name;
11685
+ END IF;
11686
+ IF NOT COALESCE(v_row.system_use_enabled, FALSE) THEN
11687
+ RAISE EXCEPTION 'PROVIDER_INVALID: "%" is your own provider but is not enabled for system sessions', p_name;
11688
+ END IF;
11689
+ RETURN v_row;
11690
+ END;
11691
+ $$ LANGUAGE plpgsql STABLE;
11692
+
11693
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_for_system(
11694
+ p_name TEXT
11695
+ ) RETURNS TABLE(
11696
+ name TEXT, type_id TEXT, class TEXT, owner_user_id BIGINT,
11697
+ secret_ref JSONB, base_url TEXT, allowance_pct SMALLINT,
11698
+ hold_until_utc TIMESTAMPTZ, hold_indefinite BOOLEAN
11699
+ ) AS $$
11700
+ SELECT pi.name, pi.type_id, pi.class, pi.owner_user_id,
11701
+ pi.secret_ref, pi.base_url, pi.allowance_pct,
11702
+ pi.hold_until_utc, pi.hold_indefinite
11703
+ FROM ${s}.provider_instances pi
11704
+ WHERE pi.name = p_name
11705
+ AND (pi.class = 'shared' OR pi.system_use_enabled);
11706
+ $$ LANGUAGE sql STABLE;
11707
+
11708
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_set_display_name(
11709
+ p_name TEXT, p_display_name TEXT, p_actor BIGINT, p_is_admin BOOLEAN
11710
+ ) RETURNS BOOLEAN AS $$
11711
+ BEGIN
11712
+ PERFORM ${s}.cms_provider_assert_manage(p_name, p_actor, p_is_admin);
11713
+ UPDATE ${s}.provider_instances pi
11714
+ SET display_name = NULLIF(BTRIM(COALESCE(p_display_name, '')), ''),
11715
+ updated_at = now()
11716
+ WHERE pi.name = p_name;
11717
+ RETURN TRUE;
11718
+ END;
11719
+ $$ LANGUAGE plpgsql VOLATILE;
11720
+
11721
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_set_system_use(
11722
+ p_name TEXT, p_enabled BOOLEAN, p_actor BIGINT, p_is_admin BOOLEAN
11723
+ ) RETURNS BOOLEAN AS $$
11724
+ DECLARE v_row ${s}.provider_instances;
11725
+ BEGIN
11726
+ IF p_actor IS NULL THEN
11727
+ RAISE EXCEPTION 'PROVIDER_FORBIDDEN: sign in to change system use';
11728
+ END IF;
11729
+ SELECT * INTO v_row FROM ${s}.provider_instances pi WHERE pi.name = p_name;
11730
+ IF NOT FOUND OR v_row.class <> 'personal' OR v_row.owner_user_id IS DISTINCT FROM p_actor THEN
11731
+ RAISE EXCEPTION 'PROVIDER_NOT_FOUND: there is no provider named "%"', p_name;
11732
+ END IF;
11733
+ IF COALESCE(p_enabled, FALSE) AND NOT COALESCE(p_is_admin, FALSE) THEN
11734
+ RAISE EXCEPTION 'PROVIDER_FORBIDDEN: only an administrator may enable their own provider for system sessions';
11735
+ END IF;
11736
+ IF NOT COALESCE(p_enabled, FALSE) THEN
11737
+ IF EXISTS (
11738
+ SELECT 1 FROM ${s}.provider_cluster_settings cs
11739
+ WHERE cs.singleton AND cs.system_default_provider = p_name
11740
+ ) OR EXISTS (
11741
+ SELECT 1 FROM ${s}.system_agent_model_overrides o
11742
+ WHERE o.provider_name = p_name
11743
+ ) THEN
11744
+ RAISE EXCEPTION 'PROVIDER_IN_USE: "%" is still selected for system sessions', p_name;
11745
+ END IF;
11746
+ END IF;
11747
+ UPDATE ${s}.provider_instances pi
11748
+ SET system_use_enabled = COALESCE(p_enabled, FALSE),
11749
+ system_use_enabled_by = CASE WHEN COALESCE(p_enabled, FALSE) THEN p_actor ELSE NULL END,
11750
+ system_use_enabled_at = CASE WHEN COALESCE(p_enabled, FALSE) THEN now() ELSE NULL END,
11751
+ updated_at = now()
11752
+ WHERE pi.name = p_name;
11753
+ RETURN COALESCE(p_enabled, FALSE);
11754
+ END;
11755
+ $$ LANGUAGE plpgsql VOLATILE;
11756
+
11757
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_set_system_default(
11758
+ p_provider TEXT, p_model TEXT, p_reasoning TEXT, p_context TEXT,
11759
+ p_actor BIGINT, p_is_admin BOOLEAN
11760
+ ) RETURNS BOOLEAN AS $$
11761
+ DECLARE
11762
+ v_name TEXT := NULLIF(BTRIM(COALESCE(p_provider, '')), '');
11763
+ v_model TEXT := NULLIF(BTRIM(COALESCE(p_model, '')), '');
11764
+ BEGIN
11765
+ IF p_actor IS NULL OR NOT COALESCE(p_is_admin, FALSE) THEN
11766
+ RAISE EXCEPTION 'PROVIDER_FORBIDDEN: only an administrator can set the system default';
11767
+ END IF;
11768
+ IF (v_name IS NULL) <> (v_model IS NULL) THEN
11769
+ RAISE EXCEPTION 'PROVIDER_INVALID: the system default needs both a provider and model, or neither to clear it';
11770
+ END IF;
11771
+ IF v_name IS NOT NULL THEN
11772
+ PERFORM ${s}.cms_provider_assert_system_eligible(v_name, p_actor);
11773
+ IF v_model NOT LIKE v_name || ':%' THEN
11774
+ RAISE EXCEPTION 'PROVIDER_INVALID: the model must belong to "%": write it as "%:<model>"', v_name, v_name;
11775
+ END IF;
11776
+ END IF;
11777
+ UPDATE ${s}.provider_cluster_settings
11778
+ SET system_default_provider = v_name,
11779
+ system_default_model = v_model,
11780
+ system_default_reasoning = CASE WHEN v_name IS NULL THEN NULL ELSE NULLIF(BTRIM(COALESCE(p_reasoning, '')), '') END,
11781
+ system_default_context = CASE WHEN v_name IS NULL THEN NULL ELSE NULLIF(BTRIM(COALESCE(p_context, '')), '') END,
11782
+ system_default_updated_by = p_actor,
11783
+ system_default_updated_at = now(),
11784
+ updated_at = now()
11785
+ WHERE singleton;
11786
+ RETURN TRUE;
11787
+ END;
11788
+ $$ LANGUAGE plpgsql VOLATILE;
11789
+
11790
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_set_system_agent_model(
11791
+ p_agent_id TEXT, p_provider TEXT, p_model TEXT, p_reasoning TEXT, p_context TEXT,
11792
+ p_actor BIGINT, p_is_admin BOOLEAN
11793
+ ) RETURNS BOOLEAN AS $$
11794
+ DECLARE
11795
+ v_agent TEXT := NULLIF(BTRIM(COALESCE(p_agent_id, '')), '');
11796
+ v_name TEXT := NULLIF(BTRIM(COALESCE(p_provider, '')), '');
11797
+ v_model TEXT := NULLIF(BTRIM(COALESCE(p_model, '')), '');
11798
+ BEGIN
11799
+ IF p_actor IS NULL OR NOT COALESCE(p_is_admin, FALSE) THEN
11800
+ RAISE EXCEPTION 'PROVIDER_FORBIDDEN: only an administrator can set a system-agent model';
11801
+ END IF;
11802
+ IF v_agent IS NULL OR v_name IS NULL OR v_model IS NULL THEN
11803
+ RAISE EXCEPTION 'PROVIDER_INVALID: a system-agent override needs agent, provider and model';
11804
+ END IF;
11805
+ PERFORM ${s}.cms_provider_assert_system_eligible(v_name, p_actor);
11806
+ IF v_model NOT LIKE v_name || ':%' THEN
11807
+ RAISE EXCEPTION 'PROVIDER_INVALID: the model must belong to "%": write it as "%:<model>"', v_name, v_name;
11808
+ END IF;
11809
+ INSERT INTO ${s}.system_agent_model_overrides
11810
+ (agent_id, provider_name, model_qualified, reasoning_effort, context_tier, updated_by)
11811
+ VALUES (
11812
+ v_agent, v_name, v_model,
11813
+ NULLIF(BTRIM(COALESCE(p_reasoning, '')), ''),
11814
+ NULLIF(BTRIM(COALESCE(p_context, '')), ''), p_actor)
11815
+ ON CONFLICT (agent_id) DO UPDATE
11816
+ SET provider_name = EXCLUDED.provider_name,
11817
+ model_qualified = EXCLUDED.model_qualified,
11818
+ reasoning_effort = EXCLUDED.reasoning_effort,
11819
+ context_tier = EXCLUDED.context_tier,
11820
+ updated_by = EXCLUDED.updated_by,
11821
+ updated_at = now();
11822
+ RETURN TRUE;
11823
+ END;
11824
+ $$ LANGUAGE plpgsql VOLATILE;
11825
+
11826
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_clear_system_agent_model(
11827
+ p_agent_id TEXT, p_actor BIGINT, p_is_admin BOOLEAN
11828
+ ) RETURNS BOOLEAN AS $$
11829
+ DECLARE v_deleted INTEGER;
11830
+ BEGIN
11831
+ IF p_actor IS NULL OR NOT COALESCE(p_is_admin, FALSE) THEN
11832
+ RAISE EXCEPTION 'PROVIDER_FORBIDDEN: only an administrator can clear a system-agent model';
11833
+ END IF;
11834
+ DELETE FROM ${s}.system_agent_model_overrides o
11835
+ WHERE o.agent_id = NULLIF(BTRIM(COALESCE(p_agent_id, '')), '');
11836
+ GET DIAGNOSTICS v_deleted = ROW_COUNT;
11837
+ RETURN v_deleted > 0;
11838
+ END;
11839
+ $$ LANGUAGE plpgsql VOLATILE;
11840
+
11841
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_list_system_agent_models()
11842
+ RETURNS TABLE(
11843
+ agent_id TEXT, provider_name TEXT, model_qualified TEXT,
11844
+ reasoning_effort TEXT, context_tier TEXT,
11845
+ updated_by BIGINT, updated_at TIMESTAMPTZ
11846
+ ) AS $$
11847
+ SELECT o.agent_id, o.provider_name, o.model_qualified,
11848
+ o.reasoning_effort, o.context_tier, o.updated_by, o.updated_at
11849
+ FROM ${s}.system_agent_model_overrides o
11850
+ ORDER BY o.agent_id;
11851
+ $$ LANGUAGE sql STABLE;
11852
+
11853
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_delete(
11854
+ p_name TEXT, p_actor BIGINT, p_is_admin BOOLEAN
11855
+ ) RETURNS BIGINT AS $$
11856
+ DECLARE
11857
+ v_row ${s}.provider_instances;
11858
+ v_waiting BIGINT;
11859
+ BEGIN
11860
+ v_row := ${s}.cms_provider_assert_manage(p_name, p_actor, p_is_admin);
11861
+ IF EXISTS (
11862
+ SELECT 1 FROM ${s}.provider_cluster_settings cs
11863
+ WHERE cs.singleton
11864
+ AND (cs.default_provider = p_name OR cs.system_default_provider = p_name)
11865
+ ) OR EXISTS (
11866
+ SELECT 1 FROM ${s}.users u WHERE u.default_provider = p_name
11867
+ ) OR EXISTS (
11868
+ SELECT 1 FROM ${s}.system_agent_model_overrides o WHERE o.provider_name = p_name
11869
+ ) THEN
11870
+ RAISE EXCEPTION 'PROVIDER_IN_USE: "%" is selected by a default or system-agent override; clear that routing first', p_name;
11871
+ END IF;
11872
+ SELECT count(*) INTO v_waiting
11873
+ FROM ${s}.sessions ss
11874
+ CROSS JOIN LATERAL ${s}.cms_provider_split_ref(ss.model) sp
11875
+ WHERE ss.deleted_at IS NULL
11876
+ AND ss.state NOT IN ('completed', 'failed', 'error', 'cancelled')
11877
+ AND sp.provider_name = p_name;
11878
+ DELETE FROM ${s}.provider_instances pi WHERE pi.name = p_name;
11879
+ RETURN v_waiting;
11880
+ END;
11881
+ $$ LANGUAGE plpgsql VOLATILE;
11882
+
11883
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_set_cluster_default(
11884
+ p_provider TEXT, p_model TEXT, p_reasoning TEXT, p_context TEXT, p_is_admin BOOLEAN
11885
+ ) RETURNS BOOLEAN AS $$
11886
+ DECLARE
11887
+ v_name TEXT := NULLIF(BTRIM(COALESCE(p_provider, '')), '');
11888
+ v_model TEXT := NULLIF(BTRIM(COALESCE(p_model, '')), '');
11889
+ v_row ${s}.provider_instances;
11890
+ BEGIN
11891
+ IF NOT COALESCE(p_is_admin, FALSE) THEN
11892
+ RAISE EXCEPTION 'PROVIDER_FORBIDDEN: only an administrator can set the cluster default';
11893
+ END IF;
11894
+ IF (v_name IS NULL) <> (v_model IS NULL) THEN
11895
+ RAISE EXCEPTION 'PROVIDER_INVALID: the cluster default needs both a provider and model, or neither to clear it';
11896
+ END IF;
11897
+ IF v_name IS NOT NULL THEN
11898
+ SELECT * INTO v_row FROM ${s}.provider_instances pi WHERE pi.name = v_name;
11899
+ IF NOT FOUND THEN
11900
+ RAISE EXCEPTION 'PROVIDER_NOT_FOUND: there is no provider named "%"', v_name;
11901
+ END IF;
11902
+ IF v_row.class <> 'shared' THEN
11903
+ RAISE EXCEPTION 'PROVIDER_INVALID: the cluster default must be a shared provider';
11904
+ END IF;
11905
+ IF v_model NOT LIKE v_name || ':%' THEN
11906
+ RAISE EXCEPTION 'PROVIDER_INVALID: the model must belong to "%": write it as "%:<model>"', v_name, v_name;
11907
+ END IF;
11908
+ END IF;
11909
+ UPDATE ${s}.provider_cluster_settings
11910
+ SET default_provider = v_name,
11911
+ default_model = v_model,
11912
+ default_reasoning = CASE WHEN v_name IS NULL THEN NULL ELSE NULLIF(BTRIM(COALESCE(p_reasoning, '')), '') END,
11913
+ default_context = CASE WHEN v_name IS NULL THEN NULL ELSE NULLIF(BTRIM(COALESCE(p_context, '')), '') END,
11914
+ updated_at = now()
11915
+ WHERE singleton;
11916
+ RETURN TRUE;
11917
+ END;
11918
+ $$ LANGUAGE plpgsql VOLATILE;
11919
+
11920
+ DROP FUNCTION IF EXISTS ${s}.cms_provider_list(BIGINT, BOOLEAN);
11921
+ CREATE FUNCTION ${s}.cms_provider_list(
11922
+ p_viewer BIGINT, p_is_admin BOOLEAN
11923
+ ) RETURNS TABLE(
11924
+ name TEXT, type_id TEXT, class TEXT, owner_user_id BIGINT, owner_email TEXT,
11925
+ owner_display_name TEXT, display_name TEXT, base_url TEXT, allowance_pct SMALLINT,
11926
+ hold_until_utc TIMESTAMPTZ, hold_indefinite BOOLEAN, has_credential BOOLEAN,
11927
+ usable_by_me BOOLEAN, system_use_enabled BOOLEAN, system_eligible BOOLEAN,
11928
+ is_cluster_default BOOLEAN, is_my_default BOOLEAN, is_system_default BOOLEAN,
11929
+ rule_count BIGINT, created_at TIMESTAMPTZ
11930
+ ) AS $$
11931
+ SELECT pi.name, pi.type_id, pi.class, pi.owner_user_id, ou.email, ou.display_name,
11932
+ pi.display_name, pi.base_url, pi.allowance_pct, pi.hold_until_utc, pi.hold_indefinite,
11933
+ (pi.secret_ref IS NOT NULL AND pi.secret_ref <> '{}'::jsonb),
11934
+ (pi.class = 'shared' OR pi.owner_user_id = p_viewer),
11935
+ pi.system_use_enabled,
11936
+ (pi.class = 'shared' OR (pi.owner_user_id = p_viewer AND pi.system_use_enabled)),
11937
+ (cs.default_provider = pi.name),
11938
+ (vu.default_provider = pi.name),
11939
+ (cs.system_default_provider = pi.name),
11940
+ (SELECT count(*) FROM ${s}.provider_budget_rules r WHERE r.provider_name = pi.name),
11941
+ pi.created_at
11942
+ FROM ${s}.provider_instances pi
11943
+ LEFT JOIN ${s}.users ou ON ou.user_id = pi.owner_user_id
11944
+ LEFT JOIN ${s}.users vu ON vu.user_id = p_viewer
11945
+ CROSS JOIN ${s}.provider_cluster_settings cs
11946
+ WHERE cs.singleton
11947
+ AND (pi.class = 'shared'
11948
+ OR pi.owner_user_id = p_viewer
11949
+ OR COALESCE(p_is_admin, FALSE))
11950
+ ORDER BY (pi.class = 'shared') DESC, pi.name;
11951
+ $$ LANGUAGE sql STABLE;
11952
+
11953
+ DROP FUNCTION IF EXISTS ${s}.cms_provider_get_defaults(BIGINT);
11954
+ CREATE FUNCTION ${s}.cms_provider_get_defaults(
11955
+ p_actor BIGINT
11956
+ ) RETURNS TABLE(
11957
+ cluster_provider TEXT, cluster_model TEXT, cluster_reasoning TEXT, cluster_context TEXT,
11958
+ my_provider TEXT, my_model TEXT, my_reasoning TEXT, my_context TEXT,
11959
+ system_provider TEXT, system_model TEXT, system_reasoning TEXT, system_context TEXT,
11960
+ system_updated_by BIGINT, system_updated_at TIMESTAMPTZ
11961
+ ) AS $$
11962
+ SELECT c.default_provider, c.default_model, c.default_reasoning, c.default_context,
11963
+ u.default_provider, u.default_model, u.default_reasoning, u.default_context,
11964
+ c.system_default_provider, c.system_default_model,
11965
+ c.system_default_reasoning, c.system_default_context,
11966
+ c.system_default_updated_by, c.system_default_updated_at
11967
+ FROM ${s}.provider_cluster_settings c
11968
+ LEFT JOIN ${s}.users u ON u.user_id = p_actor
11969
+ WHERE c.singleton;
11970
+ $$ LANGUAGE sql STABLE;
11971
+ `;
11972
+ }
11973
+ /**
11974
+ * 0058 — turn legacy per-user GHCP keys into private provider instances.
11975
+ *
11976
+ * Keys never leave PostgreSQL. Regular users migrate automatically and keep
11977
+ * the legacy column for dual-read rollback. The synthetic System key requires
11978
+ * an authenticated admin to claim it into their own personal provider.
11979
+ */
11980
+ function migration_0058_legacy_github_provider_migration(schema) {
11981
+ const s = `"${schema}"`;
11982
+ return `
11983
+ CREATE TABLE IF NOT EXISTS ${s}.provider_legacy_key_migrations (
11984
+ source_kind TEXT NOT NULL CHECK (source_kind IN ('user','system')),
11985
+ source_user_id BIGINT NOT NULL REFERENCES ${s}.users(user_id) ON DELETE CASCADE,
11986
+ provider_name TEXT UNIQUE REFERENCES ${s}.provider_instances(name) ON DELETE SET NULL,
11987
+ migrated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
11988
+ PRIMARY KEY (source_kind, source_user_id)
11989
+ );
11990
+
11991
+ CREATE TABLE IF NOT EXISTS ${s}.provider_legacy_session_models (
11992
+ session_id TEXT PRIMARY KEY REFERENCES ${s}.sessions(session_id) ON DELETE CASCADE,
11993
+ original_model TEXT NOT NULL,
11994
+ migrated_model TEXT NOT NULL,
11995
+ migrated_at TIMESTAMPTZ NOT NULL DEFAULT now()
11996
+ );
11997
+
11998
+ DO $$
11999
+ DECLARE
12000
+ v_user RECORD;
12001
+ v_name TEXT;
12002
+ v_existing RECORD;
12003
+ BEGIN
12004
+ FOR v_user IN
12005
+ SELECT u.user_id, u.github_copilot_key
12006
+ FROM ${s}.users u
12007
+ WHERE NOT (u.provider = 'system' AND u.subject = 'system')
12008
+ AND NULLIF(BTRIM(u.github_copilot_key), '') IS NOT NULL
12009
+ ORDER BY u.user_id
12010
+ LOOP
12011
+ IF EXISTS (
12012
+ SELECT 1 FROM ${s}.provider_legacy_key_migrations m
12013
+ WHERE m.source_kind = 'user' AND m.source_user_id = v_user.user_id
12014
+ ) THEN
12015
+ CONTINUE;
12016
+ END IF;
12017
+ v_name := 'ghcp-u' || v_user.user_id::text;
12018
+ SELECT pi.name, pi.type_id, pi.class, pi.owner_user_id INTO v_existing
12019
+ FROM ${s}.provider_instances pi WHERE pi.name = v_name;
12020
+ IF FOUND THEN
12021
+ IF v_existing.type_id <> 'github-copilot'
12022
+ OR v_existing.class <> 'personal'
12023
+ OR v_existing.owner_user_id IS DISTINCT FROM v_user.user_id THEN
12024
+ RAISE EXCEPTION 'PROVIDER_CONFLICT: legacy GHCP migration name "%" is already used by another provider', v_name;
12025
+ END IF;
12026
+ ELSE
12027
+ INSERT INTO ${s}.provider_instances
12028
+ (name, type_id, class, owner_user_id, secret_ref, display_name)
12029
+ VALUES (
12030
+ v_name, 'github-copilot', 'personal', v_user.user_id,
12031
+ jsonb_build_object(
12032
+ 'kind', 'githubToken',
12033
+ 'value', v_user.github_copilot_key,
12034
+ 'migratedFrom', 'users.github_copilot_key'),
12035
+ 'My GitHub Copilot');
12036
+ END IF;
12037
+ INSERT INTO ${s}.provider_legacy_key_migrations(source_kind, source_user_id, provider_name)
12038
+ VALUES ('user', v_user.user_id, v_name)
12039
+ ON CONFLICT (source_kind, source_user_id) DO NOTHING;
12040
+ END LOOP;
12041
+
12042
+ INSERT INTO ${s}.provider_legacy_session_models(session_id, original_model, migrated_model)
12043
+ SELECT ss.session_id, ss.model,
12044
+ m.provider_name || substring(ss.model from position(':' in ss.model))
12045
+ FROM ${s}.session_owners so
12046
+ JOIN ${s}.provider_legacy_key_migrations m
12047
+ ON m.source_kind = 'user' AND m.source_user_id = so.user_id
12048
+ JOIN ${s}.sessions ss ON ss.session_id = so.session_id
12049
+ WHERE m.provider_name IS NOT NULL
12050
+ AND ss.is_system = FALSE
12051
+ AND ss.deleted_at IS NULL
12052
+ AND ss.state NOT IN ('completed','failed','error','cancelled')
12053
+ AND ss.model LIKE 'github-copilot:%'
12054
+ ON CONFLICT (session_id) DO NOTHING;
12055
+
12056
+ UPDATE ${s}.sessions ss
12057
+ SET model = sm.migrated_model
12058
+ FROM ${s}.provider_legacy_session_models sm
12059
+ WHERE sm.session_id = ss.session_id
12060
+ AND ss.model = sm.original_model
12061
+ AND EXISTS (
12062
+ SELECT 1 FROM ${s}.provider_instances pi
12063
+ WHERE sm.migrated_model LIKE pi.name || ':%'
12064
+ );
12065
+ END;
12066
+ $$;
12067
+
12068
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_adopt_system_github_key(
12069
+ p_name TEXT, p_display_name TEXT, p_actor BIGINT, p_is_admin BOOLEAN
12070
+ ) RETURNS TABLE(name TEXT, type_id TEXT, class TEXT, owner_user_id BIGINT) AS $$
12071
+ DECLARE
12072
+ v_name TEXT := NULLIF(BTRIM(COALESCE(p_name, '')), '');
12073
+ v_system_user BIGINT;
12074
+ v_key TEXT;
12075
+ v_adopted_name TEXT;
12076
+ BEGIN
12077
+ IF p_actor IS NULL OR NOT COALESCE(p_is_admin, FALSE) THEN
12078
+ RAISE EXCEPTION 'PROVIDER_FORBIDDEN: only an authenticated administrator may adopt the System GitHub Copilot key';
12079
+ END IF;
12080
+ IF v_name IS NULL THEN
12081
+ RAISE EXCEPTION 'PROVIDER_INVALID: the adopted provider needs a name';
12082
+ END IF;
12083
+ SELECT u.user_id, u.github_copilot_key INTO v_system_user, v_key
12084
+ FROM ${s}.users u
12085
+ WHERE u.provider = 'system' AND u.subject = 'system'
12086
+ FOR UPDATE;
12087
+ IF v_system_user IS NULL OR NULLIF(BTRIM(v_key), '') IS NULL THEN
12088
+ RAISE EXCEPTION 'PROVIDER_NOT_FOUND: no legacy System GitHub Copilot key is configured';
12089
+ END IF;
12090
+
12091
+ SELECT m.provider_name INTO v_adopted_name
12092
+ FROM ${s}.provider_legacy_key_migrations m
12093
+ WHERE m.source_kind = 'system' AND m.source_user_id = v_system_user;
12094
+ IF FOUND THEN
12095
+ IF v_adopted_name IS NULL THEN
12096
+ RAISE EXCEPTION 'PROVIDER_CONFLICT: the legacy System key was already adopted and that provider was deleted';
12097
+ END IF;
12098
+ IF v_adopted_name <> v_name OR NOT EXISTS (
12099
+ SELECT 1 FROM ${s}.provider_instances pi
12100
+ WHERE pi.name = v_adopted_name
12101
+ AND pi.class = 'personal'
12102
+ AND pi.owner_user_id = p_actor
12103
+ ) THEN
12104
+ RAISE EXCEPTION 'PROVIDER_CONFLICT: the legacy System key has already been adopted';
12105
+ END IF;
12106
+ RETURN QUERY
12107
+ SELECT pi.name, pi.type_id, pi.class, pi.owner_user_id
12108
+ FROM ${s}.provider_instances pi WHERE pi.name = v_adopted_name;
12109
+ RETURN;
12110
+ END IF;
12111
+
12112
+ IF EXISTS (SELECT 1 FROM ${s}.provider_instances pi WHERE pi.name = v_name) THEN
12113
+ RAISE EXCEPTION 'PROVIDER_CONFLICT: the name "%" is already taken', v_name;
12114
+ END IF;
12115
+ INSERT INTO ${s}.provider_instances
12116
+ (name, type_id, class, owner_user_id, secret_ref, display_name,
12117
+ system_use_enabled, system_use_enabled_by, system_use_enabled_at)
12118
+ VALUES (
12119
+ v_name, 'github-copilot', 'personal', p_actor,
12120
+ jsonb_build_object(
12121
+ 'kind', 'githubToken',
12122
+ 'value', v_key,
12123
+ 'migratedFrom', 'system.github_copilot_key'),
12124
+ COALESCE(NULLIF(BTRIM(p_display_name), ''), 'System GitHub Copilot'),
12125
+ TRUE, p_actor, now());
12126
+
12127
+ INSERT INTO ${s}.provider_legacy_key_migrations(source_kind, source_user_id, provider_name)
12128
+ VALUES ('system', v_system_user, v_name)
12129
+ ON CONFLICT (source_kind, source_user_id) DO NOTHING;
12130
+
12131
+ RETURN QUERY
12132
+ SELECT pi.name, pi.type_id, pi.class, pi.owner_user_id
12133
+ FROM ${s}.provider_instances pi WHERE pi.name = v_name;
12134
+ END;
12135
+ $$ LANGUAGE plpgsql VOLATILE;
12136
+
12137
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_legacy_key_migration_status()
12138
+ RETURNS TABLE(
12139
+ regular_keys BIGINT,
12140
+ migrated_regular_keys BIGINT,
12141
+ system_key_present BOOLEAN,
12142
+ system_key_adopted BOOLEAN
12143
+ ) AS $$
12144
+ SELECT
12145
+ (SELECT count(*) FROM ${s}.users u
12146
+ WHERE NOT (u.provider = 'system' AND u.subject = 'system')
12147
+ AND NULLIF(BTRIM(u.github_copilot_key), '') IS NOT NULL),
12148
+ (SELECT count(*) FROM ${s}.provider_legacy_key_migrations m WHERE m.source_kind = 'user'),
12149
+ EXISTS (SELECT 1 FROM ${s}.users u
12150
+ WHERE u.provider = 'system' AND u.subject = 'system'
12151
+ AND NULLIF(BTRIM(u.github_copilot_key), '') IS NOT NULL),
12152
+ EXISTS (SELECT 1 FROM ${s}.provider_legacy_key_migrations m WHERE m.source_kind = 'system');
12153
+ $$ LANGUAGE sql STABLE;
12154
+ `;
12155
+ }
12156
+ /**
12157
+ * 0059 — make the stamped CMS model authoritative end to end.
12158
+ *
12159
+ * Creation validates and locks the provider in the same transaction as the
12160
+ * session insert. Admission uses the system namespace for machinery. Legacy
12161
+ * bootstrap records one receipt per provider, so a credential-poor process
12162
+ * cannot permanently block a better-provisioned worker from adding the rest.
12163
+ */
12164
+ function migration_0059_provider_authoritative_routing(schema) {
12165
+ const s = `"${schema}"`;
12166
+ const systemAwareCheck = migration_0056_provider_admission_defaults(schema)
12167
+ .replace("SELECT ss.is_system, ss.model, ss.pause_state INTO v_sess", "SELECT ss.is_system, ss.model, ss.pause_state, ss.agent_id INTO v_sess")
12168
+ .replace(` IF NOT FOUND THEN
12169
+ RETURN QUERY SELECT 'clear'::TEXT, NULL::TEXT, NULL::TEXT, FALSE, NULL::JSONB, '[]'::jsonb;
12170
+ RETURN;
12171
+ END IF;`, ` IF NOT FOUND THEN
12172
+ RAISE EXCEPTION 'PROVIDER_NOT_FOUND: session "%" does not exist', p_session_id;
12173
+ END IF;`)
12174
+ .replace(` SELECT COALESCE(
12175
+ NULLIF(BTRIM(COALESCE(p_model, '')), ''),
12176
+ NULLIF(BTRIM(COALESCE(v_sess.model, '')), ''),
12177
+ NULLIF(BTRIM(COALESCE(u.default_model, '')), ''),
12178
+ NULLIF(BTRIM(COALESCE(cs.default_model, '')), ''))
12179
+ INTO v_ref
12180
+ FROM ${s}.provider_cluster_settings cs
12181
+ LEFT JOIN ${s}.users u ON u.user_id = v_owner
12182
+ WHERE cs.singleton;`, ` SELECT CASE WHEN COALESCE(v_sess.is_system, FALSE) THEN COALESCE(
12183
+ NULLIF(BTRIM(COALESCE(v_sess.model, '')), ''),
12184
+ NULLIF(BTRIM(COALESCE(o.model_qualified, '')), ''),
12185
+ NULLIF(BTRIM(COALESCE(cs.system_default_model, '')), ''),
12186
+ NULLIF(BTRIM(COALESCE(cs.default_model, '')), ''))
12187
+ ELSE COALESCE(
12188
+ NULLIF(BTRIM(COALESCE(v_sess.model, '')), ''),
12189
+ NULLIF(BTRIM(COALESCE(u.default_model, '')), ''),
12190
+ NULLIF(BTRIM(COALESCE(cs.default_model, '')), '')) END
12191
+ INTO v_ref
12192
+ FROM ${s}.provider_cluster_settings cs
12193
+ LEFT JOIN ${s}.users u ON u.user_id = v_owner
12194
+ LEFT JOIN ${s}.system_agent_model_overrides o ON o.agent_id = v_sess.agent_id
12195
+ WHERE cs.singleton;`)
12196
+ .replace(` SELECT * INTO v_inst FROM ${s}.cms_provider_in_namespace(
12197
+ COALESCE(v_split.provider_name, ''), v_owner);`, ` IF COALESCE(v_sess.is_system, FALSE) THEN
12198
+ SELECT * INTO v_inst FROM ${s}.cms_provider_for_system(
12199
+ COALESCE(v_split.provider_name, ''));
12200
+ ELSE
12201
+ SELECT * INTO v_inst FROM ${s}.cms_provider_in_namespace(
12202
+ COALESCE(v_split.provider_name, ''), v_owner);
12203
+ END IF;`);
12204
+ const authoritativeCheck = systemAwareCheck.replace(` SELECT * INTO v_split FROM ${s}.cms_provider_split_ref(v_ref);
12205
+
12206
+ IF COALESCE(v_sess.is_system, FALSE) THEN`, ` SELECT * INTO v_split FROM ${s}.cms_provider_split_ref(v_ref);
12207
+
12208
+ IF v_sess.model IS NULL AND v_ref IS NOT NULL THEN
12209
+ UPDATE ${s}.sessions ss
12210
+ SET model = v_ref,
12211
+ reasoning_effort = CASE WHEN COALESCE(v_sess.is_system, FALSE) THEN COALESCE(
12212
+ (SELECT o.reasoning_effort FROM ${s}.system_agent_model_overrides o WHERE o.agent_id = v_sess.agent_id),
12213
+ (SELECT cs.system_default_reasoning FROM ${s}.provider_cluster_settings cs WHERE cs.singleton),
12214
+ (SELECT cs.default_reasoning FROM ${s}.provider_cluster_settings cs WHERE cs.singleton))
12215
+ ELSE COALESCE(
12216
+ (SELECT u.default_reasoning FROM ${s}.users u WHERE u.user_id = v_owner),
12217
+ (SELECT cs.default_reasoning FROM ${s}.provider_cluster_settings cs WHERE cs.singleton)) END,
12218
+ context_tier = CASE WHEN COALESCE(v_sess.is_system, FALSE) THEN COALESCE(
12219
+ (SELECT o.context_tier FROM ${s}.system_agent_model_overrides o WHERE o.agent_id = v_sess.agent_id),
12220
+ (SELECT cs.system_default_context FROM ${s}.provider_cluster_settings cs WHERE cs.singleton),
12221
+ (SELECT cs.default_context FROM ${s}.provider_cluster_settings cs WHERE cs.singleton))
12222
+ ELSE COALESCE(
12223
+ (SELECT u.default_context FROM ${s}.users u WHERE u.user_id = v_owner),
12224
+ (SELECT cs.default_context FROM ${s}.provider_cluster_settings cs WHERE cs.singleton)) END,
12225
+ model_resolution_source = CASE WHEN COALESCE(v_sess.is_system, FALSE) THEN
12226
+ CASE WHEN EXISTS (SELECT 1 FROM ${s}.system_agent_model_overrides o WHERE o.agent_id = v_sess.agent_id)
12227
+ THEN 'agent_override'
12228
+ WHEN (SELECT cs.system_default_model FROM ${s}.provider_cluster_settings cs WHERE cs.singleton) IS NOT NULL
12229
+ THEN 'system_default' ELSE 'cluster_default' END
12230
+ ELSE CASE WHEN (SELECT u.default_model FROM ${s}.users u WHERE u.user_id = v_owner) IS NOT NULL
12231
+ THEN 'user_default' ELSE 'cluster_default' END END,
12232
+ updated_at = now()
12233
+ WHERE ss.session_id = p_session_id AND ss.model IS NULL;
12234
+ END IF;
12235
+
12236
+ IF COALESCE(v_sess.is_system, FALSE) THEN`);
12237
+ return `
12238
+ ALTER TABLE ${s}.sessions
12239
+ ADD COLUMN IF NOT EXISTS context_tier TEXT,
12240
+ ADD COLUMN IF NOT EXISTS model_resolution_source TEXT;
12241
+
12242
+ CREATE TABLE IF NOT EXISTS ${s}.provider_bootstrap_receipts (
12243
+ provider_name TEXT PRIMARY KEY,
12244
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
12245
+ );
12246
+
12247
+ CREATE TABLE IF NOT EXISTS ${s}.system_agent_restart_rollouts (
12248
+ agent_id TEXT PRIMARY KEY,
12249
+ operation_id TEXT NOT NULL,
12250
+ claim_id TEXT NOT NULL,
12251
+ target_model TEXT NOT NULL,
12252
+ target_reasoning TEXT,
12253
+ target_context TEXT,
12254
+ disposition TEXT NOT NULL CHECK (disposition IN ('complete','terminate','hard_delete')),
12255
+ status TEXT NOT NULL CHECK (status IN ('in_progress','failed','complete')),
12256
+ claimed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
12257
+ completed_at TIMESTAMPTZ,
12258
+ last_error TEXT
12259
+ );
12260
+
12261
+ INSERT INTO ${s}.provider_bootstrap_receipts(provider_name)
12262
+ SELECT pi.name FROM ${s}.provider_instances pi
12263
+ WHERE pi.secret_ref->>'source' = 'config-file'
12264
+ ON CONFLICT (provider_name) DO NOTHING;
12265
+
12266
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_assert_session_model(
12267
+ p_model TEXT,
12268
+ p_owner_provider TEXT,
12269
+ p_owner_subject TEXT,
12270
+ p_is_system BOOLEAN
12271
+ ) RETURNS BOOLEAN AS $$
12272
+ DECLARE
12273
+ v_split RECORD;
12274
+ v_owner BIGINT;
12275
+ v_found BOOLEAN := FALSE;
12276
+ BEGIN
12277
+ SELECT * INTO v_split FROM ${s}.cms_provider_split_ref(p_model);
12278
+ IF v_split.provider_name IS NULL OR v_split.model_name IS NULL THEN
12279
+ RAISE EXCEPTION 'PROVIDER_INVALID: a session model must be an exact provider:model reference';
12280
+ END IF;
12281
+ IF COALESCE(p_is_system, FALSE) THEN
12282
+ SELECT TRUE INTO v_found
12283
+ FROM ${s}.provider_instances pi
12284
+ WHERE pi.name = v_split.provider_name
12285
+ AND (pi.class = 'shared' OR pi.system_use_enabled)
12286
+ FOR KEY SHARE;
12287
+ ELSE
12288
+ SELECT u.user_id INTO v_owner FROM ${s}.users u
12289
+ WHERE u.provider = NULLIF(BTRIM(p_owner_provider), '')
12290
+ AND u.subject = NULLIF(BTRIM(p_owner_subject), '');
12291
+ SELECT TRUE INTO v_found
12292
+ FROM ${s}.provider_instances pi
12293
+ WHERE pi.name = v_split.provider_name
12294
+ AND (pi.class = 'shared' OR (v_owner IS NOT NULL AND pi.owner_user_id = v_owner))
12295
+ FOR KEY SHARE;
12296
+ END IF;
12297
+ IF NOT COALESCE(v_found, FALSE) THEN
12298
+ RAISE EXCEPTION 'PROVIDER_NOT_FOUND: there is no usable provider named "%"', v_split.provider_name;
12299
+ END IF;
12300
+ RETURN TRUE;
12301
+ END;
12302
+ $$ LANGUAGE plpgsql VOLATILE;
12303
+
12304
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_claim_system_restart(
12305
+ p_agent_id TEXT, p_operation_id TEXT, p_claim_id TEXT,
12306
+ p_model TEXT, p_reasoning TEXT, p_context TEXT, p_disposition TEXT
12307
+ ) RETURNS TEXT AS $$
12308
+ DECLARE v_row ${s}.system_agent_restart_rollouts;
12309
+ BEGIN
12310
+ IF NULLIF(BTRIM(p_agent_id), '') IS NULL
12311
+ OR NULLIF(BTRIM(p_operation_id), '') IS NULL
12312
+ OR NULLIF(BTRIM(p_claim_id), '') IS NULL
12313
+ OR NULLIF(BTRIM(p_model), '') IS NULL
12314
+ OR p_disposition NOT IN ('complete','terminate','hard_delete') THEN
12315
+ RAISE EXCEPTION 'PROVIDER_INVALID: invalid system restart claim';
12316
+ END IF;
12317
+
12318
+ PERFORM pg_advisory_xact_lock(hashtext('system-restart:' || p_agent_id));
12319
+
12320
+ SELECT * INTO v_row FROM ${s}.system_agent_restart_rollouts r
12321
+ WHERE r.agent_id = p_agent_id FOR UPDATE;
12322
+ IF FOUND THEN
12323
+ IF v_row.operation_id = p_operation_id AND v_row.status = 'complete' THEN
12324
+ RETURN 'complete';
12325
+ END IF;
12326
+ IF v_row.status = 'in_progress'
12327
+ AND v_row.claimed_at > now() - interval '10 minutes' THEN
12328
+ RETURN 'busy';
12329
+ END IF;
12330
+ UPDATE ${s}.system_agent_restart_rollouts r
12331
+ SET operation_id = p_operation_id, claim_id = p_claim_id,
12332
+ target_model = p_model,
12333
+ target_reasoning = NULLIF(BTRIM(COALESCE(p_reasoning, '')), ''),
12334
+ target_context = NULLIF(BTRIM(COALESCE(p_context, '')), ''),
12335
+ disposition = p_disposition, status = 'in_progress',
12336
+ claimed_at = now(), completed_at = NULL, last_error = NULL
12337
+ WHERE r.agent_id = p_agent_id;
12338
+ ELSE
12339
+ INSERT INTO ${s}.system_agent_restart_rollouts
12340
+ (agent_id, operation_id, claim_id, target_model, target_reasoning,
12341
+ target_context, disposition, status)
12342
+ VALUES (
12343
+ p_agent_id, p_operation_id, p_claim_id, p_model,
12344
+ NULLIF(BTRIM(COALESCE(p_reasoning, '')), ''),
12345
+ NULLIF(BTRIM(COALESCE(p_context, '')), ''),
12346
+ p_disposition, 'in_progress');
12347
+ END IF;
12348
+ RETURN 'claimed';
12349
+ END;
12350
+ $$ LANGUAGE plpgsql VOLATILE;
12351
+
12352
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_finish_system_restart(
12353
+ p_agent_id TEXT, p_claim_id TEXT, p_error TEXT
12354
+ ) RETURNS BOOLEAN AS $$
12355
+ DECLARE v_updated INTEGER;
12356
+ BEGIN
12357
+ UPDATE ${s}.system_agent_restart_rollouts r
12358
+ SET status = CASE WHEN NULLIF(BTRIM(COALESCE(p_error, '')), '') IS NULL
12359
+ THEN 'complete' ELSE 'failed' END,
12360
+ completed_at = now(),
12361
+ last_error = NULLIF(BTRIM(COALESCE(p_error, '')), '')
12362
+ WHERE r.agent_id = p_agent_id AND r.claim_id = p_claim_id;
12363
+ GET DIAGNOSTICS v_updated = ROW_COUNT;
12364
+ RETURN v_updated > 0;
12365
+ END;
12366
+ $$ LANGUAGE plpgsql VOLATILE;
12367
+
12368
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_get_system_restart(
12369
+ p_agent_id TEXT
12370
+ ) RETURNS TABLE(
12371
+ agent_id TEXT, operation_id TEXT, target_model TEXT,
12372
+ target_reasoning TEXT, target_context TEXT,
12373
+ disposition TEXT, status TEXT, claimed_at TIMESTAMPTZ,
12374
+ completed_at TIMESTAMPTZ, last_error TEXT
12375
+ ) AS $$
12376
+ SELECT r.agent_id, r.operation_id, r.target_model,
12377
+ r.target_reasoning, r.target_context,
12378
+ r.disposition, r.status, r.claimed_at,
12379
+ r.completed_at, r.last_error
12380
+ FROM ${s}.system_agent_restart_rollouts r
12381
+ WHERE r.agent_id = p_agent_id;
12382
+ $$ LANGUAGE sql STABLE;
12383
+
12384
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_bootstrap(
12385
+ p_instances JSONB, p_default JSONB
12386
+ ) RETURNS TABLE(claimed BOOLEAN, created INTEGER) AS $$
12387
+ DECLARE
12388
+ v_item JSONB;
12389
+ v_receipt TEXT;
12390
+ v_created INTEGER := 0;
12391
+ v_claimed BOOLEAN := FALSE;
12392
+ BEGIN
12393
+ FOR v_item IN SELECT * FROM jsonb_array_elements(COALESCE(p_instances, '[]'::jsonb)) LOOP
12394
+ v_receipt := NULL;
12395
+ IF COALESCE(v_item->>'name', '') !~ '^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$' THEN
12396
+ RAISE EXCEPTION 'PROVIDER_INVALID: invalid provider name "%"', v_item->>'name';
12397
+ END IF;
12398
+ INSERT INTO ${s}.provider_bootstrap_receipts(provider_name)
12399
+ VALUES (v_item->>'name')
12400
+ ON CONFLICT (provider_name) DO NOTHING
12401
+ RETURNING provider_name INTO v_receipt;
12402
+ IF v_receipt IS NULL THEN CONTINUE; END IF;
12403
+
12404
+ -- Fail closed: any malformed entry rolls back every provider and
12405
+ -- receipt in this call, leaving a later process free to retry.
12406
+ PERFORM ${s}.cms_provider_create(
12407
+ v_item->>'name', v_item->>'typeId', 'shared', NULL,
12408
+ COALESCE(v_item->'secretRef', '{}'::jsonb), v_item->>'baseUrl',
12409
+ NULL, TRUE);
12410
+ v_created := v_created + 1;
12411
+ v_claimed := TRUE;
12412
+ END LOOP;
12413
+
12414
+ IF p_default IS NOT NULL AND p_default->>'provider' IS NOT NULL
12415
+ AND EXISTS (SELECT 1 FROM ${s}.provider_cluster_settings cs
12416
+ WHERE cs.singleton AND cs.bootstrapped_at IS NULL) THEN
12417
+ PERFORM ${s}.cms_provider_set_cluster_default(
12418
+ p_default->>'provider', p_default->>'model',
12419
+ p_default->>'reasoning', p_default->>'context', TRUE);
12420
+ UPDATE ${s}.provider_cluster_settings
12421
+ SET bootstrapped_at = now(), updated_at = now()
12422
+ WHERE singleton AND bootstrapped_at IS NULL;
12423
+ v_claimed := TRUE;
12424
+ END IF;
12425
+
12426
+ RETURN QUERY SELECT v_claimed, v_created;
12427
+ END;
12428
+ $$ LANGUAGE plpgsql VOLATILE;
12429
+
12430
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_rollback_legacy_session_models(
12431
+ p_is_admin BOOLEAN
12432
+ ) RETURNS INTEGER AS $$
12433
+ DECLARE v_count INTEGER;
12434
+ BEGIN
12435
+ IF NOT COALESCE(p_is_admin, FALSE) THEN
12436
+ RAISE EXCEPTION 'PROVIDER_FORBIDDEN: only an administrator can roll back migrated session models';
12437
+ END IF;
12438
+ UPDATE ${s}.sessions ss
12439
+ SET model = sm.original_model
12440
+ FROM ${s}.provider_legacy_session_models sm
12441
+ WHERE sm.session_id = ss.session_id
12442
+ AND ss.model = sm.migrated_model;
12443
+ GET DIAGNOSTICS v_count = ROW_COUNT;
12444
+ RETURN v_count;
12445
+ END;
12446
+ $$ LANGUAGE plpgsql VOLATILE;
12447
+
12448
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_clear_routing_dependencies(
12449
+ p_name TEXT, p_actor BIGINT, p_is_admin BOOLEAN
12450
+ ) RETURNS JSONB AS $$
12451
+ DECLARE
12452
+ v_row ${s}.provider_instances;
12453
+ v_cluster INTEGER := 0;
12454
+ v_system INTEGER := 0;
12455
+ v_users INTEGER := 0;
12456
+ v_overrides INTEGER := 0;
12457
+ BEGIN
12458
+ v_row := ${s}.cms_provider_assert_manage(p_name, p_actor, p_is_admin);
12459
+ IF v_row.class = 'shared' AND NOT COALESCE(p_is_admin, FALSE) THEN
12460
+ RAISE EXCEPTION 'PROVIDER_FORBIDDEN: only an administrator can clear shared-provider routing';
12461
+ END IF;
12462
+
12463
+ UPDATE ${s}.provider_cluster_settings cs
12464
+ SET default_provider = NULL, default_model = NULL,
12465
+ default_reasoning = NULL, default_context = NULL,
12466
+ updated_at = now()
12467
+ WHERE cs.singleton AND cs.default_provider = p_name;
12468
+ GET DIAGNOSTICS v_cluster = ROW_COUNT;
12469
+
12470
+ UPDATE ${s}.provider_cluster_settings cs
12471
+ SET system_default_provider = NULL, system_default_model = NULL,
12472
+ system_default_reasoning = NULL, system_default_context = NULL,
12473
+ system_default_updated_by = p_actor,
12474
+ system_default_updated_at = now(), updated_at = now()
12475
+ WHERE cs.singleton AND cs.system_default_provider = p_name;
12476
+ GET DIAGNOSTICS v_system = ROW_COUNT;
12477
+
12478
+ UPDATE ${s}.users u
12479
+ SET default_provider = NULL, default_model = NULL,
12480
+ default_reasoning = NULL, default_context = NULL
12481
+ WHERE u.default_provider = p_name
12482
+ AND (v_row.class = 'shared' OR u.user_id = p_actor);
12483
+ GET DIAGNOSTICS v_users = ROW_COUNT;
12484
+
12485
+ DELETE FROM ${s}.system_agent_model_overrides o WHERE o.provider_name = p_name;
12486
+ GET DIAGNOSTICS v_overrides = ROW_COUNT;
12487
+
12488
+ RETURN jsonb_build_object(
12489
+ 'clusterDefault', v_cluster,
12490
+ 'systemDefault', v_system,
12491
+ 'userDefaults', v_users,
12492
+ 'systemOverrides', v_overrides);
12493
+ END;
12494
+ $$ LANGUAGE plpgsql VOLATILE;
12495
+
12496
+ CREATE OR REPLACE FUNCTION ${s}.cms_update_session(
12497
+ p_session_id TEXT,
12498
+ p_updates JSONB
12499
+ ) RETURNS VOID AS $$
12500
+ BEGIN
12501
+ UPDATE ${s}.sessions SET
12502
+ orchestration_id = CASE WHEN p_updates ? 'orchestrationId' THEN (p_updates->>'orchestrationId') ELSE orchestration_id END,
12503
+ title = CASE WHEN p_updates ? 'title' THEN (p_updates->>'title') ELSE title END,
12504
+ title_locked = CASE WHEN p_updates ? 'titleLocked' THEN (p_updates->>'titleLocked')::BOOLEAN ELSE title_locked END,
12505
+ state = CASE WHEN p_updates ? 'state' THEN (p_updates->>'state') ELSE state END,
12506
+ model = CASE WHEN p_updates ? 'model' THEN (p_updates->>'model') ELSE model END,
12507
+ reasoning_effort = CASE WHEN p_updates ? 'reasoningEffort' THEN NULLIF(BTRIM(p_updates->>'reasoningEffort'), '') ELSE reasoning_effort END,
12508
+ context_tier = CASE WHEN p_updates ? 'contextTier' THEN NULLIF(BTRIM(p_updates->>'contextTier'), '') ELSE context_tier END,
12509
+ model_resolution_source = CASE WHEN p_updates ? 'modelResolutionSource' THEN NULLIF(BTRIM(p_updates->>'modelResolutionSource'), '') ELSE model_resolution_source END,
12510
+ last_active_at = CASE WHEN p_updates ? 'lastActiveAt' THEN (p_updates->>'lastActiveAt')::TIMESTAMPTZ ELSE last_active_at END,
12511
+ current_iteration = CASE WHEN p_updates ? 'currentIteration' THEN (p_updates->>'currentIteration')::INT ELSE current_iteration END,
12512
+ last_error = CASE WHEN p_updates ? 'lastError' THEN (p_updates->>'lastError') ELSE last_error END,
12513
+ wait_reason = CASE WHEN p_updates ? 'waitReason' THEN (p_updates->>'waitReason') ELSE wait_reason END,
12514
+ is_system = CASE WHEN p_updates ? 'isSystem' THEN (p_updates->>'isSystem')::BOOLEAN ELSE is_system END,
12515
+ agent_id = CASE WHEN p_updates ? 'agentId' THEN (p_updates->>'agentId') ELSE agent_id END,
12516
+ splash = CASE WHEN p_updates ? 'splash' THEN (p_updates->>'splash') ELSE splash END,
12517
+ splash_mobile = CASE WHEN p_updates ? 'splashMobile' THEN (p_updates->>'splashMobile') ELSE splash_mobile END,
12518
+ active_turn_index = CASE WHEN (p_updates ? 'state') AND (p_updates->>'state') <> 'running' THEN NULL ELSE active_turn_index END,
12519
+ updated_at = now()
12520
+ WHERE session_id = p_session_id;
12521
+
12522
+ UPDATE ${s}.session_metrics
12523
+ SET model = CASE WHEN p_updates ? 'model' THEN (p_updates->>'model') ELSE model END,
12524
+ reasoning_effort = CASE WHEN p_updates ? 'reasoningEffort' THEN NULLIF(BTRIM(p_updates->>'reasoningEffort'), '') ELSE reasoning_effort END,
12525
+ updated_at = CASE WHEN p_updates ? 'model' OR p_updates ? 'reasoningEffort' THEN now() ELSE updated_at END
12526
+ WHERE session_id = p_session_id
12527
+ AND (p_updates ? 'model' OR p_updates ? 'reasoningEffort');
12528
+ END;
12529
+ $$ LANGUAGE plpgsql VOLATILE;
12530
+
12531
+ ${authoritativeCheck}
12532
+ `;
12533
+ }
12534
+ // ─── Migration 0061: provider correctness fixes ──────────────────────
12535
+ //
12536
+ // Two defects from the 2026-08-24 campaign, both in code that already
12537
+ // shipped in this lineage — so both land as REPLACEMENTS here, never as
12538
+ // edits to an applied migration.
12539
+ //
12540
+ // 1. A recycled provider NAME must not inherit the old holder's spend in
12541
+ // ANY section of the usage report. The bound landed on the daily series
12542
+ // (the chart the portal draws) and nowhere else, so one response
12543
+ // contradicted itself: totals and breakdown counted ledger rows from
12544
+ // before the current instance existed while daily excluded them. The
12545
+ // same provider-epoch bound now applies to all three.
12546
+ //
12547
+ // 2. cms_provider_next_turn_index: the ledger PK (session_id, turn_index)
12548
+ // is the exactly-once settlement claim. A restarted system session
12549
+ // keeps its session_id while its fresh orchestration counts turns from
12550
+ // zero again, so once the new lifetime reached an index the old one had
12551
+ // written, settle's ON CONFLICT DO NOTHING read every turn as a replay
12552
+ // and the spend vanished. The restart path now seeds the new lifetime's
12553
+ // first index from this function.
12554
+ function migration_0061_provider_correctness_fixes(schema) {
12555
+ const s = schema;
12556
+ return `
12557
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_usage_totals(
12558
+ p_viewer BIGINT, p_is_admin BOOLEAN, p_days INTEGER,
12559
+ p_owner BIGINT, p_provider TEXT, p_model TEXT, p_session TEXT, p_class TEXT
12560
+ ) RETURNS TABLE(tokens_total BIGINT, turns BIGINT, sessions BIGINT) AS $$
12561
+ SELECT COALESCE(sum(l.tokens_total), 0), count(*)::BIGINT,
12562
+ count(DISTINCT l.session_id)::BIGINT
12563
+ FROM ${s}.provider_usage_ledger l
12564
+ WHERE l.created_at >= now() - (COALESCE(p_days, 7) || ' days')::interval
12565
+ AND (COALESCE(p_is_admin, FALSE)
12566
+ OR l.owner_user_id IS NOT DISTINCT FROM p_viewer
12567
+ -- A named SHARED provider's TOTAL is open to everyone who may
12568
+ -- spend from it; the moment an attribution filter is present
12569
+ -- the question becomes admin-only. (Unchanged from 0051.)
12570
+ OR (p_provider IS NOT NULL AND p_owner IS NULL AND p_session IS NULL
12571
+ AND EXISTS (
12572
+ SELECT 1 FROM ${s}.provider_instances pi
12573
+ WHERE pi.name = p_provider AND pi.class = 'shared')))
12574
+ AND (p_owner IS NULL OR l.owner_user_id = p_owner)
12575
+ AND (p_provider IS NULL OR l.provider_name = p_provider)
12576
+ -- Nothing from before THIS provider took the name.
12577
+ AND (p_provider IS NULL OR l.created_at >= COALESCE(
12578
+ (SELECT pi.created_at FROM ${s}.provider_instances pi WHERE pi.name = p_provider),
12579
+ '-infinity'::timestamptz))
12580
+ AND (p_model IS NULL OR l.model_qualified = p_model)
12581
+ AND (p_session IS NULL OR l.session_id = p_session)
12582
+ AND (p_class IS NULL OR l.charge_class = p_class);
12583
+ $$ LANGUAGE sql STABLE;
12584
+
12585
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_usage_breakdown(
12586
+ p_viewer BIGINT, p_is_admin BOOLEAN, p_days INTEGER,
12587
+ p_owner BIGINT, p_provider TEXT, p_model TEXT, p_session TEXT, p_class TEXT,
12588
+ p_dim TEXT, p_limit INTEGER
12589
+ ) RETURNS TABLE(key TEXT, label TEXT, tokens_total BIGINT, turns BIGINT) AS $$
12590
+ WITH rows AS (
12591
+ SELECT l.*,
12592
+ CASE p_dim
12593
+ WHEN 'session' THEN l.session_id
12594
+ WHEN 'provider' THEN COALESCE(l.provider_name, '(none)')
12595
+ WHEN 'model' THEN COALESCE(l.model_qualified, '(none)')
12596
+ WHEN 'agent' THEN COALESCE(l.agent_id, '(none)')
12597
+ WHEN 'user' THEN COALESCE(l.owner_user_id::TEXT,
12598
+ CASE WHEN l.charge_class = 'system'
12599
+ THEN '(system)' ELSE '(unowned)' END)
12600
+ ELSE COALESCE(l.provider_name, '(none)')
12601
+ END AS dim_key
12602
+ FROM ${s}.provider_usage_ledger l
12603
+ WHERE l.created_at >= now() - (COALESCE(p_days, 7) || ' days')::interval
12604
+ AND (COALESCE(p_is_admin, FALSE) OR l.owner_user_id IS NOT DISTINCT FROM p_viewer)
12605
+ AND (p_owner IS NULL OR l.owner_user_id = p_owner)
12606
+ AND (p_provider IS NULL OR l.provider_name = p_provider)
12607
+ -- Nothing from before THIS provider took the name.
12608
+ AND (p_provider IS NULL OR l.created_at >= COALESCE(
12609
+ (SELECT pi.created_at FROM ${s}.provider_instances pi WHERE pi.name = p_provider),
12610
+ '-infinity'::timestamptz))
12611
+ AND (p_model IS NULL OR l.model_qualified = p_model)
12612
+ AND (p_session IS NULL OR l.session_id = p_session)
12613
+ AND (p_class IS NULL OR l.charge_class = p_class)
12614
+ )
12615
+ SELECT g.dim_key,
12616
+ CASE p_dim
12617
+ WHEN 'session' THEN COALESCE((SELECT ss.title FROM ${s}.sessions ss
12618
+ WHERE ss.session_id = g.dim_key), g.dim_key)
12619
+ WHEN 'user' THEN COALESCE((SELECT COALESCE(uu.display_name, uu.email, uu.subject)
12620
+ FROM ${s}.users uu
12621
+ WHERE uu.user_id::TEXT = g.dim_key), g.dim_key)
12622
+ ELSE g.dim_key
12623
+ END,
12624
+ sum(g.tokens_total)::BIGINT, count(*)::BIGINT
12625
+ FROM rows g
12626
+ GROUP BY g.dim_key
12627
+ ORDER BY 3 DESC
12628
+ LIMIT COALESCE(p_limit, 40);
12629
+ $$ LANGUAGE sql STABLE;
12630
+
12631
+ -- The next free settlement index for a session, across every lifetime the
12632
+ -- ledger has seen. -1+1 = 0 for a session with no spend at all.
12633
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_next_turn_index(
12634
+ p_session_id TEXT
12635
+ ) RETURNS INTEGER AS $$
12636
+ SELECT (COALESCE(MAX(l.turn_index), -1) + 1)::INTEGER
12637
+ FROM ${s}.provider_usage_ledger l
12638
+ WHERE l.session_id = p_session_id;
12639
+ $$ LANGUAGE sql STABLE;
12640
+ `;
12641
+ }
12642
+ // ─── Migration 0062: the ledger survives a retained session id ───────
12643
+ //
12644
+ // The ledger PK (session_id, turn_index) is the exactly-once settlement
12645
+ // claim. A restarted system session keeps its session_id while its fresh
12646
+ // orchestration counts turns from zero, so the new lifetime's indexes
12647
+ // collide with the old lifetime's rows and ON CONFLICT DO NOTHING reads
12648
+ // every one as a replay — the spend silently vanishes (live-proved: a
12649
+ // 31K-token sweeper turn wrote no row).
12650
+ //
12651
+ // The orchestration input cannot carry the correction: input.iteration is
12652
+ // the resume/bootstrap discriminator, and seeding it makes a fresh
12653
+ // lifetime try to resume a session file it does not have (also
12654
+ // live-proved — an unbroken resume fail-loop). So the base lives on the
12655
+ // SESSION ROW, the restart path advances it past every existing row, and
12656
+ // the settle proc applies it. The orchestration keeps counting from zero;
12657
+ // only the ledger key moves.
12658
+ function migration_0062_provider_ledger_base(schema) {
12659
+ const s = schema;
12660
+ return `
12661
+ ALTER TABLE ${s}.sessions
12662
+ ADD COLUMN IF NOT EXISTS provider_ledger_base INTEGER NOT NULL DEFAULT 0;
12663
+
12664
+ -- Advance the base past every ledger row the session id already has.
12665
+ -- Idempotent: with no new spend, a second bump computes the same base.
12666
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_bump_ledger_base(
12667
+ p_session_id TEXT
12668
+ ) RETURNS INTEGER AS $$
12669
+ DECLARE v_base INTEGER;
12670
+ BEGIN
12671
+ SELECT ${s}.cms_provider_next_turn_index(p_session_id) INTO v_base;
12672
+ UPDATE ${s}.sessions
12673
+ SET provider_ledger_base = v_base
12674
+ WHERE session_id = p_session_id;
12675
+ RETURN v_base;
12676
+ END;
12677
+ $$ LANGUAGE plpgsql VOLATILE;
12678
+
12679
+ CREATE OR REPLACE FUNCTION ${s}.cms_provider_settle_turn(
12680
+ p_session_id TEXT, p_turn_index INTEGER, p_provider TEXT, p_model TEXT,
12681
+ p_owner BIGINT, p_charge_class TEXT, p_agent_id TEXT,
12682
+ p_in BIGINT, p_out BIGINT, p_cache_read BIGINT, p_cache_write BIGINT
12683
+ ) RETURNS BOOLEAN AS $$
12684
+ DECLARE
12685
+ v_total BIGINT := COALESCE(p_in,0) + COALESCE(p_out,0)
12686
+ + COALESCE(p_cache_read,0) + COALESCE(p_cache_write,0);
12687
+ v_class TEXT := COALESCE(NULLIF(BTRIM(COALESCE(p_charge_class,'')), ''), 'user');
12688
+ v_scope TEXT := COALESCE(NULLIF(BTRIM(COALESCE(p_model,'')), ''), '*');
12689
+ -- The lifetime offset. Stable for the whole turn: only a restart moves
12690
+ -- it, and a restart deletes the orchestration mid-turn anyway.
12691
+ v_index INTEGER := COALESCE(p_turn_index, 0) + COALESCE(
12692
+ (SELECT ss.provider_ledger_base FROM ${s}.sessions ss
12693
+ WHERE ss.session_id = p_session_id), 0);
12694
+ v_first INTEGER;
12695
+ BEGIN
12696
+ IF p_provider IS NULL THEN v_class := 'unattributed'; END IF;
12697
+
12698
+ INSERT INTO ${s}.provider_usage_ledger
12699
+ (session_id, turn_index, provider_name, model_qualified, owner_user_id,
12700
+ charge_class, tokens_input, tokens_output, tokens_cache_read,
12701
+ tokens_cache_write, tokens_total, agent_id)
12702
+ VALUES (p_session_id, v_index, p_provider, p_model, p_owner,
12703
+ v_class, COALESCE(p_in,0), COALESCE(p_out,0), COALESCE(p_cache_read,0),
12704
+ COALESCE(p_cache_write,0), v_total, p_agent_id)
12705
+ ON CONFLICT (session_id, turn_index) DO NOTHING;
12706
+ GET DIAGNOSTICS v_first = ROW_COUNT;
12707
+ IF v_first = 0 THEN RETURN FALSE; END IF;
12708
+
12709
+ IF v_class <> 'user' OR p_provider IS NULL OR v_total <= 0 THEN
12710
+ RETURN TRUE;
12711
+ END IF;
12712
+
12713
+ PERFORM 1 FROM ${s}.provider_instances pi
12714
+ WHERE pi.name = p_provider FOR KEY SHARE;
12715
+ IF NOT FOUND THEN RETURN TRUE; END IF;
12716
+
12717
+ INSERT INTO ${s}.provider_meters
12718
+ (provider_name, period, scope, window_key_utc, used_tokens,
12719
+ window_start_utc, resets_at_utc)
12720
+ SELECT p_provider, per.period, sc.scope, wb.window_key, v_total,
12721
+ wb.window_start, wb.resets_at
12722
+ FROM (VALUES ('day'),('week'),('month')) AS per(period)
12723
+ CROSS JOIN LATERAL ${s}.cms_provider_window_bounds(per.period, now()) wb
12724
+ CROSS JOIN (SELECT DISTINCT v.s FROM (VALUES ('*'), (v_scope)) AS v(s)) AS sc(scope)
12725
+ ORDER BY per.period, sc.scope
12726
+ ON CONFLICT (provider_name, period, scope, window_key_utc) DO UPDATE
12727
+ SET used_tokens = ${s}.provider_meters.used_tokens + EXCLUDED.used_tokens,
12728
+ updated_at = now();
12729
+
12730
+ IF p_owner IS NOT NULL THEN
12731
+ INSERT INTO ${s}.provider_meters_user
12732
+ (provider_name, period, scope, window_key_utc, user_id, used_tokens,
12733
+ window_start_utc, resets_at_utc)
12734
+ SELECT p_provider, per.period, sc.scope, wb.window_key, p_owner, v_total,
12735
+ wb.window_start, wb.resets_at
12736
+ FROM (VALUES ('day'),('week'),('month')) AS per(period)
12737
+ CROSS JOIN LATERAL ${s}.cms_provider_window_bounds(per.period, now()) wb
12738
+ CROSS JOIN (SELECT DISTINCT v.s FROM (VALUES ('*'), (v_scope)) AS v(s)) AS sc(scope)
12739
+ ORDER BY per.period, sc.scope
12740
+ ON CONFLICT (provider_name, period, scope, window_key_utc, user_id) DO UPDATE
12741
+ SET used_tokens = ${s}.provider_meters_user.used_tokens + EXCLUDED.used_tokens,
12742
+ updated_at = now();
12743
+ END IF;
12744
+
12745
+ RETURN TRUE;
12746
+ END;
12747
+ $$ LANGUAGE plpgsql VOLATILE;
12748
+ `;
12749
+ }
9212
12750
  //# sourceMappingURL=cms-migrations.js.map