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.
- package/README.md +2 -1
- package/api/index.d.ts +22 -1
- package/api/index.js +5 -0
- package/api/src/api-client.js +20 -1
- package/api/src/http-api-transport.js +102 -1
- package/api/src/protocol.js +39 -4
- package/api/src/surface-contract.js +44 -0
- package/dist/agent-manager-tools.d.ts +2 -0
- package/dist/agent-manager-tools.d.ts.map +1 -1
- package/dist/agent-manager-tools.js +24 -15
- package/dist/agent-manager-tools.js.map +1 -1
- package/dist/agent-package-service.d.ts +9 -0
- package/dist/agent-package-service.d.ts.map +1 -1
- package/dist/agent-package-service.js +46 -2
- package/dist/agent-package-service.js.map +1 -1
- package/dist/client.d.ts +2 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +82 -19
- package/dist/client.js.map +1 -1
- package/dist/cms-migrations.d.ts.map +1 -1
- package/dist/cms-migrations.js +3538 -0
- package/dist/cms-migrations.js.map +1 -1
- package/dist/cms.d.ts +25 -0
- package/dist/cms.d.ts.map +1 -1
- package/dist/cms.js +43 -4
- package/dist/cms.js.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/managed-session.d.ts +9 -1
- package/dist/managed-session.d.ts.map +1 -1
- package/dist/managed-session.js +40 -8
- package/dist/managed-session.js.map +1 -1
- package/dist/management-client.d.ts +410 -1
- package/dist/management-client.d.ts.map +1 -1
- package/dist/management-client.js +1254 -78
- package/dist/management-client.js.map +1 -1
- package/dist/model-providers.d.ts +13 -3
- package/dist/model-providers.d.ts.map +1 -1
- package/dist/model-providers.js +50 -13
- package/dist/model-providers.js.map +1 -1
- package/dist/orchestration/index.d.ts +2 -2
- package/dist/orchestration/index.js +1 -1
- package/dist/orchestration/lifecycle.d.ts.map +1 -1
- package/dist/orchestration/lifecycle.js +6 -3
- package/dist/orchestration/lifecycle.js.map +1 -1
- package/dist/orchestration/queue.d.ts.map +1 -1
- package/dist/orchestration/queue.js +1 -0
- package/dist/orchestration/queue.js.map +1 -1
- package/dist/orchestration/runtime.d.ts +1 -1
- package/dist/orchestration/state.d.ts +20 -0
- package/dist/orchestration/state.d.ts.map +1 -1
- package/dist/orchestration/state.js +1 -0
- package/dist/orchestration/state.js.map +1 -1
- package/dist/orchestration/turn.d.ts.map +1 -1
- package/dist/orchestration/turn.js +134 -3
- package/dist/orchestration/turn.js.map +1 -1
- package/dist/orchestration-registry.d.ts.map +1 -1
- package/dist/orchestration-registry.js +4 -2
- package/dist/orchestration-registry.js.map +1 -1
- package/dist/orchestration-version.d.ts +1 -1
- package/dist/orchestration-version.js +1 -1
- package/dist/orchestration.d.ts +2 -2
- package/dist/orchestration.js +1 -1
- package/dist/orchestration_1_0_46.d.ts +1 -1
- package/dist/orchestration_1_0_47.d.ts +1 -1
- package/dist/orchestration_1_0_48.d.ts +1 -1
- package/dist/orchestration_1_0_49.d.ts +1 -1
- package/dist/orchestration_1_0_50.d.ts +1 -1
- package/dist/orchestration_1_0_68/agents.d.ts +61 -0
- package/dist/orchestration_1_0_68/agents.d.ts.map +1 -0
- package/dist/orchestration_1_0_68/agents.js +840 -0
- package/dist/orchestration_1_0_68/agents.js.map +1 -0
- package/dist/orchestration_1_0_68/index.d.ts +30 -0
- package/dist/orchestration_1_0_68/index.d.ts.map +1 -0
- package/dist/orchestration_1_0_68/index.js +13 -0
- package/dist/orchestration_1_0_68/index.js.map +1 -0
- package/dist/orchestration_1_0_68/lifecycle.d.ts +49 -0
- package/dist/orchestration_1_0_68/lifecycle.d.ts.map +1 -0
- package/dist/orchestration_1_0_68/lifecycle.js +944 -0
- package/dist/orchestration_1_0_68/lifecycle.js.map +1 -0
- package/dist/orchestration_1_0_68/queue.d.ts +7 -0
- package/dist/orchestration_1_0_68/queue.d.ts.map +1 -0
- package/dist/orchestration_1_0_68/queue.js +804 -0
- package/dist/orchestration_1_0_68/queue.js.map +1 -0
- package/dist/orchestration_1_0_68/runtime.d.ts +29 -0
- package/dist/orchestration_1_0_68/runtime.d.ts.map +1 -0
- package/dist/orchestration_1_0_68/runtime.js +228 -0
- package/dist/orchestration_1_0_68/runtime.js.map +1 -0
- package/dist/orchestration_1_0_68/state.d.ts +176 -0
- package/dist/orchestration_1_0_68/state.d.ts.map +1 -0
- package/dist/orchestration_1_0_68/state.js +158 -0
- package/dist/orchestration_1_0_68/state.js.map +1 -0
- package/dist/orchestration_1_0_68/turn.d.ts +34 -0
- package/dist/orchestration_1_0_68/turn.d.ts.map +1 -0
- package/dist/orchestration_1_0_68/turn.js +1211 -0
- package/dist/orchestration_1_0_68/turn.js.map +1 -0
- package/dist/orchestration_1_0_68/utils.d.ts +39 -0
- package/dist/orchestration_1_0_68/utils.d.ts.map +1 -0
- package/dist/orchestration_1_0_68/utils.js +316 -0
- package/dist/orchestration_1_0_68/utils.js.map +1 -0
- package/dist/provider-budgets.d.ts +127 -0
- package/dist/provider-budgets.d.ts.map +1 -0
- package/dist/provider-budgets.js +182 -0
- package/dist/provider-budgets.js.map +1 -0
- package/dist/provider-catalog.d.ts +125 -0
- package/dist/provider-catalog.d.ts.map +1 -0
- package/dist/provider-catalog.js +299 -0
- package/dist/provider-catalog.js.map +1 -0
- package/dist/provider-store.d.ts +383 -0
- package/dist/provider-store.d.ts.map +1 -0
- package/dist/provider-store.js +514 -0
- package/dist/provider-store.js.map +1 -0
- package/dist/provider-tools.d.ts +47 -0
- package/dist/provider-tools.d.ts.map +1 -0
- package/dist/provider-tools.js +585 -0
- package/dist/provider-tools.js.map +1 -0
- package/dist/session-manager.d.ts +23 -2
- package/dist/session-manager.d.ts.map +1 -1
- package/dist/session-manager.js +144 -6
- package/dist/session-manager.js.map +1 -1
- package/dist/session-proxy.d.ts +4 -3
- package/dist/session-proxy.d.ts.map +1 -1
- package/dist/session-proxy.js +202 -11
- package/dist/session-proxy.js.map +1 -1
- package/dist/system-agents.d.ts +12 -0
- package/dist/system-agents.d.ts.map +1 -1
- package/dist/system-agents.js +54 -12
- package/dist/system-agents.js.map +1 -1
- package/dist/types.d.ts +19 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/web/generated-op-methods.d.ts +208 -2
- package/dist/web/generated-op-methods.d.ts.map +1 -1
- package/dist/web/generated-op-methods.js +50 -0
- package/dist/web/generated-op-methods.js.map +1 -1
- package/dist/web/web-management-client.d.ts +112 -2
- package/dist/web/web-management-client.d.ts.map +1 -1
- package/dist/web/web-management-client.js +163 -0
- package/dist/web/web-management-client.js.map +1 -1
- package/dist/worker.d.ts +9 -0
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +124 -19
- package/dist/worker.js.map +1 -1
- package/package.json +3 -3
- package/plugins/mgmt/agents/token-manager.agent.md +183 -0
- package/plugins/system/agents/default.agent.md +21 -7
- package/plugins/system/skills/canvas-apps/SKILL.md +158 -0
package/dist/cms-migrations.js
CHANGED
|
@@ -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
|