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.
Files changed (134) hide show
  1. package/dist/dashboard/assets/{BannedPage-C8pfspAj.js → BannedPage-rvAOt5Ad.js} +1 -1
  2. package/dist/dashboard/assets/BannedPage-rvAOt5Ad.js.gz +0 -0
  3. package/dist/dashboard/assets/{CliToolDetailPage-D-OwfzDV.js → CliToolDetailPage-ZoKZDsob.js} +1 -1
  4. package/dist/dashboard/assets/CliToolDetailPage-ZoKZDsob.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolsPage-DqRcDO5j.js → CliToolsPage-1_a6Tw8Y.js} +1 -1
  6. package/dist/dashboard/assets/CliToolsPage-1_a6Tw8Y.js.gz +0 -0
  7. package/dist/dashboard/assets/{CombosPage-B-a0l8yE.js → CombosPage-Czo7GLxL.js} +1 -1
  8. package/dist/dashboard/assets/CombosPage-Czo7GLxL.js.gz +0 -0
  9. package/dist/dashboard/assets/{ConsoleLogPage-BVVGUdqt.js → ConsoleLogPage-BrlBFGtF.js} +1 -1
  10. package/dist/dashboard/assets/ConsoleLogPage-BrlBFGtF.js.gz +0 -0
  11. package/dist/dashboard/assets/{CustomizationPage-BzmbE05k.js → CustomizationPage-COvQuDTc.js} +1 -1
  12. package/dist/dashboard/assets/CustomizationPage-COvQuDTc.js.gz +0 -0
  13. package/dist/dashboard/assets/HealthEventsModal-gTD1Qv_d.js +1 -0
  14. package/dist/dashboard/assets/HealthEventsModal-gTD1Qv_d.js.gz +0 -0
  15. package/dist/dashboard/assets/{LoginPage-BdSGc5J0.js → LoginPage-BlGbMdYo.js} +1 -1
  16. package/dist/dashboard/assets/LoginPage-BlGbMdYo.js.gz +0 -0
  17. package/dist/dashboard/assets/{ModelPicker-DVny4S5_.js → ModelPicker-CA1kLs45.js} +1 -1
  18. package/dist/dashboard/assets/ModelPicker-CA1kLs45.js.gz +0 -0
  19. package/dist/dashboard/assets/{OverviewPage-D3fMRkpP.js → OverviewPage-BhUFw9Qp.js} +1 -1
  20. package/dist/dashboard/assets/OverviewPage-BhUFw9Qp.js.gz +0 -0
  21. package/dist/dashboard/assets/ProviderDetailPage-CrBfX_gj.js +3 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-CrBfX_gj.js.gz +0 -0
  23. package/dist/dashboard/assets/{ProvidersPage-T7WamMuq.js → ProvidersPage-CZ7sCKxk.js} +1 -1
  24. package/dist/dashboard/assets/ProvidersPage-CZ7sCKxk.js.gz +0 -0
  25. package/dist/dashboard/assets/{ProxyPage-CKJb_Kj4.js → ProxyPage-PnJWpC9C.js} +1 -1
  26. package/dist/dashboard/assets/ProxyPage-PnJWpC9C.js.gz +0 -0
  27. package/dist/dashboard/assets/{QuotaPage-hxJ1hGES.js → QuotaPage-CrGTXWjQ.js} +1 -1
  28. package/dist/dashboard/assets/QuotaPage-CrGTXWjQ.js.gz +0 -0
  29. package/dist/dashboard/assets/{SettingsPage-CpEErAq5.js → SettingsPage-DXjUbFh_.js} +1 -1
  30. package/dist/dashboard/assets/SettingsPage-DXjUbFh_.js.gz +0 -0
  31. package/dist/dashboard/assets/{SetupPage-DRsoneXm.js → SetupPage-DCFo3R5_.js} +1 -1
  32. package/dist/dashboard/assets/SetupPage-DCFo3R5_.js.gz +0 -0
  33. package/dist/dashboard/assets/{SortableList-QvDz0eQo.js → SortableList-lFdVPMo_.js} +1 -1
  34. package/dist/dashboard/assets/SortableList-lFdVPMo_.js.gz +0 -0
  35. package/dist/dashboard/assets/{StudioPage-6yLBksbv.js → StudioPage-DPbSWHny.js} +1 -1
  36. package/dist/dashboard/assets/StudioPage-DPbSWHny.js.gz +0 -0
  37. package/dist/dashboard/assets/{UsagePage-CpB88NH4.js → UsagePage-B-CTPrqB.js} +1 -1
  38. package/dist/dashboard/assets/UsagePage-B-CTPrqB.js.gz +0 -0
  39. package/dist/dashboard/assets/app-BUSiYL6F.css +2 -0
  40. package/dist/dashboard/assets/app-BUSiYL6F.css.gz +0 -0
  41. package/dist/dashboard/assets/{badge-CKsF123d.js → badge-Bkz_-RrM.js} +1 -1
  42. package/dist/dashboard/assets/badge-Bkz_-RrM.js.gz +0 -0
  43. package/dist/dashboard/assets/{banner-8j7uChA9.js → banner-C-7y3VKV.js} +1 -1
  44. package/dist/dashboard/assets/banner-C-7y3VKV.js.gz +0 -0
  45. package/dist/dashboard/assets/{card-CdsSlWA3.js → card-BOPBuBzD.js} +1 -1
  46. package/dist/dashboard/assets/card-BOPBuBzD.js.gz +0 -0
  47. package/dist/dashboard/assets/{clipboard-button-uL5aYLfW.js → clipboard-button-e-Mlk0FH.js} +1 -1
  48. package/dist/dashboard/assets/clipboard-button-e-Mlk0FH.js.gz +0 -0
  49. package/dist/dashboard/assets/{dialog-C07hr5_G.js → dialog-Iec_ywbD.js} +1 -1
  50. package/dist/dashboard/assets/dialog-Iec_ywbD.js.gz +0 -0
  51. package/dist/dashboard/assets/{drawer-BPO1TcKH.js → drawer-Bs8MIIHc.js} +1 -1
  52. package/dist/dashboard/assets/drawer-Bs8MIIHc.js.gz +0 -0
  53. package/dist/dashboard/assets/{entry-Bz_CtDfC.js → entry-BRszm4_Z.js} +1 -1
  54. package/dist/dashboard/assets/entry-BRszm4_Z.js.gz +0 -0
  55. package/dist/dashboard/assets/{entry-8U_8IAW2.js → entry-BcfdW4C_.js} +3 -3
  56. package/dist/dashboard/assets/entry-BcfdW4C_.js.gz +0 -0
  57. package/dist/dashboard/assets/{entry-GJRbmrpA.js → entry-DcobaKFu.js} +1 -1
  58. package/dist/dashboard/assets/entry-DcobaKFu.js.gz +0 -0
  59. package/dist/dashboard/assets/{github-badge-DmQreSDd.js → github-badge-BuDsrsPn.js} +1 -1
  60. package/dist/dashboard/assets/github-badge-BuDsrsPn.js.gz +0 -0
  61. package/dist/dashboard/assets/{index-BG48fsZ2.js → index-_rbyCXD2.js} +2 -2
  62. package/dist/dashboard/assets/index-_rbyCXD2.js.gz +0 -0
  63. package/dist/dashboard/assets/{inline-C9dCYaBZ.js → inline-DToMICwl.js} +1 -1
  64. package/dist/dashboard/assets/inline-DToMICwl.js.gz +0 -0
  65. package/dist/dashboard/assets/{input-CjCnXweJ.js → input-WlyHWeG2.js} +1 -1
  66. package/dist/dashboard/assets/input-WlyHWeG2.js.gz +0 -0
  67. package/dist/dashboard/assets/{layout-V6y8DAkl.js → layout-DaJ5baJr.js} +1 -1
  68. package/dist/dashboard/assets/layout-DaJ5baJr.js.gz +0 -0
  69. package/dist/dashboard/assets/{list-row-CM1AaG_y.js → list-row-B3oSK13z.js} +1 -1
  70. package/dist/dashboard/assets/list-row-B3oSK13z.js.gz +0 -0
  71. package/dist/dashboard/assets/{numbers-BOevJzdz.js → numbers-Dulqgys5.js} +1 -1
  72. package/dist/dashboard/assets/numbers-Dulqgys5.js.gz +0 -0
  73. package/dist/dashboard/assets/{select-AnGKpdl1.js → select-BTEzH1iH.js} +1 -1
  74. package/dist/dashboard/assets/select-BTEzH1iH.js.gz +0 -0
  75. package/dist/dashboard/assets/{stack-_gfTdLVB.js → stack-B1w5BM0u.js} +1 -1
  76. package/dist/dashboard/assets/stack-B1w5BM0u.js.gz +0 -0
  77. package/dist/dashboard/assets/{state-2c5V4zo7.js → state-DhZBfWgx.js} +1 -1
  78. package/dist/dashboard/assets/state-DhZBfWgx.js.gz +0 -0
  79. package/dist/dashboard/assets/{switch-Dkke7Naf.js → switch-yD7D99Lg.js} +1 -1
  80. package/dist/dashboard/assets/switch-yD7D99Lg.js.gz +0 -0
  81. package/dist/dashboard/index.html +1 -1
  82. package/dist/dashboard/index.html.gz +0 -0
  83. package/dist/dashboard/precompressed-assets.json +41 -41
  84. package/package.json +96 -96
  85. package/src/boot/app.ts +11 -1
  86. package/src/console/providers/catalog/contracts.ts +7 -0
  87. package/src/console/providers/catalog/store.ts +25 -2
  88. package/src/observability/event-query.ts +10 -5
  89. package/src/providers/operations/account-health-service.ts +211 -17
  90. package/dist/dashboard/assets/BannedPage-C8pfspAj.js.gz +0 -0
  91. package/dist/dashboard/assets/CliToolDetailPage-D-OwfzDV.js.gz +0 -0
  92. package/dist/dashboard/assets/CliToolsPage-DqRcDO5j.js.gz +0 -0
  93. package/dist/dashboard/assets/CombosPage-B-a0l8yE.js.gz +0 -0
  94. package/dist/dashboard/assets/ConsoleLogPage-BVVGUdqt.js.gz +0 -0
  95. package/dist/dashboard/assets/CustomizationPage-BzmbE05k.js.gz +0 -0
  96. package/dist/dashboard/assets/HealthEventsModal-BRDc_wx8.js +0 -1
  97. package/dist/dashboard/assets/HealthEventsModal-BRDc_wx8.js.gz +0 -0
  98. package/dist/dashboard/assets/LoginPage-BdSGc5J0.js.gz +0 -0
  99. package/dist/dashboard/assets/ModelPicker-DVny4S5_.js.gz +0 -0
  100. package/dist/dashboard/assets/OverviewPage-D3fMRkpP.js.gz +0 -0
  101. package/dist/dashboard/assets/ProviderDetailPage-DlBwPsHM.js +0 -3
  102. package/dist/dashboard/assets/ProviderDetailPage-DlBwPsHM.js.gz +0 -0
  103. package/dist/dashboard/assets/ProvidersPage-T7WamMuq.js.gz +0 -0
  104. package/dist/dashboard/assets/ProxyPage-CKJb_Kj4.js.gz +0 -0
  105. package/dist/dashboard/assets/QuotaPage-hxJ1hGES.js.gz +0 -0
  106. package/dist/dashboard/assets/SettingsPage-CpEErAq5.js.gz +0 -0
  107. package/dist/dashboard/assets/SetupPage-DRsoneXm.js.gz +0 -0
  108. package/dist/dashboard/assets/SortableList-QvDz0eQo.js.gz +0 -0
  109. package/dist/dashboard/assets/StudioPage-6yLBksbv.js.gz +0 -0
  110. package/dist/dashboard/assets/UsagePage-CpB88NH4.js.gz +0 -0
  111. package/dist/dashboard/assets/app-CEeaMHOy.css +0 -2
  112. package/dist/dashboard/assets/app-CEeaMHOy.css.gz +0 -0
  113. package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
  114. package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
  115. package/dist/dashboard/assets/card-CdsSlWA3.js.gz +0 -0
  116. package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
  117. package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
  118. package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
  119. package/dist/dashboard/assets/entry-8U_8IAW2.js.gz +0 -0
  120. package/dist/dashboard/assets/entry-Bz_CtDfC.js.gz +0 -0
  121. package/dist/dashboard/assets/entry-GJRbmrpA.js.gz +0 -0
  122. package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
  123. package/dist/dashboard/assets/index-BG48fsZ2.js.gz +0 -0
  124. package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
  125. package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
  126. package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
  127. package/dist/dashboard/assets/list-row-CM1AaG_y.js.gz +0 -0
  128. package/dist/dashboard/assets/numbers-BOevJzdz.js.gz +0 -0
  129. package/dist/dashboard/assets/select-AnGKpdl1.js.gz +0 -0
  130. package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
  131. package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
  132. package/dist/dashboard/assets/switch-Dkke7Naf.js.gz +0 -0
  133. /package/dist/dashboard/assets/{app-2KhtHCv9.js → app-MdVfwraB.js} +0 -0
  134. /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.3",
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
- if (deps.mode === "production") deps.scheduledTasks?.register({
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
- function parseJwtCredential(raw: string): { readonly expiresAt?: Date } | undefined {
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
- return expiresAt === undefined ? {} : { expiresAt };
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
- return dialect?.constructor?.name === "PostgresDialect";
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) / ${width}) * ${width}`
513
- : sql<number>`(cast(strftime('%s', ${telemetryEvents.createdAt}) as integer) / ${width}) * ${width}`;
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
- const policyBlockMs = resolveAccountPolicyBlockCooldownMs();
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
- "policy_blocked",
862
+ "policy_blocked_invalid_request",
772
863
  "cooldown",
773
- policyBlockMs,
774
- new Date(Date.now() + policyBlockMs),
775
- reason("Provider content-policy block"),
776
- origin === "upstream",
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
- const channelBlockMs = resolveAccountPolicyBlockCooldownMs();
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
- "policy_blocked",
940
+ "policy_blocked_invalid_request",
835
941
  "cooldown",
836
- channelBlockMs,
837
- new Date(Date.now() + channelBlockMs),
942
+ 0,
943
+ null,
838
944
  reason("Provider channel rejection"),
839
- origin === "upstream",
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
- const policyBlockMs = resolveAccountPolicyBlockCooldownMs();
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
- "policy_blocked",
1173
+ "policy_blocked_invalid_request",
982
1174
  "cooldown",
983
- policyBlockMs,
984
- new Date(Date.now() + policyBlockMs),
985
- s.reason("Provider content-policy block"),
986
- s.origin === "upstream",
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
  },