cartethyia 2.6.3 → 2.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dashboard/assets/{BannedPage-C8pfspAj.js → BannedPage-rvAOt5Ad.js} +1 -1
- package/dist/dashboard/assets/BannedPage-rvAOt5Ad.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-D-OwfzDV.js → CliToolDetailPage-ZoKZDsob.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-ZoKZDsob.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-DqRcDO5j.js → CliToolsPage-1_a6Tw8Y.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-1_a6Tw8Y.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-B-a0l8yE.js → CombosPage-Czo7GLxL.js} +1 -1
- package/dist/dashboard/assets/CombosPage-Czo7GLxL.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-BVVGUdqt.js → ConsoleLogPage-BrlBFGtF.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-BrlBFGtF.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-BzmbE05k.js → CustomizationPage-COvQuDTc.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-COvQuDTc.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-gTD1Qv_d.js +1 -0
- package/dist/dashboard/assets/HealthEventsModal-gTD1Qv_d.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-BdSGc5J0.js → LoginPage-BlGbMdYo.js} +1 -1
- package/dist/dashboard/assets/LoginPage-BlGbMdYo.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-DVny4S5_.js → ModelPicker-CA1kLs45.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-CA1kLs45.js.gz +0 -0
- package/dist/dashboard/assets/{OverviewPage-D3fMRkpP.js → OverviewPage-BhUFw9Qp.js} +1 -1
- package/dist/dashboard/assets/OverviewPage-BhUFw9Qp.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-CrBfX_gj.js +3 -0
- package/dist/dashboard/assets/ProviderDetailPage-CrBfX_gj.js.gz +0 -0
- package/dist/dashboard/assets/{ProvidersPage-T7WamMuq.js → ProvidersPage-CZ7sCKxk.js} +1 -1
- package/dist/dashboard/assets/ProvidersPage-CZ7sCKxk.js.gz +0 -0
- package/dist/dashboard/assets/{ProxyPage-CKJb_Kj4.js → ProxyPage-PnJWpC9C.js} +1 -1
- package/dist/dashboard/assets/ProxyPage-PnJWpC9C.js.gz +0 -0
- package/dist/dashboard/assets/{QuotaPage-hxJ1hGES.js → QuotaPage-CrGTXWjQ.js} +1 -1
- package/dist/dashboard/assets/QuotaPage-CrGTXWjQ.js.gz +0 -0
- package/dist/dashboard/assets/{SettingsPage-CpEErAq5.js → SettingsPage-DXjUbFh_.js} +1 -1
- package/dist/dashboard/assets/SettingsPage-DXjUbFh_.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-DRsoneXm.js → SetupPage-DCFo3R5_.js} +1 -1
- package/dist/dashboard/assets/SetupPage-DCFo3R5_.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-QvDz0eQo.js → SortableList-lFdVPMo_.js} +1 -1
- package/dist/dashboard/assets/SortableList-lFdVPMo_.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-6yLBksbv.js → StudioPage-DPbSWHny.js} +1 -1
- package/dist/dashboard/assets/StudioPage-DPbSWHny.js.gz +0 -0
- package/dist/dashboard/assets/{UsagePage-CpB88NH4.js → UsagePage-B-CTPrqB.js} +1 -1
- package/dist/dashboard/assets/UsagePage-B-CTPrqB.js.gz +0 -0
- package/dist/dashboard/assets/app-BUSiYL6F.css +2 -0
- package/dist/dashboard/assets/app-BUSiYL6F.css.gz +0 -0
- package/dist/dashboard/assets/{badge-CKsF123d.js → badge-Bkz_-RrM.js} +1 -1
- package/dist/dashboard/assets/badge-Bkz_-RrM.js.gz +0 -0
- package/dist/dashboard/assets/{banner-8j7uChA9.js → banner-C-7y3VKV.js} +1 -1
- package/dist/dashboard/assets/banner-C-7y3VKV.js.gz +0 -0
- package/dist/dashboard/assets/{card-CdsSlWA3.js → card-BOPBuBzD.js} +1 -1
- package/dist/dashboard/assets/card-BOPBuBzD.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-uL5aYLfW.js → clipboard-button-e-Mlk0FH.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-e-Mlk0FH.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-C07hr5_G.js → dialog-Iec_ywbD.js} +1 -1
- package/dist/dashboard/assets/dialog-Iec_ywbD.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-BPO1TcKH.js → drawer-Bs8MIIHc.js} +1 -1
- package/dist/dashboard/assets/drawer-Bs8MIIHc.js.gz +0 -0
- package/dist/dashboard/assets/{entry-Bz_CtDfC.js → entry-BRszm4_Z.js} +1 -1
- package/dist/dashboard/assets/entry-BRszm4_Z.js.gz +0 -0
- package/dist/dashboard/assets/{entry-8U_8IAW2.js → entry-BcfdW4C_.js} +3 -3
- package/dist/dashboard/assets/entry-BcfdW4C_.js.gz +0 -0
- package/dist/dashboard/assets/{entry-GJRbmrpA.js → entry-DcobaKFu.js} +1 -1
- package/dist/dashboard/assets/entry-DcobaKFu.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-DmQreSDd.js → github-badge-BuDsrsPn.js} +1 -1
- package/dist/dashboard/assets/github-badge-BuDsrsPn.js.gz +0 -0
- package/dist/dashboard/assets/{index-BG48fsZ2.js → index-_rbyCXD2.js} +2 -2
- package/dist/dashboard/assets/index-_rbyCXD2.js.gz +0 -0
- package/dist/dashboard/assets/{inline-C9dCYaBZ.js → inline-DToMICwl.js} +1 -1
- package/dist/dashboard/assets/inline-DToMICwl.js.gz +0 -0
- package/dist/dashboard/assets/{input-CjCnXweJ.js → input-WlyHWeG2.js} +1 -1
- package/dist/dashboard/assets/input-WlyHWeG2.js.gz +0 -0
- package/dist/dashboard/assets/{layout-V6y8DAkl.js → layout-DaJ5baJr.js} +1 -1
- package/dist/dashboard/assets/layout-DaJ5baJr.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-CM1AaG_y.js → list-row-B3oSK13z.js} +1 -1
- package/dist/dashboard/assets/list-row-B3oSK13z.js.gz +0 -0
- package/dist/dashboard/assets/{numbers-BOevJzdz.js → numbers-Dulqgys5.js} +1 -1
- package/dist/dashboard/assets/numbers-Dulqgys5.js.gz +0 -0
- package/dist/dashboard/assets/{select-AnGKpdl1.js → select-BTEzH1iH.js} +1 -1
- package/dist/dashboard/assets/select-BTEzH1iH.js.gz +0 -0
- package/dist/dashboard/assets/{stack-_gfTdLVB.js → stack-B1w5BM0u.js} +1 -1
- package/dist/dashboard/assets/stack-B1w5BM0u.js.gz +0 -0
- package/dist/dashboard/assets/{state-2c5V4zo7.js → state-DhZBfWgx.js} +1 -1
- package/dist/dashboard/assets/state-DhZBfWgx.js.gz +0 -0
- package/dist/dashboard/assets/{switch-Dkke7Naf.js → switch-yD7D99Lg.js} +1 -1
- package/dist/dashboard/assets/switch-yD7D99Lg.js.gz +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +41 -41
- package/package.json +96 -96
- package/src/boot/app.ts +11 -1
- package/src/console/providers/catalog/contracts.ts +7 -0
- package/src/console/providers/catalog/store.ts +25 -2
- package/src/observability/event-query.ts +10 -5
- package/src/providers/operations/account-health-service.ts +211 -17
- package/dist/dashboard/assets/BannedPage-C8pfspAj.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-D-OwfzDV.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-DqRcDO5j.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-B-a0l8yE.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-BVVGUdqt.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-BzmbE05k.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-BRDc_wx8.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-BRDc_wx8.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-BdSGc5J0.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-DVny4S5_.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-D3fMRkpP.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-DlBwPsHM.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-DlBwPsHM.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-T7WamMuq.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-CKJb_Kj4.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-hxJ1hGES.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-CpEErAq5.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-DRsoneXm.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-QvDz0eQo.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-6yLBksbv.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-CpB88NH4.js.gz +0 -0
- package/dist/dashboard/assets/app-CEeaMHOy.css +0 -2
- package/dist/dashboard/assets/app-CEeaMHOy.css.gz +0 -0
- package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
- package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
- package/dist/dashboard/assets/card-CdsSlWA3.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
- package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
- package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
- package/dist/dashboard/assets/entry-8U_8IAW2.js.gz +0 -0
- package/dist/dashboard/assets/entry-Bz_CtDfC.js.gz +0 -0
- package/dist/dashboard/assets/entry-GJRbmrpA.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
- package/dist/dashboard/assets/index-BG48fsZ2.js.gz +0 -0
- package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
- package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
- package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
- package/dist/dashboard/assets/list-row-CM1AaG_y.js.gz +0 -0
- package/dist/dashboard/assets/numbers-BOevJzdz.js.gz +0 -0
- package/dist/dashboard/assets/select-AnGKpdl1.js.gz +0 -0
- package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
- package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
- package/dist/dashboard/assets/switch-Dkke7Naf.js.gz +0 -0
- /package/dist/dashboard/assets/{app-2KhtHCv9.js → app-MdVfwraB.js} +0 -0
- /package/dist/dashboard/assets/{app-2KhtHCv9.js.gz → app-MdVfwraB.js.gz} +0 -0
package/package.json
CHANGED
|
@@ -1,96 +1,96 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "cartethyia",
|
|
3
|
-
"version": "2.6.
|
|
4
|
-
"description": "A powerful, self-hosted reverse proxy for OpenAI- and Anthropic-compatible AI clients, powered by native Bun and Elysia. It serves both protocols from a single server, routes every model across any configured provider, and translates requests and responses in real time — streaming and prompt caching included. Underneath it runs a full delivery engine: per-account health tracking, quota and cooldown enforcement, automatic failover across pools and providers, usage accounting, and telemetry. Point OpenCode, Cline, Claude Code, Codex, or any compatible client at Cartethyia and it keeps its native protocol while the gateway absorbs every provider-specific difference behind it. Runs on embedded SQLite or external PostgreSQL with optional Redis coordination, ships as a global CLI with a system-tray console, and needs only Bun 1.4+ alongside Node.js/npm.",
|
|
5
|
-
"license": "GPL-3.0-only",
|
|
6
|
-
"type": "module",
|
|
7
|
-
"packageManager": "bun@1.4.0",
|
|
8
|
-
"repository": {
|
|
9
|
-
"type": "git",
|
|
10
|
-
"url": "git+https://github.com/risunCodex/Cartethyia.git"
|
|
11
|
-
},
|
|
12
|
-
"homepage": "https://github.com/risunCodex/Cartethyia#readme",
|
|
13
|
-
"bugs": {
|
|
14
|
-
"url": "https://github.com/risunCodex/Cartethyia/issues"
|
|
15
|
-
},
|
|
16
|
-
"keywords": [
|
|
17
|
-
"ai",
|
|
18
|
-
"gateway",
|
|
19
|
-
"llm",
|
|
20
|
-
"bun",
|
|
21
|
-
"self-hosted"
|
|
22
|
-
],
|
|
23
|
-
"bin": {
|
|
24
|
-
"cartethyia": "scripts/cli/bin/cartethyia.cjs",
|
|
25
|
-
"carte": "scripts/cli/bin/carte.cjs"
|
|
26
|
-
},
|
|
27
|
-
"files": [
|
|
28
|
-
"scripts/cli/",
|
|
29
|
-
"src/",
|
|
30
|
-
"migrations/",
|
|
31
|
-
"dist/dashboard/",
|
|
32
|
-
".env.example",
|
|
33
|
-
"README.md"
|
|
34
|
-
],
|
|
35
|
-
"workspaces": [
|
|
36
|
-
"dashboard"
|
|
37
|
-
],
|
|
38
|
-
"engines": {
|
|
39
|
-
"bun": ">=1.4.0"
|
|
40
|
-
},
|
|
41
|
-
"scripts": {
|
|
42
|
-
"dev": "bun run dev:stack",
|
|
43
|
-
"dev:watch": "bun run scripts/dev/supervisor.ts",
|
|
44
|
-
"test": "bun run scripts/test/run.ts",
|
|
45
|
-
"build": "bun run scripts/build/build.ts",
|
|
46
|
-
"prepack": "bun run dashboard:build",
|
|
47
|
-
"setup": "bun run scripts/cli/setup.ts",
|
|
48
|
-
"start": "bun run scripts/cli/main.ts start",
|
|
49
|
-
"doctor": "bun run scripts/cli/doctor.ts",
|
|
50
|
-
"probe:egress": "bun run scripts/tools/probe-egress.ts",
|
|
51
|
-
"restart": "bun run scripts/build/restart.ts",
|
|
52
|
-
"build:aot": "bun run scripts/build/aot.ts",
|
|
53
|
-
"dev:stack": "concurrently -n BACKEND,DASHBOARD -c cyan,magenta --kill-others-on-fail \"bun run dev:backend\" \"bun run dashboard:dev\"",
|
|
54
|
-
"typecheck": "tsc --noEmit",
|
|
55
|
-
"test:watch": "bun run scripts/test/run.ts --watch",
|
|
56
|
-
"test-db:up": "docker compose -f docker-compose.test.yml up -d --wait postgres-test",
|
|
57
|
-
"dev:backend": "bun run --hot src/main.ts",
|
|
58
|
-
"dev:console-auth": "bun run scripts/test/console-session.ts",
|
|
59
|
-
"dev:visual-baseline": "bun run scripts/test/visual-baseline.ts",
|
|
60
|
-
"build:binary": "bun run scripts/build/compile.ts",
|
|
61
|
-
"test:backend": "bun run scripts/test/run.ts --scope backend",
|
|
62
|
-
"test:integration": "bun run scripts/test/run.ts --scope integration",
|
|
63
|
-
"test-db:down": "docker compose -f docker-compose.test.yml down",
|
|
64
|
-
"dashboard:dev": "bun run --cwd dashboard dev",
|
|
65
|
-
"test-db:check": "bun run scripts/test/check-db.ts",
|
|
66
|
-
"dashboard:test": "bun run scripts/test/run.ts --scope dashboard",
|
|
67
|
-
"test-db:reset": "docker compose -f docker-compose.test.yml down -v && docker compose -f docker-compose.test.yml up -d --wait postgres-test",
|
|
68
|
-
"dashboard:build": "bun run --cwd dashboard build",
|
|
69
|
-
"dashboard:typecheck": "bun run --cwd dashboard typecheck",
|
|
70
|
-
"check:migrations": "bun run scripts/test/check-migrations.ts"
|
|
71
|
-
},
|
|
72
|
-
"dependencies": {
|
|
73
|
-
"drizzle-orm": "0.45.2",
|
|
74
|
-
"elysia": "2.0.0-beta.16",
|
|
75
|
-
"enquirer": "^2.4.1",
|
|
76
|
-
"ioredis": "6.0.0",
|
|
77
|
-
"jose": "^6.2.12",
|
|
78
|
-
"jsonrepair": "^3.15.0",
|
|
79
|
-
"pg": "8.23.0",
|
|
80
|
-
"pino": "^10.3.1",
|
|
81
|
-
"pino-pretty": "^13.1.3",
|
|
82
|
-
"protobufjs": "^7.6.5",
|
|
83
|
-
"socks-proxy-agent": "^10.1.0",
|
|
84
|
-
"systray2": "2.1.4",
|
|
85
|
-
"typebox": "1.3.27",
|
|
86
|
-
"ws": "8.22.0"
|
|
87
|
-
},
|
|
88
|
-
"devDependencies": {
|
|
89
|
-
"@types/pg": "8.11.10",
|
|
90
|
-
"@types/ws": "8.18.1",
|
|
91
|
-
"bun-types": "1.4.0",
|
|
92
|
-
"concurrently": "^10.0.5",
|
|
93
|
-
"drizzle-kit": "^0.31.11",
|
|
94
|
-
"typescript": "5.9.2"
|
|
95
|
-
}
|
|
96
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "cartethyia",
|
|
3
|
+
"version": "2.6.4",
|
|
4
|
+
"description": "A powerful, self-hosted reverse proxy for OpenAI- and Anthropic-compatible AI clients, powered by native Bun and Elysia. It serves both protocols from a single server, routes every model across any configured provider, and translates requests and responses in real time — streaming and prompt caching included. Underneath it runs a full delivery engine: per-account health tracking, quota and cooldown enforcement, automatic failover across pools and providers, usage accounting, and telemetry. Point OpenCode, Cline, Claude Code, Codex, or any compatible client at Cartethyia and it keeps its native protocol while the gateway absorbs every provider-specific difference behind it. Runs on embedded SQLite or external PostgreSQL with optional Redis coordination, ships as a global CLI with a system-tray console, and needs only Bun 1.4+ alongside Node.js/npm.",
|
|
5
|
+
"license": "GPL-3.0-only",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"packageManager": "bun@1.4.0",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/risunCodex/Cartethyia.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/risunCodex/Cartethyia#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/risunCodex/Cartethyia/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"ai",
|
|
18
|
+
"gateway",
|
|
19
|
+
"llm",
|
|
20
|
+
"bun",
|
|
21
|
+
"self-hosted"
|
|
22
|
+
],
|
|
23
|
+
"bin": {
|
|
24
|
+
"cartethyia": "scripts/cli/bin/cartethyia.cjs",
|
|
25
|
+
"carte": "scripts/cli/bin/carte.cjs"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"scripts/cli/",
|
|
29
|
+
"src/",
|
|
30
|
+
"migrations/",
|
|
31
|
+
"dist/dashboard/",
|
|
32
|
+
".env.example",
|
|
33
|
+
"README.md"
|
|
34
|
+
],
|
|
35
|
+
"workspaces": [
|
|
36
|
+
"dashboard"
|
|
37
|
+
],
|
|
38
|
+
"engines": {
|
|
39
|
+
"bun": ">=1.4.0"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"dev": "bun run dev:stack",
|
|
43
|
+
"dev:watch": "bun run scripts/dev/supervisor.ts",
|
|
44
|
+
"test": "bun run scripts/test/run.ts",
|
|
45
|
+
"build": "bun run scripts/build/build.ts",
|
|
46
|
+
"prepack": "bun run dashboard:build",
|
|
47
|
+
"setup": "bun run scripts/cli/setup.ts",
|
|
48
|
+
"start": "bun run scripts/cli/main.ts start",
|
|
49
|
+
"doctor": "bun run scripts/cli/doctor.ts",
|
|
50
|
+
"probe:egress": "bun run scripts/tools/probe-egress.ts",
|
|
51
|
+
"restart": "bun run scripts/build/restart.ts",
|
|
52
|
+
"build:aot": "bun run scripts/build/aot.ts",
|
|
53
|
+
"dev:stack": "concurrently -n BACKEND,DASHBOARD -c cyan,magenta --kill-others-on-fail \"bun run dev:backend\" \"bun run dashboard:dev\"",
|
|
54
|
+
"typecheck": "tsc --noEmit",
|
|
55
|
+
"test:watch": "bun run scripts/test/run.ts --watch",
|
|
56
|
+
"test-db:up": "docker compose -f docker-compose.test.yml up -d --wait postgres-test",
|
|
57
|
+
"dev:backend": "bun run --hot src/main.ts",
|
|
58
|
+
"dev:console-auth": "bun run scripts/test/console-session.ts",
|
|
59
|
+
"dev:visual-baseline": "bun run scripts/test/visual-baseline.ts",
|
|
60
|
+
"build:binary": "bun run scripts/build/compile.ts",
|
|
61
|
+
"test:backend": "bun run scripts/test/run.ts --scope backend",
|
|
62
|
+
"test:integration": "bun run scripts/test/run.ts --scope integration",
|
|
63
|
+
"test-db:down": "docker compose -f docker-compose.test.yml down",
|
|
64
|
+
"dashboard:dev": "bun run --cwd dashboard dev",
|
|
65
|
+
"test-db:check": "bun run scripts/test/check-db.ts",
|
|
66
|
+
"dashboard:test": "bun run scripts/test/run.ts --scope dashboard",
|
|
67
|
+
"test-db:reset": "docker compose -f docker-compose.test.yml down -v && docker compose -f docker-compose.test.yml up -d --wait postgres-test",
|
|
68
|
+
"dashboard:build": "bun run --cwd dashboard build",
|
|
69
|
+
"dashboard:typecheck": "bun run --cwd dashboard typecheck",
|
|
70
|
+
"check:migrations": "bun run scripts/test/check-migrations.ts"
|
|
71
|
+
},
|
|
72
|
+
"dependencies": {
|
|
73
|
+
"drizzle-orm": "0.45.2",
|
|
74
|
+
"elysia": "2.0.0-beta.16",
|
|
75
|
+
"enquirer": "^2.4.1",
|
|
76
|
+
"ioredis": "6.0.0",
|
|
77
|
+
"jose": "^6.2.12",
|
|
78
|
+
"jsonrepair": "^3.15.0",
|
|
79
|
+
"pg": "8.23.0",
|
|
80
|
+
"pino": "^10.3.1",
|
|
81
|
+
"pino-pretty": "^13.1.3",
|
|
82
|
+
"protobufjs": "^7.6.5",
|
|
83
|
+
"socks-proxy-agent": "^10.1.0",
|
|
84
|
+
"systray2": "2.1.4",
|
|
85
|
+
"typebox": "1.3.27",
|
|
86
|
+
"ws": "8.22.0"
|
|
87
|
+
},
|
|
88
|
+
"devDependencies": {
|
|
89
|
+
"@types/pg": "8.11.10",
|
|
90
|
+
"@types/ws": "8.18.1",
|
|
91
|
+
"bun-types": "1.4.0",
|
|
92
|
+
"concurrently": "^10.0.5",
|
|
93
|
+
"drizzle-kit": "^0.31.11",
|
|
94
|
+
"typescript": "5.9.2"
|
|
95
|
+
}
|
|
96
|
+
}
|
package/src/boot/app.ts
CHANGED
|
@@ -269,7 +269,17 @@ export function createGatewayApp(deps: GatewayAppDeps) {
|
|
|
269
269
|
const INFLIGHT_BACKSTOP_INTERVAL_MS = 30_000;
|
|
270
270
|
const INFLIGHT_BACKSTOP_GRACE_MS = 30_000;
|
|
271
271
|
const INFLIGHT_BACKSTOP_HARD_GRACE_MS = 120_000;
|
|
272
|
-
|
|
272
|
+
// Registered in every mode, not just production. The gauge is the operator's
|
|
273
|
+
// concurrency signal, and a cancelled request that never ran a finalizer
|
|
274
|
+
// leaves its flight behind — gating the only thing that reaps those on
|
|
275
|
+
// production made a permanently wrong count depend on how the gateway was
|
|
276
|
+
// started. The registry exists in every mode, so there is no mode where
|
|
277
|
+
// running this is wrong.
|
|
278
|
+
// `scheduledTasks` is declared on the production shape only, so it is
|
|
279
|
+
// narrowed rather than reached through the union — a shell composition
|
|
280
|
+
// without the registry simply skips the backstop, exactly as before.
|
|
281
|
+
const backstopTasks = "scheduledTasks" in deps ? deps.scheduledTasks : undefined;
|
|
282
|
+
backstopTasks?.register({
|
|
273
283
|
name: "inflight-backstop",
|
|
274
284
|
intervalMs: INFLIGHT_BACKSTOP_INTERVAL_MS,
|
|
275
285
|
run: () => {
|
|
@@ -509,6 +509,13 @@ export interface ProviderAccountResponse {
|
|
|
509
509
|
credentialKind: CredentialKind;
|
|
510
510
|
credentialMode?: CredentialMode;
|
|
511
511
|
tokenExpiresAt?: string;
|
|
512
|
+
/**
|
|
513
|
+
* Who issued this credential, read from the JWT `iss` claim at registration
|
|
514
|
+
* time. Present only for static JWTs, and only when the payload carried the
|
|
515
|
+
* claim: the secret is never readable after registration, so this is the one
|
|
516
|
+
* record of where the token came from.
|
|
517
|
+
*/
|
|
518
|
+
jwtIssuer?: string;
|
|
512
519
|
status: string;
|
|
513
520
|
/** Live routing admission count for this request path; absent when unavailable. */
|
|
514
521
|
inflight?: number;
|
|
@@ -59,11 +59,26 @@ function parseExpiry(value: unknown): Date | undefined {
|
|
|
59
59
|
}
|
|
60
60
|
return undefined;
|
|
61
61
|
}
|
|
62
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Reads the two claims a static token's own payload can answer: when it stops
|
|
64
|
+
* working, and who issued it.
|
|
65
|
+
*
|
|
66
|
+
* The issuer is kept because a static JWT carries no other hint of its origin:
|
|
67
|
+
* an operator who pasted one from the wrong surface has nothing in the console
|
|
68
|
+
* to check it against, and several issuers accept the same token shape.
|
|
69
|
+
*/
|
|
70
|
+
function parseJwtCredential(
|
|
71
|
+
raw: string,
|
|
72
|
+
): { readonly expiresAt?: Date; readonly issuer?: string } | undefined {
|
|
63
73
|
const payload = decodeJwtPayload(raw.trim());
|
|
64
74
|
if (payload === undefined) return undefined;
|
|
65
75
|
const expiresAt = parseExpiry(payload["exp"]);
|
|
66
|
-
|
|
76
|
+
const iss = payload["iss"];
|
|
77
|
+
const issuer = typeof iss === "string" && iss.trim().length > 0 ? iss.trim() : undefined;
|
|
78
|
+
return {
|
|
79
|
+
...(expiresAt === undefined ? {} : { expiresAt }),
|
|
80
|
+
...(issuer === undefined ? {} : { issuer }),
|
|
81
|
+
};
|
|
67
82
|
}
|
|
68
83
|
|
|
69
84
|
/**
|
|
@@ -1124,6 +1139,7 @@ export class DrizzleProviderCatalogStore implements ProviderCatalogStore {
|
|
|
1124
1139
|
const requestedCredentialMode: CredentialMode = request.credentialMode ?? "auto";
|
|
1125
1140
|
let effectiveCredentialMode: CredentialMode = requestedCredentialMode;
|
|
1126
1141
|
let tokenExpiresAt: Date | undefined;
|
|
1142
|
+
let jwtIssuer: string | undefined;
|
|
1127
1143
|
let oauthRefreshCiphertext: Buffer | undefined;
|
|
1128
1144
|
let oauthExpiresAt: Date | undefined;
|
|
1129
1145
|
let oauthHasRefreshToken = false;
|
|
@@ -1160,6 +1176,9 @@ export class DrizzleProviderCatalogStore implements ProviderCatalogStore {
|
|
|
1160
1176
|
effectiveCredentialKind = "api_key";
|
|
1161
1177
|
effectiveCredentialMode = "jwt";
|
|
1162
1178
|
tokenExpiresAt = parsedJwt.expiresAt;
|
|
1179
|
+
// Kept alongside the expiry: the console shows who issued a static
|
|
1180
|
+
// token, and the secret itself is never readable after this point.
|
|
1181
|
+
jwtIssuer = parsedJwt.issuer;
|
|
1163
1182
|
} else if (request.credentialKind === "oauth" || hasRefreshToken) {
|
|
1164
1183
|
effectiveSecret = parts.accessToken;
|
|
1165
1184
|
effectiveCredentialKind = hasRefreshToken ? "oauth" : "api_key";
|
|
@@ -1185,6 +1204,7 @@ export class DrizzleProviderCatalogStore implements ProviderCatalogStore {
|
|
|
1185
1204
|
...(request.authState ?? {}),
|
|
1186
1205
|
credentialMode: effectiveCredentialMode,
|
|
1187
1206
|
...(tokenExpiresAt === undefined ? {} : { tokenExpiresAt: tokenExpiresAt.toISOString() }),
|
|
1207
|
+
...(jwtIssuer === undefined ? {} : { jwtIssuer }),
|
|
1188
1208
|
};
|
|
1189
1209
|
const nextSortIndex =
|
|
1190
1210
|
sortIndexOverride ?? (await this.nextAccountSortIndex(tx, providerId, tenantId));
|
|
@@ -1409,6 +1429,8 @@ export class DrizzleProviderCatalogStore implements ProviderCatalogStore {
|
|
|
1409
1429
|
: undefined;
|
|
1410
1430
|
const tokenExpiresAt =
|
|
1411
1431
|
typeof authState?.["tokenExpiresAt"] === "string" ? authState["tokenExpiresAt"] : undefined;
|
|
1432
|
+
const jwtIssuer =
|
|
1433
|
+
typeof authState?.["jwtIssuer"] === "string" ? authState["jwtIssuer"] : undefined;
|
|
1412
1434
|
return {
|
|
1413
1435
|
id: row.id,
|
|
1414
1436
|
providerId: row.providerId,
|
|
@@ -1417,6 +1439,7 @@ export class DrizzleProviderCatalogStore implements ProviderCatalogStore {
|
|
|
1417
1439
|
credentialKind: row.credentialKind,
|
|
1418
1440
|
...(credentialMode === undefined ? {} : { credentialMode }),
|
|
1419
1441
|
...(tokenExpiresAt === undefined ? {} : { tokenExpiresAt }),
|
|
1442
|
+
...(jwtIssuer === undefined ? {} : { jwtIssuer }),
|
|
1420
1443
|
status: row.status,
|
|
1421
1444
|
...(usage.inflight === undefined ? {} : { inflight: usage.inflight }),
|
|
1422
1445
|
usageToday: usage.today,
|
|
@@ -215,7 +215,8 @@ export function eventRecordFromRow(row: {
|
|
|
215
215
|
*/
|
|
216
216
|
function isPostgres(db: CartethyiaDatabase): boolean {
|
|
217
217
|
const dialect = (db as unknown as { dialect?: { constructor?: { name?: string } } }).dialect;
|
|
218
|
-
|
|
218
|
+
const name = dialect?.constructor?.name;
|
|
219
|
+
return name === "PostgresDialect" || name === "PgDialect";
|
|
219
220
|
}
|
|
220
221
|
|
|
221
222
|
/**
|
|
@@ -508,9 +509,14 @@ export async function chartTelemetry(
|
|
|
508
509
|
bucketSeconds: number,
|
|
509
510
|
): Promise<ChartBucket[]> {
|
|
510
511
|
const width = Math.max(1, Math.trunc(bucketSeconds));
|
|
512
|
+
// Inlined (not parameterised) so Postgres sees the SELECT and GROUP BY
|
|
513
|
+
// expressions as identical: parameter numbers differing between the two
|
|
514
|
+
// clauses made Postgres treat them as unrelated expressions referencing
|
|
515
|
+
// `created_at`, which 42803-rejected the query.
|
|
516
|
+
const w = sql.raw(String(width));
|
|
511
517
|
const bucketExpression = isPostgres(db)
|
|
512
|
-
? sql<number>`(cast(extract(epoch from ${telemetryEvents.createdAt}) as bigint) / ${
|
|
513
|
-
: sql<number>`(cast(strftime('%s', ${telemetryEvents.createdAt}) as integer) / ${
|
|
518
|
+
? sql<number>`(cast(extract(epoch from ${telemetryEvents.createdAt}) as bigint) / ${w}) * ${w}`
|
|
519
|
+
: sql<number>`(cast(strftime('%s', ${telemetryEvents.createdAt}) as integer) / ${w}) * ${w}`;
|
|
514
520
|
const rows = await db
|
|
515
521
|
.select({
|
|
516
522
|
at: bucketExpression,
|
|
@@ -521,8 +527,7 @@ export async function chartTelemetry(
|
|
|
521
527
|
})
|
|
522
528
|
.from(telemetryEvents)
|
|
523
529
|
.where(scoped(tenantId, since))
|
|
524
|
-
.groupBy(bucketExpression)
|
|
525
|
-
.orderBy(bucketExpression);
|
|
530
|
+
.groupBy(bucketExpression);
|
|
526
531
|
return rows.map((row) => ({
|
|
527
532
|
at: numeric(row.at) * 1000,
|
|
528
533
|
requests: numeric(row.requests),
|
|
@@ -160,7 +160,29 @@ export type AccountErrorCategory =
|
|
|
160
160
|
| "model_capacity"
|
|
161
161
|
| "model_unreachable"
|
|
162
162
|
| "auth_invalidated"
|
|
163
|
+
/**
|
|
164
|
+
* Deprecated: an upstream ban. Kept so existing health rows keep rendering
|
|
165
|
+
* instead of showing a blank cause. No longer produced — see
|
|
166
|
+
* `policy_blocked_banned` and `policy_blocked_invalid_request`.
|
|
167
|
+
*/
|
|
163
168
|
| "policy_blocked"
|
|
169
|
+
/**
|
|
170
|
+
* The upstream banned this credential: a verdict on the account, e.g. buddy
|
|
171
|
+
* `11140` "request illegal" with "Your account has been restricted due to
|
|
172
|
+
* unusual activity". Cooldown cannot help — the account is not throttled, it
|
|
173
|
+
* is refused. Disabling immediately is a *feature*: a client's retry budget
|
|
174
|
+
* is spent in seconds, so surviving attempts burn sibling accounts that are
|
|
175
|
+
* still healthy.
|
|
176
|
+
*/
|
|
177
|
+
| "policy_blocked_banned"
|
|
178
|
+
/**
|
|
179
|
+
* The upstream refused this request: a verdict on the request. e.g. buddy
|
|
180
|
+
* `11-128` "request illegal" with no account restriction. Distinguished from
|
|
181
|
+
* {@link policy_blocked_banned} because the correct responses differ — a
|
|
182
|
+
* banned account must be disabled, a malformed request must only stop being
|
|
183
|
+
* retried verbatim.
|
|
184
|
+
*/
|
|
185
|
+
| "policy_blocked_invalid_request"
|
|
164
186
|
| "server_error"
|
|
165
187
|
| "timeout"
|
|
166
188
|
| "unknown";
|
|
@@ -338,6 +360,35 @@ function accountDeadlineMs(
|
|
|
338
360
|
return latest > 0 ? new Date(latest) : null;
|
|
339
361
|
}
|
|
340
362
|
|
|
363
|
+
/**
|
|
364
|
+
* Whether the upstream named a restriction on the account itself.
|
|
365
|
+
*
|
|
366
|
+
* The buddy family reuses "request illegal" for two unrelated rejections, and
|
|
367
|
+
* the difference is only in the body:
|
|
368
|
+
*
|
|
369
|
+
* - `11140` carries `displayMsg` about the account being restricted ("Your
|
|
370
|
+
* account has been restricted due to unusual activity") and `actions`
|
|
371
|
+
* offering an appeal. That is a ban — the account is refused, not throttled.
|
|
372
|
+
* - `11-128` is a refusal of this request; the account is fine.
|
|
373
|
+
*
|
|
374
|
+
* Proven account-scoped (2026-10-16): two live share-page requests carrying
|
|
375
|
+
* byte-identical bodies and headers to the same model differed only in the
|
|
376
|
+
* pooled account, and the blocked one 400'd while the other 200'd.
|
|
377
|
+
*
|
|
378
|
+
* Matched on the code *and* the account-restriction wording, so an unrelated
|
|
379
|
+
* upstream that happens to say "restricted" in prose cannot disable accounts.
|
|
380
|
+
*/
|
|
381
|
+
function isBuddyAccountBanned(lower: string, providerCode: string): boolean {
|
|
382
|
+
if (providerCode !== "11140") return false;
|
|
383
|
+
return (
|
|
384
|
+
lower.includes("restricted") ||
|
|
385
|
+
lower.includes("has been banned") ||
|
|
386
|
+
lower.includes("unusual activity") ||
|
|
387
|
+
lower.includes("appeal this restriction") ||
|
|
388
|
+
lower.includes("submit feedback to appeal")
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
341
392
|
/**
|
|
342
393
|
* Bounded to a day, matching `MAX_COOLDOWN_MS` in the failure policy: a stated
|
|
343
394
|
* window longer than that is a monthly or plan-level limit, and parking an
|
|
@@ -421,6 +472,12 @@ interface AccountErrorContext {
|
|
|
421
472
|
readonly canMutate: boolean;
|
|
422
473
|
readonly providerCode: string;
|
|
423
474
|
readonly statedCooldownMs: number | null;
|
|
475
|
+
/**
|
|
476
|
+
* The upstream named a restriction on the account, not a refusal of the
|
|
477
|
+
* request. Read by the policy arm to pick between disabling the account and
|
|
478
|
+
* merely refusing the request.
|
|
479
|
+
*/
|
|
480
|
+
readonly buddyBanned: boolean;
|
|
424
481
|
readonly reason: (prefix: string) => string;
|
|
425
482
|
readonly result: (
|
|
426
483
|
category: AccountErrorCategory,
|
|
@@ -476,6 +533,13 @@ interface AccountErrorSignals {
|
|
|
476
533
|
readonly credentialKind: string | undefined;
|
|
477
534
|
/** Buddy-family provider: the only family with provider-specific codes below. */
|
|
478
535
|
readonly buddy: boolean;
|
|
536
|
+
/**
|
|
537
|
+
* The upstream named a restriction on this account, not a refusal of the
|
|
538
|
+
* request. Only the `11140` shape carries `displayMsg` about the account
|
|
539
|
+
* being restricted, so the policy arm can tell a ban from a bad request
|
|
540
|
+
* instead of treating both as one cooldown.
|
|
541
|
+
*/
|
|
542
|
+
readonly buddyBanned: boolean;
|
|
479
543
|
readonly priceRefusal: boolean;
|
|
480
544
|
readonly deploymentInfraError: boolean;
|
|
481
545
|
readonly quotaSignal: boolean;
|
|
@@ -601,6 +665,7 @@ function accountErrorContext(error: unknown, options?: AccountFailureEvidence):
|
|
|
601
665
|
canMutate,
|
|
602
666
|
providerCode,
|
|
603
667
|
statedCooldownMs,
|
|
668
|
+
buddyBanned: isBuddyAccountBanned(lower, providerCode),
|
|
604
669
|
reason,
|
|
605
670
|
result,
|
|
606
671
|
};
|
|
@@ -623,6 +688,7 @@ export function classifyAccountError(
|
|
|
623
688
|
providerCode,
|
|
624
689
|
|
|
625
690
|
statedCooldownMs,
|
|
691
|
+
buddyBanned,
|
|
626
692
|
reason,
|
|
627
693
|
result,
|
|
628
694
|
} = accountErrorContext(error, options);
|
|
@@ -761,19 +827,46 @@ export function classifyAccountError(
|
|
|
761
827
|
lower.includes("request illegal") ||
|
|
762
828
|
lower.includes("content blocked") ||
|
|
763
829
|
lower.includes("account suspended");
|
|
830
|
+
// A ban is a verdict on the credential, and cooldown cannot answer it: the
|
|
831
|
+
// account is refused, not throttled. Disabling is the fix, and disabling
|
|
832
|
+
// *immediately* is a feature — a client's retry budget is spent in seconds,
|
|
833
|
+
// so a banned account that merely cooled stayed in the plan and burned
|
|
834
|
+
// sibling accounts that were still healthy before the window expired.
|
|
835
|
+
//
|
|
836
|
+
// The two shapes are told apart by the body, not the code alone: `11140`
|
|
837
|
+
// names a restriction on the account, `11-128` refuses the request.
|
|
764
838
|
const policyBlock =
|
|
765
839
|
policyRejection &&
|
|
766
840
|
options?.providerId !== undefined &&
|
|
767
841
|
BUDDY_PROVIDER_IDS.has(options.providerId.toLowerCase());
|
|
768
842
|
if (policyBlock) {
|
|
769
|
-
|
|
843
|
+
if (buddyBanned) {
|
|
844
|
+
return result(
|
|
845
|
+
"policy_blocked_banned",
|
|
846
|
+
"disabled",
|
|
847
|
+
0,
|
|
848
|
+
null,
|
|
849
|
+
reason("Account banned by upstream"),
|
|
850
|
+
// A ban is evidence about the credential even at provider scope, for
|
|
851
|
+
// the same reason a named revocation is: the message is the evidence.
|
|
852
|
+
// Without this a stream frame's missing credential detail would leave a
|
|
853
|
+
// banned account serving.
|
|
854
|
+
true,
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
// Not a ban: the upstream refused this request. The account is fine, so the
|
|
858
|
+
// only correct move is to stop retrying it verbatim. Cooling the account
|
|
859
|
+
// here was the bug that made one malformed request look like a dead
|
|
860
|
+
// credential.
|
|
770
861
|
return result(
|
|
771
|
-
"
|
|
862
|
+
"policy_blocked_invalid_request",
|
|
772
863
|
"cooldown",
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
reason("
|
|
776
|
-
|
|
864
|
+
0,
|
|
865
|
+
null,
|
|
866
|
+
reason("Upstream refused this request"),
|
|
867
|
+
// Never mutates: a malformed body fails identically on every account, so
|
|
868
|
+
// cooling would burn a healthy credential on the gateway's own fault.
|
|
869
|
+
false,
|
|
777
870
|
);
|
|
778
871
|
}
|
|
779
872
|
// These codes are a verdict on the request, not the account. Cooling the
|
|
@@ -829,14 +922,30 @@ export function classifyAccountError(
|
|
|
829
922
|
options?.providerId !== undefined &&
|
|
830
923
|
BUDDY_PROVIDER_IDS.has(options.providerId.toLowerCase());
|
|
831
924
|
if (channelBlock) {
|
|
832
|
-
|
|
925
|
+
// Same split as the policy arm above: a ban disables, a channel refusal
|
|
926
|
+
// only stops this request. `11-128` is the request shape, so this branch
|
|
927
|
+
// nearly always takes the refusal path — but it shares the ban test so a
|
|
928
|
+
// `11140`-shaped body arriving as a channel rejection still disables.
|
|
929
|
+
if (buddyBanned) {
|
|
930
|
+
return result(
|
|
931
|
+
"policy_blocked_banned",
|
|
932
|
+
"disabled",
|
|
933
|
+
0,
|
|
934
|
+
null,
|
|
935
|
+
reason("Account banned by upstream"),
|
|
936
|
+
true,
|
|
937
|
+
);
|
|
938
|
+
}
|
|
833
939
|
return result(
|
|
834
|
-
"
|
|
940
|
+
"policy_blocked_invalid_request",
|
|
835
941
|
"cooldown",
|
|
836
|
-
|
|
837
|
-
|
|
942
|
+
0,
|
|
943
|
+
null,
|
|
838
944
|
reason("Provider channel rejection"),
|
|
839
|
-
|
|
945
|
+
// The credential is valid; the upstream refuses this calling channel.
|
|
946
|
+
// Cooling was the bug: it kept a refused account in the plan, where a
|
|
947
|
+
// client's retry budget burned healthy siblings for the whole window.
|
|
948
|
+
false,
|
|
840
949
|
);
|
|
841
950
|
}
|
|
842
951
|
// server-side tool (web search, x search, web fetch), not evidence about the
|
|
@@ -907,6 +1016,7 @@ export function classifyAccountError(
|
|
|
907
1016
|
staticToken,
|
|
908
1017
|
credentialKind,
|
|
909
1018
|
buddy,
|
|
1019
|
+
buddyBanned,
|
|
910
1020
|
priceRefusal,
|
|
911
1021
|
deploymentInfraError,
|
|
912
1022
|
quotaSignal,
|
|
@@ -941,6 +1051,62 @@ export function classifyAccountError(
|
|
|
941
1051
|
* {@link AccountErrorRule}.
|
|
942
1052
|
*/
|
|
943
1053
|
const ACCOUNT_ERROR_RULES: readonly AccountErrorRule[] = [
|
|
1054
|
+
{
|
|
1055
|
+
// Must precede "rate-limit": this rejection is a 429 whose own text says
|
|
1056
|
+
// "rate limit", so the broader arm claimed it and cooled the account for
|
|
1057
|
+
// the full 15-minute rate-limit window.
|
|
1058
|
+
//
|
|
1059
|
+
// A full max-inflight is a statement about the gateway's own routing, not
|
|
1060
|
+
// about the credential: every account serving the model is busy right now.
|
|
1061
|
+
// Cooling them marks healthy accounts as `cooldown` for a limit their
|
|
1062
|
+
// provider never applied, which is what made every account read busy while
|
|
1063
|
+
// incoming requests piled up — the accounts were never the problem, the
|
|
1064
|
+
// ceiling was.
|
|
1065
|
+
name: "gateway-capacity-exhausted",
|
|
1066
|
+
match: (s) =>
|
|
1067
|
+
s.lower.includes("at its concurrency limit") ||
|
|
1068
|
+
s.lower.includes("every proxy pool for this provider is at its concurrency limit") ||
|
|
1069
|
+
s.lower.includes("the gateway is at its concurrency limit"),
|
|
1070
|
+
decide: (s) =>
|
|
1071
|
+
s.result(
|
|
1072
|
+
"model_capacity",
|
|
1073
|
+
// `status` is the status the account would move to; it is never
|
|
1074
|
+
// applied because `mutatesAccount` is false below, so this names the
|
|
1075
|
+
// shape rather than a transition. Zero cooldown is the point: no wait
|
|
1076
|
+
// is attached to the account, so the next request may use it at once.
|
|
1077
|
+
"cooldown",
|
|
1078
|
+
0,
|
|
1079
|
+
null,
|
|
1080
|
+
s.reason("Gateway concurrency ceiling reached; no account was at fault"),
|
|
1081
|
+
// Never mutates: there is nothing wrong with the account to record.
|
|
1082
|
+
false,
|
|
1083
|
+
),
|
|
1084
|
+
},
|
|
1085
|
+
{
|
|
1086
|
+
// Must precede the unclassified fallback: the fallback is account-blind and
|
|
1087
|
+
// would stamp this as a provider-wide failure, which is how one proxy
|
|
1088
|
+
// refusing CONNECT put every account of every provider behind that pool
|
|
1089
|
+
// into cooldown — opencode's dead front door taking codebuddy down with it.
|
|
1090
|
+
//
|
|
1091
|
+
// A CONNECT refusal is a statement about the proxy pool, not about a
|
|
1092
|
+
// credential: the tunnel was never established, so no upstream ever saw the
|
|
1093
|
+
// request and no account can be at fault. Scoped to the pool so the
|
|
1094
|
+
// provider's own accounts stay in rotation.
|
|
1095
|
+
name: "proxy-connect-refusal",
|
|
1096
|
+
match: (s) =>
|
|
1097
|
+
s.lower.includes("proxy connect failed") ||
|
|
1098
|
+
(s.lower.includes("connect") && s.lower.includes("tunnel") && s.lower.includes("refus")),
|
|
1099
|
+
decide: (s) =>
|
|
1100
|
+
s.result(
|
|
1101
|
+
"model_unreachable",
|
|
1102
|
+
"cooldown",
|
|
1103
|
+
0,
|
|
1104
|
+
null,
|
|
1105
|
+
s.reason("Proxy pool refused the CONNECT tunnel"),
|
|
1106
|
+
// Never mutates: the pool owns this failure, not the account.
|
|
1107
|
+
false,
|
|
1108
|
+
),
|
|
1109
|
+
},
|
|
944
1110
|
{
|
|
945
1111
|
name: "deployment-infrastructure",
|
|
946
1112
|
match: (s) => s.deploymentInfraError,
|
|
@@ -976,14 +1142,42 @@ const ACCOUNT_ERROR_RULES: readonly AccountErrorRule[] = [
|
|
|
976
1142
|
name: "content-policy-block",
|
|
977
1143
|
match: (s) => s.policyRejection && s.buddy,
|
|
978
1144
|
decide: (s) => {
|
|
979
|
-
|
|
1145
|
+
// A ban is a verdict on the credential, and cooldown cannot answer it:
|
|
1146
|
+
// the account is refused, not throttled. Disabling is the fix, and
|
|
1147
|
+
// disabling *immediately* is a feature — a client's retry budget is spent
|
|
1148
|
+
// in seconds, so a banned account that merely cooled stayed in the plan
|
|
1149
|
+
// and burned sibling accounts that were still healthy before the window
|
|
1150
|
+
// expired.
|
|
1151
|
+
//
|
|
1152
|
+
// The two shapes are told apart by the body, not the code alone: `11140`
|
|
1153
|
+
// names a restriction on the account, `11-128` refuses the request.
|
|
1154
|
+
if (s.buddyBanned) {
|
|
1155
|
+
return s.result(
|
|
1156
|
+
"policy_blocked_banned",
|
|
1157
|
+
"disabled",
|
|
1158
|
+
0,
|
|
1159
|
+
null,
|
|
1160
|
+
s.reason("Account banned by upstream"),
|
|
1161
|
+
// A ban is evidence about the credential even at provider scope, for
|
|
1162
|
+
// the same reason a named revocation is: the message is the evidence.
|
|
1163
|
+
// Without this a stream frame's missing credential detail would leave
|
|
1164
|
+
// a banned account serving.
|
|
1165
|
+
true,
|
|
1166
|
+
);
|
|
1167
|
+
}
|
|
1168
|
+
// Not a ban: the upstream refused this request. The account is fine, so
|
|
1169
|
+
// the only correct move is to stop retrying it verbatim. Cooling the
|
|
1170
|
+
// account here was the bug that made one malformed request look like a
|
|
1171
|
+
// dead credential.
|
|
980
1172
|
return s.result(
|
|
981
|
-
"
|
|
1173
|
+
"policy_blocked_invalid_request",
|
|
982
1174
|
"cooldown",
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
s.reason("
|
|
986
|
-
|
|
1175
|
+
0,
|
|
1176
|
+
null,
|
|
1177
|
+
s.reason("Upstream refused this request"),
|
|
1178
|
+
// Never mutates: a malformed body fails identically on every account,
|
|
1179
|
+
// so cooling would burn a healthy credential on the gateway's own fault.
|
|
1180
|
+
false,
|
|
987
1181
|
);
|
|
988
1182
|
},
|
|
989
1183
|
},
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|