@cat-factory/server 0.166.2 → 0.169.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (232) hide show
  1. package/dist/agents/ContainerAgentExecutor.d.ts.map +1 -1
  2. package/dist/agents/ContainerAgentExecutor.js +15 -1
  3. package/dist/agents/ContainerAgentExecutor.js.map +1 -1
  4. package/dist/agents/ContainerEnvConfigRepairer.d.ts.map +1 -1
  5. package/dist/agents/ContainerEnvConfigRepairer.js +3 -0
  6. package/dist/agents/ContainerEnvConfigRepairer.js.map +1 -1
  7. package/dist/agents/ContainerRepoBootstrapper.d.ts.map +1 -1
  8. package/dist/agents/ContainerRepoBootstrapper.js +3 -0
  9. package/dist/agents/ContainerRepoBootstrapper.js.map +1 -1
  10. package/dist/agents/modelProviderResolver.d.ts +9 -1
  11. package/dist/agents/modelProviderResolver.d.ts.map +1 -1
  12. package/dist/agents/modelProviderResolver.js +1 -0
  13. package/dist/agents/modelProviderResolver.js.map +1 -1
  14. package/dist/agents/storeAgentContextGate.d.ts +36 -0
  15. package/dist/agents/storeAgentContextGate.d.ts.map +1 -0
  16. package/dist/agents/storeAgentContextGate.js +36 -0
  17. package/dist/agents/storeAgentContextGate.js.map +1 -0
  18. package/dist/auth/middleware.d.ts.map +1 -1
  19. package/dist/auth/middleware.js +2 -1
  20. package/dist/auth/middleware.js.map +1 -1
  21. package/dist/http/errorHandler.d.ts.map +1 -1
  22. package/dist/http/errorHandler.js +6 -0
  23. package/dist/http/errorHandler.js.map +1 -1
  24. package/dist/index.d.ts +2 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +5 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/modules/accounts/AccountController.d.ts.map +1 -1
  29. package/dist/modules/accounts/AccountController.js +37 -31
  30. package/dist/modules/accounts/AccountController.js.map +1 -1
  31. package/dist/modules/agentRuns/AgentRunController.d.ts.map +1 -1
  32. package/dist/modules/agentRuns/AgentRunController.js +9 -6
  33. package/dist/modules/agentRuns/AgentRunController.js.map +1 -1
  34. package/dist/modules/artifacts/ArtifactController.d.ts.map +1 -1
  35. package/dist/modules/artifacts/ArtifactController.js +8 -5
  36. package/dist/modules/artifacts/ArtifactController.js.map +1 -1
  37. package/dist/modules/artifacts/HarnessArtifactController.d.ts.map +1 -1
  38. package/dist/modules/artifacts/HarnessArtifactController.js +5 -4
  39. package/dist/modules/artifacts/HarnessArtifactController.js.map +1 -1
  40. package/dist/modules/auth/AuthController.d.ts.map +1 -1
  41. package/dist/modules/auth/AuthController.js +48 -50
  42. package/dist/modules/auth/AuthController.js.map +1 -1
  43. package/dist/modules/bootstrap/BootstrapController.d.ts.map +1 -1
  44. package/dist/modules/bootstrap/BootstrapController.js +12 -9
  45. package/dist/modules/bootstrap/BootstrapController.js.map +1 -1
  46. package/dist/modules/brainstorm/BrainstormController.d.ts.map +1 -1
  47. package/dist/modules/brainstorm/BrainstormController.js +12 -9
  48. package/dist/modules/brainstorm/BrainstormController.js.map +1 -1
  49. package/dist/modules/bugHunt/BugHuntController.d.ts.map +1 -1
  50. package/dist/modules/bugHunt/BugHuntController.js +7 -5
  51. package/dist/modules/bugHunt/BugHuntController.js.map +1 -1
  52. package/dist/modules/clarity/ClarityReviewController.d.ts.map +1 -1
  53. package/dist/modules/clarity/ClarityReviewController.js +12 -9
  54. package/dist/modules/clarity/ClarityReviewController.js.map +1 -1
  55. package/dist/modules/docInterview/DocInterviewController.d.ts.map +1 -1
  56. package/dist/modules/docInterview/DocInterviewController.js +8 -5
  57. package/dist/modules/docInterview/DocInterviewController.js.map +1 -1
  58. package/dist/modules/documents/DocumentSourceController.d.ts.map +1 -1
  59. package/dist/modules/documents/DocumentSourceController.js +17 -15
  60. package/dist/modules/documents/DocumentSourceController.js.map +1 -1
  61. package/dist/modules/environments/EnvironmentController.d.ts.map +1 -1
  62. package/dist/modules/environments/EnvironmentController.js +33 -33
  63. package/dist/modules/environments/EnvironmentController.js.map +1 -1
  64. package/dist/modules/environments/EnvironmentUserHandlerController.d.ts.map +1 -1
  65. package/dist/modules/environments/EnvironmentUserHandlerController.js +13 -9
  66. package/dist/modules/environments/EnvironmentUserHandlerController.js.map +1 -1
  67. package/dist/modules/fragmentLibrary/FragmentLibraryController.d.ts.map +1 -1
  68. package/dist/modules/fragmentLibrary/FragmentLibraryController.js +32 -41
  69. package/dist/modules/fragmentLibrary/FragmentLibraryController.js.map +1 -1
  70. package/dist/modules/github/GitHubController.d.ts.map +1 -1
  71. package/dist/modules/github/GitHubController.js +26 -23
  72. package/dist/modules/github/GitHubController.js.map +1 -1
  73. package/dist/modules/github/GitHubWebhookController.d.ts.map +1 -1
  74. package/dist/modules/github/GitHubWebhookController.js +5 -4
  75. package/dist/modules/github/GitHubWebhookController.js.map +1 -1
  76. package/dist/modules/gitlab/GitLabController.d.ts.map +1 -1
  77. package/dist/modules/gitlab/GitLabController.js +7 -4
  78. package/dist/modules/gitlab/GitLabController.js.map +1 -1
  79. package/dist/modules/incidentEnrichment/IncidentEnrichmentController.d.ts.map +1 -1
  80. package/dist/modules/incidentEnrichment/IncidentEnrichmentController.js +7 -9
  81. package/dist/modules/incidentEnrichment/IncidentEnrichmentController.js.map +1 -1
  82. package/dist/modules/initiatives/InitiativeController.d.ts.map +1 -1
  83. package/dist/modules/initiatives/InitiativeController.js +20 -17
  84. package/dist/modules/initiatives/InitiativeController.js.map +1 -1
  85. package/dist/modules/kaizen/KaizenController.d.ts.map +1 -1
  86. package/dist/modules/kaizen/KaizenController.js +6 -3
  87. package/dist/modules/kaizen/KaizenController.js.map +1 -1
  88. package/dist/modules/llmProxy/LlmProxyController.d.ts.map +1 -1
  89. package/dist/modules/llmProxy/LlmProxyController.js +309 -222
  90. package/dist/modules/llmProxy/LlmProxyController.js.map +1 -1
  91. package/dist/modules/localModels/LocalModelEndpointController.d.ts.map +1 -1
  92. package/dist/modules/localModels/LocalModelEndpointController.js +15 -10
  93. package/dist/modules/localModels/LocalModelEndpointController.js.map +1 -1
  94. package/dist/modules/localSettings/LocalSettingsController.d.ts.map +1 -1
  95. package/dist/modules/localSettings/LocalSettingsController.js +6 -8
  96. package/dist/modules/localSettings/LocalSettingsController.js.map +1 -1
  97. package/dist/modules/localSettings/MothershipConnectController.d.ts.map +1 -1
  98. package/dist/modules/localSettings/MothershipConnectController.js +2 -6
  99. package/dist/modules/localSettings/MothershipConnectController.js.map +1 -1
  100. package/dist/modules/merge/MergeTrackRecordController.d.ts.map +1 -1
  101. package/dist/modules/merge/MergeTrackRecordController.js +6 -3
  102. package/dist/modules/merge/MergeTrackRecordController.js.map +1 -1
  103. package/dist/modules/merge/RiskPolicyController.d.ts.map +1 -1
  104. package/dist/modules/merge/RiskPolicyController.js +9 -6
  105. package/dist/modules/merge/RiskPolicyController.js.map +1 -1
  106. package/dist/modules/modelPresets/ModelPresetController.d.ts.map +1 -1
  107. package/dist/modules/modelPresets/ModelPresetController.js +9 -6
  108. package/dist/modules/modelPresets/ModelPresetController.js.map +1 -1
  109. package/dist/modules/notificationWebhook/NotificationWebhookController.d.ts.map +1 -1
  110. package/dist/modules/notificationWebhook/NotificationWebhookController.js +7 -4
  111. package/dist/modules/notificationWebhook/NotificationWebhookController.js.map +1 -1
  112. package/dist/modules/notifications/NotificationController.d.ts.map +1 -1
  113. package/dist/modules/notifications/NotificationController.js +7 -4
  114. package/dist/modules/notifications/NotificationController.js.map +1 -1
  115. package/dist/modules/observability/PlatformObservabilityController.d.ts.map +1 -1
  116. package/dist/modules/observability/PlatformObservabilityController.js +6 -8
  117. package/dist/modules/observability/PlatformObservabilityController.js.map +1 -1
  118. package/dist/modules/openrouter/OpenRouterCatalogController.d.ts.map +1 -1
  119. package/dist/modules/openrouter/OpenRouterCatalogController.js +13 -8
  120. package/dist/modules/openrouter/OpenRouterCatalogController.js.map +1 -1
  121. package/dist/modules/packageRegistries/PackageRegistriesController.d.ts.map +1 -1
  122. package/dist/modules/packageRegistries/PackageRegistriesController.js +7 -9
  123. package/dist/modules/packageRegistries/PackageRegistriesController.js.map +1 -1
  124. package/dist/modules/persistence/GitHubDelegationController.d.ts.map +1 -1
  125. package/dist/modules/persistence/GitHubDelegationController.js +3 -7
  126. package/dist/modules/persistence/GitHubDelegationController.js.map +1 -1
  127. package/dist/modules/preflight/PreflightController.d.ts.map +1 -1
  128. package/dist/modules/preflight/PreflightController.js +5 -7
  129. package/dist/modules/preflight/PreflightController.js.map +1 -1
  130. package/dist/modules/preview/PreviewController.d.ts.map +1 -1
  131. package/dist/modules/preview/PreviewController.js +7 -9
  132. package/dist/modules/preview/PreviewController.js.map +1 -1
  133. package/dist/modules/providers/ApiKeyController.d.ts.map +1 -1
  134. package/dist/modules/providers/ApiKeyController.js +19 -14
  135. package/dist/modules/providers/ApiKeyController.js.map +1 -1
  136. package/dist/modules/providers/PersonalSubscriptionController.d.ts.map +1 -1
  137. package/dist/modules/providers/PersonalSubscriptionController.js +13 -8
  138. package/dist/modules/providers/PersonalSubscriptionController.js.map +1 -1
  139. package/dist/modules/providers/UserSecretController.d.ts.map +1 -1
  140. package/dist/modules/providers/UserSecretController.js +17 -12
  141. package/dist/modules/providers/UserSecretController.js.map +1 -1
  142. package/dist/modules/providers/VendorCredentialController.d.ts.map +1 -1
  143. package/dist/modules/providers/VendorCredentialController.js +8 -10
  144. package/dist/modules/providers/VendorCredentialController.js.map +1 -1
  145. package/dist/modules/provisioningLogs/ProvisioningLogController.d.ts.map +1 -1
  146. package/dist/modules/provisioningLogs/ProvisioningLogController.js +5 -2
  147. package/dist/modules/provisioningLogs/ProvisioningLogController.js.map +1 -1
  148. package/dist/modules/publicApi/PublicApiController.js +4 -4
  149. package/dist/modules/publicApi/PublicApiController.js.map +1 -1
  150. package/dist/modules/publicApi/PublicApiKeyController.d.ts.map +1 -1
  151. package/dist/modules/publicApi/PublicApiKeyController.js +7 -4
  152. package/dist/modules/publicApi/PublicApiKeyController.js.map +1 -1
  153. package/dist/modules/recurring/RecurringPipelineController.d.ts.map +1 -1
  154. package/dist/modules/recurring/RecurringPipelineController.js +10 -7
  155. package/dist/modules/recurring/RecurringPipelineController.js.map +1 -1
  156. package/dist/modules/recurring/TrackerSettingsController.d.ts.map +1 -1
  157. package/dist/modules/recurring/TrackerSettingsController.js +6 -3
  158. package/dist/modules/recurring/TrackerSettingsController.js.map +1 -1
  159. package/dist/modules/releaseHealth/ReleaseHealthController.d.ts.map +1 -1
  160. package/dist/modules/releaseHealth/ReleaseHealthController.js +10 -7
  161. package/dist/modules/releaseHealth/ReleaseHealthController.js.map +1 -1
  162. package/dist/modules/reports/ReportsController.d.ts.map +1 -1
  163. package/dist/modules/reports/ReportsController.js +6 -3
  164. package/dist/modules/reports/ReportsController.js.map +1 -1
  165. package/dist/modules/requirements/RequirementReviewController.d.ts.map +1 -1
  166. package/dist/modules/requirements/RequirementReviewController.js +16 -13
  167. package/dist/modules/requirements/RequirementReviewController.js.map +1 -1
  168. package/dist/modules/runners/RunnerPoolController.d.ts.map +1 -1
  169. package/dist/modules/runners/RunnerPoolController.js +10 -7
  170. package/dist/modules/runners/RunnerPoolController.js.map +1 -1
  171. package/dist/modules/sandbox/SandboxController.d.ts.map +1 -1
  172. package/dist/modules/sandbox/SandboxController.js +17 -14
  173. package/dist/modules/sandbox/SandboxController.js.map +1 -1
  174. package/dist/modules/serviceFragmentDefaults/ServiceFragmentDefaultsController.d.ts.map +1 -1
  175. package/dist/modules/serviceFragmentDefaults/ServiceFragmentDefaultsController.js +6 -3
  176. package/dist/modules/serviceFragmentDefaults/ServiceFragmentDefaultsController.js.map +1 -1
  177. package/dist/modules/services/ServiceMountController.d.ts.map +1 -1
  178. package/dist/modules/services/ServiceMountController.js +9 -6
  179. package/dist/modules/services/ServiceMountController.js.map +1 -1
  180. package/dist/modules/settings/WorkspaceSettingsController.d.ts.map +1 -1
  181. package/dist/modules/settings/WorkspaceSettingsController.js +6 -3
  182. package/dist/modules/settings/WorkspaceSettingsController.js.map +1 -1
  183. package/dist/modules/sharedStack/SharedStackController.d.ts.map +1 -1
  184. package/dist/modules/sharedStack/SharedStackController.js +11 -8
  185. package/dist/modules/sharedStack/SharedStackController.js.map +1 -1
  186. package/dist/modules/skillLibrary/SkillLibraryController.d.ts.map +1 -1
  187. package/dist/modules/skillLibrary/SkillLibraryController.js +19 -19
  188. package/dist/modules/skillLibrary/SkillLibraryController.js.map +1 -1
  189. package/dist/modules/slack/SlackController.d.ts.map +1 -1
  190. package/dist/modules/slack/SlackController.js +16 -13
  191. package/dist/modules/slack/SlackController.js.map +1 -1
  192. package/dist/modules/tasks/TaskSourceController.d.ts.map +1 -1
  193. package/dist/modules/tasks/TaskSourceController.js +26 -24
  194. package/dist/modules/tasks/TaskSourceController.js.map +1 -1
  195. package/dist/modules/tasks/TaskWebhookController.d.ts.map +1 -1
  196. package/dist/modules/tasks/TaskWebhookController.js +4 -4
  197. package/dist/modules/tasks/TaskWebhookController.js.map +1 -1
  198. package/dist/modules/testSecrets/TestSecretsController.d.ts.map +1 -1
  199. package/dist/modules/testSecrets/TestSecretsController.js +7 -9
  200. package/dist/modules/testSecrets/TestSecretsController.js.map +1 -1
  201. package/dist/modules/userSettings/UserSettingsController.d.ts.map +1 -1
  202. package/dist/modules/userSettings/UserSettingsController.js +11 -6
  203. package/dist/modules/userSettings/UserSettingsController.js.map +1 -1
  204. package/dist/modules/validation/ValidationConfigController.d.ts.map +1 -1
  205. package/dist/modules/validation/ValidationConfigController.js +52 -11
  206. package/dist/modules/validation/ValidationConfigController.js.map +1 -1
  207. package/dist/modules/vcs/VcsWebhookController.d.ts.map +1 -1
  208. package/dist/modules/vcs/VcsWebhookController.js +5 -5
  209. package/dist/modules/vcs/VcsWebhookController.js.map +1 -1
  210. package/dist/modules/webSearch/WebSearchProxyController.d.ts.map +1 -1
  211. package/dist/modules/webSearch/WebSearchProxyController.js +3 -3
  212. package/dist/modules/webSearch/WebSearchProxyController.js.map +1 -1
  213. package/dist/modules/workspaces/WorkspaceMemberController.d.ts.map +1 -1
  214. package/dist/modules/workspaces/WorkspaceMemberController.js +9 -6
  215. package/dist/modules/workspaces/WorkspaceMemberController.js.map +1 -1
  216. package/dist/persistence/remoteRepositories.d.ts +9 -1
  217. package/dist/persistence/remoteRepositories.d.ts.map +1 -1
  218. package/dist/persistence/remoteRepositories.js +13 -1
  219. package/dist/persistence/remoteRepositories.js.map +1 -1
  220. package/dist/persistence/rpc-allowlist.d.ts +23 -0
  221. package/dist/persistence/rpc-allowlist.d.ts.map +1 -1
  222. package/dist/persistence/rpc-allowlist.js +56 -10
  223. package/dist/persistence/rpc-allowlist.js.map +1 -1
  224. package/dist/persistence/rpc-scope.logic.d.ts +15 -0
  225. package/dist/persistence/rpc-scope.logic.d.ts.map +1 -1
  226. package/dist/persistence/rpc-scope.logic.js +29 -0
  227. package/dist/persistence/rpc-scope.logic.js.map +1 -1
  228. package/dist/persistence/rpc.d.ts +11 -0
  229. package/dist/persistence/rpc.d.ts.map +1 -1
  230. package/dist/persistence/rpc.js +10 -1
  231. package/dist/persistence/rpc.js.map +1 -1
  232. package/package.json +8 -8
@@ -3,7 +3,7 @@ import { bodyLimit } from 'hono/body-limit';
3
3
  import { promptCacheParams, readInputTokenClasses } from '@cat-factory/agents';
4
4
  import { isLocalRunner } from '@cat-factory/contracts';
5
5
  import { fetchLocalRunner } from '@cat-factory/integrations';
6
- import { contextWindowFor, runBestEffort } from '@cat-factory/kernel';
6
+ import { contextWindowFor, normalizeCallPhase, runBestEffort, } from '@cat-factory/kernel';
7
7
  import { openAiCompatibleBaseUrlError } from '../../agents/providerErrors.js';
8
8
  import { ContainerSessionService, } from '../../containers/ContainerSessionService.js';
9
9
  import { makeWaitUntil } from '../../http/waitUntil.js';
@@ -124,7 +124,12 @@ async function dispatchInProcess(c, ctx) {
124
124
  errorMessage: `Provider '${session.provider}' is not available`,
125
125
  upstreamMs: 0,
126
126
  });
127
- return c.json({ error: { message: `Provider '${session.provider}' is not available` } }, 502);
127
+ return c.json({
128
+ error: {
129
+ code: 'upstream_unavailable',
130
+ message: `Provider '${session.provider}' is not available`,
131
+ },
132
+ }, 502);
128
133
  }
129
134
  try {
130
135
  return await inProcess;
@@ -141,7 +146,12 @@ async function dispatchInProcess(c, ctx) {
141
146
  errorMessage: message,
142
147
  upstreamMs: Date.now() - dispatchAt,
143
148
  });
144
- return c.json({ error: { message: `In-process call failed for model '${session.model}': ${message}` } }, 502);
149
+ return c.json({
150
+ error: {
151
+ code: 'upstream_error',
152
+ message: `In-process call failed for model '${session.model}': ${message}`,
153
+ },
154
+ }, 502);
145
155
  }
146
156
  }
147
157
  /**
@@ -175,7 +185,12 @@ async function resolveUpstreamTarget(c, ctx) {
175
185
  upstreamMs: 0,
176
186
  });
177
187
  return {
178
- failure: c.json({ error: { message: `No local runner '${session.provider}' configured for this run` } }, 502),
188
+ failure: c.json({
189
+ error: {
190
+ code: 'upstream_unavailable',
191
+ message: `No local runner '${session.provider}' configured for this run`,
192
+ },
193
+ }, 502),
179
194
  };
180
195
  }
181
196
  // Most local runners ignore auth; the SDK/fetch still emit an Authorization header.
@@ -199,7 +214,14 @@ async function resolveUpstreamTarget(c, ctx) {
199
214
  errorMessage: message,
200
215
  upstreamMs: 0,
201
216
  });
202
- return { failure: c.json({ error: { message } }, 502) };
217
+ // The raw exception text from an upstream/SDK call is NOT wire-safe: a fetch or SDK
218
+ // error routinely echoes the request URL (with its query) or an auth header back in its
219
+ // own message, and this response crosses out of the deployment. The cause is already
220
+ // logged above and recorded on the call metric — both of which are scrubbed sinks the
221
+ // operator can read — so the wire gets the generic framing instead.
222
+ return {
223
+ failure: c.json({ error: { code: 'upstream_error', message: 'Upstream provider request failed' } }, 502),
224
+ };
203
225
  }
204
226
  // Lease the API key for this provider from the DB-backed pool (workspace + owning account + the
205
227
  // run initiator), scoped from the signed session claims. Keys are no longer env-baked: an empty
@@ -215,7 +237,9 @@ async function resolveUpstreamTarget(c, ctx) {
215
237
  errorMessage: 'API-key store is not configured',
216
238
  upstreamMs: 0,
217
239
  });
218
- return { failure: c.json({ error: { message: 'API-key store is not configured' } }, 502) };
240
+ return {
241
+ failure: c.json({ error: { code: 'unavailable', message: 'API-key store is not configured' } }, 502),
242
+ };
219
243
  }
220
244
  try {
221
245
  const leased = await apiKeys.lease(session.workspaceId, session.provider, {
@@ -242,7 +266,12 @@ async function resolveUpstreamTarget(c, ctx) {
242
266
  upstreamMs: 0,
243
267
  });
244
268
  return {
245
- failure: c.json({ error: { message: `No API key configured for provider '${session.provider}'` } }, 502),
269
+ failure: c.json({
270
+ error: {
271
+ code: 'unavailable',
272
+ message: `No API key configured for provider '${session.provider}'`,
273
+ },
274
+ }, 502),
246
275
  };
247
276
  }
248
277
  }
@@ -289,7 +318,7 @@ async function relayUpstream(c, ctx, target) {
289
318
  errorMessage: message,
290
319
  upstreamMs: Date.now() - dispatchAt,
291
320
  });
292
- return c.json({ error: { message } }, 502);
321
+ return c.json({ error: { code: 'upstream_blocked', message } }, 502);
293
322
  }
294
323
  }
295
324
  else {
@@ -348,229 +377,287 @@ async function relayUpstream(c, ctx, target) {
348
377
  * upload routes' explicit `bodyLimit`.
349
378
  */
350
379
  const MAX_PROXY_BODY_BYTES = 32 * 1024 * 1024;
351
- export function llmProxyController() {
352
- const app = new Hono();
353
- app.post('/v1/chat/completions', bodyLimit({
354
- maxSize: MAX_PROXY_BODY_BYTES,
355
- onError: (c) => c.json({ error: { message: 'Request body exceeds size limit' } }, 413),
356
- }), async (c) => {
357
- // Proxy-entry clock: everything from here to the upstream dispatch (and after the
358
- // response) is transport overhead; the slice spent waiting on the model is the
359
- // actual execution. The two are split in the observability sink.
360
- const t0 = Date.now();
361
- const { config, spendService, gateways, llmObservability, executionEventPublisher, apiKeys, localModelEndpoints, } = c.get('container');
362
- const secret = config.auth.sessionSecret;
363
- if (!secret) {
364
- logger.error('llm proxy: session secret not configured', { scope: 'llmProxy' });
365
- return c.json({ error: { message: 'LLM proxy is not configured' } }, 503);
366
- }
367
- const sessions = new ContainerSessionService({ secret });
368
- const session = await sessions.verify(bearer(c.req.header('authorization')));
369
- if (!session) {
370
- logger.warn('llm proxy: invalid or expired session token', { scope: 'llmProxy' });
371
- return c.json({ error: { message: 'Invalid or expired session token' } }, 401);
372
- }
373
- // Parse + harden the request: lock the model to the session's, and ask for
374
- // usage on the final streamed chunk so we can always meter. Parsed before the
375
- // spend gate so a refusal is still recorded with its prompt/shape for analysis.
376
- let payload;
377
- try {
378
- payload = (await c.req.json());
379
- }
380
- catch {
381
- return c.json({ error: { message: 'Invalid JSON body' } }, 400);
382
- }
383
- payload.model = session.model;
384
- // Prompt caching: route this conversation's calls to the same cached prefix on
385
- // providers that support it (keyed on the execution id, stable across the run's
386
- // turns). A no-op for providers that cache automatically on the prefix or not at
387
- // all see `promptCacheParams`.
388
- Object.assign(payload, promptCacheParams(session.provider, session.executionId));
389
- const streaming = payload.stream === true;
390
- const toolCount = Array.isArray(payload.tools) ? payload.tools.length : 0;
391
- const messageCount = Array.isArray(payload.messages) ? payload.messages.length : 0;
392
- // The EFFECTIVE output ceiling: updated below if the proxy overrides max_tokens
393
- // (e.g. the Workers AI floor), so the recorded metric reflects what actually
394
- // applied, not just what the client asked for.
395
- let requestMaxTokens = typeof payload.max_tokens === 'number' ? payload.max_tokens : null;
396
- const promptText = JSON.stringify(payload.messages ?? []);
397
- // Correlate every proxied call with its run so a bootstrap/execution can be
398
- // traced end to end. We log the tool count explicitly: an agent (Pi) that gets
399
- // no tools back can't edit files, so a toolless call is the signature of a no-op.
400
- const log = logger.child({
401
- scope: 'llmProxy',
380
+ /**
381
+ * The completions paths this proxy serves. The second carries the RUN PHASE the caller was in
382
+ * when it made the call (`agent`, `validation-repair`, `reproduction-repair`, …): the harness
383
+ * points Pi's provider config at `${proxyBaseUrl}/phase/<phase>` for the pass it is about to
384
+ * run, so the phase axis on `llm_call_metrics` is stamped by the component that OWNS the phase
385
+ * boundary rather than inferred downstream from timestamps
386
+ * (`docs/initiatives/token-burn-instrumentation.md`).
387
+ *
388
+ * A URL segment rather than a header because the harness does not make these requests — Pi
389
+ * does, from a config file whose only per-run knobs are the base URL and the token. The
390
+ * unphased path stays the canonical one: an image that predates the phase axis keeps working,
391
+ * and its calls land in the unattributed `''` slice.
392
+ */
393
+ const COMPLETIONS_PATHS = ['/v1/chat/completions', '/v1/phase/:phase/chat/completions'];
394
+ /**
395
+ * Build the per-call observation handler: (1) push a COMPACT live activity event (no
396
+ * prompt/response bodies) so an open "Model activity" panel updates in real time, independent
397
+ * of the durable driver — the proxy records calls even while the run's poll loop is evicted;
398
+ * (2) persist the full metric to the observability sink when it is wired.
399
+ *
400
+ * `upstreamMs` is supplied by whichever path made the call; `totalMs` is the proxy's end-to-end
401
+ * time. Both halves are best-effort and must never break the proxy. Extracted from
402
+ * {@link handleChatCompletion} because it is a cohesive collaborator (everything the metering
403
+ * boundary needs is in {@link CallMeterContext}) and the handler had no headroom left under the
404
+ * 300-line function budget.
405
+ */
406
+ function makeCallObserver(meter, deps) {
407
+ const { session, callId, startedAt, streaming, phase, messageCount, toolCount, promptText } = meter;
408
+ const { waitUntil, log, executionEventPublisher, llmObservability } = deps;
409
+ return (obs) => {
410
+ const completionTokens = obs.usage?.completion_tokens ?? 0;
411
+ // The three input classes are recorded ORTHOGONALLY, so total input is their sum. An
412
+ // upstream path that already knows the split (a gateway whose own shape carries it)
413
+ // hands all three over together; otherwise they are read off the usage payload, which
414
+ // is where the inclusive-vs-exclusive reconciliation lives.
415
+ const input = obs.inputTokens ?? readInputTokenClasses(obs.usage);
416
+ const totalMs = Date.now() - startedAt;
417
+ const requestMaxTokens = meter.requestMaxTokens();
418
+ const totalTokens = input.fresh + input.cacheRead + input.cacheWrite + completionTokens;
419
+ // Emitted regardless of whether the persistence sink is wired, so the live view works
420
+ // even on a deployment that does not retain metrics. This fires on EVERY observed
421
+ // outcome, including refusals/errors (spend exhausted, unavailable provider, upstream
422
+ // non-2xx) where no model work ran: surfacing those live (with `ok:false`) is intentional
423
+ // and matches what the sink persists. Best-effort: a publish failure (no subscribers,
424
+ // transient hub error) must not break metering.
425
+ waitUntil(runBestEffort(log, 'llmProxy.publishCallObserved', () =>
426
+ // `?.` on the publisher itself, not just the method: a minimal container
427
+ // (e.g. the harness's real-proxy acceptance test) may omit it, and the live
428
+ // emit is best-effort a missing publisher must never break metering.
429
+ executionEventPublisher?.llmCallObserved?.(session.workspaceId, {
430
+ id: callId,
402
431
  workspaceId: session.workspaceId,
403
432
  executionId: session.executionId,
404
433
  agentKind: session.agentKind,
405
434
  provider: session.provider,
406
435
  model: session.model,
436
+ createdAt: Date.now(),
437
+ streaming,
438
+ phase,
439
+ messageCount,
440
+ toolCount,
441
+ requestMaxTokens,
442
+ promptTokens: input.fresh,
443
+ cacheReadTokens: input.cacheRead,
444
+ cacheWriteTokens: input.cacheWrite,
445
+ completionTokens,
446
+ totalTokens,
447
+ finishReason: obs.finishReason,
448
+ upstreamMs: obs.upstreamMs,
449
+ overheadMs: Math.max(0, totalMs - obs.upstreamMs),
450
+ totalMs,
451
+ ok: obs.ok,
452
+ httpStatus: obs.httpStatus,
453
+ errorMessage: obs.errorMessage,
454
+ })));
455
+ if (!llmObservability)
456
+ return;
457
+ waitUntil(llmObservability
458
+ .record({
459
+ id: callId,
460
+ workspaceId: session.workspaceId,
461
+ executionId: session.executionId,
462
+ agentKind: session.agentKind,
463
+ provider: session.provider,
464
+ model: session.model,
465
+ streaming,
466
+ phase,
467
+ // A proxied call carries no job-scoped turn counter — the proxy sees one HTTP
468
+ // request at a time — so its rows order by `createdAt`, never by a faked turn.
469
+ turnIndex: null,
470
+ messageCount,
471
+ toolCount,
472
+ requestMaxTokens,
473
+ promptTokens: input.fresh,
474
+ cacheReadTokens: input.cacheRead,
475
+ cacheWriteTokens: input.cacheWrite,
476
+ completionTokens,
477
+ totalTokens,
478
+ finishReason: obs.finishReason,
479
+ totalMs,
480
+ upstreamMs: obs.upstreamMs,
481
+ ok: obs.ok,
482
+ httpStatus: obs.httpStatus,
483
+ errorMessage: obs.errorMessage,
484
+ promptText,
485
+ responseText: obs.responseText,
486
+ reasoningText: obs.reasoningText ?? '',
487
+ })
488
+ // Observability must never break the proxy.
489
+ .catch((err) => log.warn('llm proxy: failed to record metric', {
490
+ err: err instanceof Error ? err.message : String(err),
491
+ })));
492
+ };
493
+ }
494
+ /**
495
+ * Serve one proxied chat completion: verify the container session, harden the payload, gate on
496
+ * spend, forward to the resolved upstream and meter the call. Registered on BOTH completions
497
+ * paths (see {@link COMPLETIONS_PATHS}), which differ only in whether the caller tagged its run
498
+ * phase — hence a named handler rather than an inline one.
499
+ */
500
+ async function handleChatCompletion(c) {
501
+ // Proxy-entry clock: everything from here to the upstream dispatch (and after the
502
+ // response) is transport overhead; the slice spent waiting on the model is the
503
+ // actual execution. The two are split in the observability sink.
504
+ const t0 = Date.now();
505
+ const { config, spendService, gateways, llmObservability, executionEventPublisher, apiKeys, localModelEndpoints, } = c.get('container');
506
+ const secret = config.auth.sessionSecret;
507
+ if (!secret) {
508
+ logger.error('llm proxy: session secret not configured', { scope: 'llmProxy' });
509
+ return c.json({ error: { code: 'unavailable', message: 'LLM proxy is not configured' } }, 503);
510
+ }
511
+ const sessions = new ContainerSessionService({ secret });
512
+ const session = await sessions.verify(bearer(c.req.header('authorization')));
513
+ if (!session) {
514
+ logger.warn('llm proxy: invalid or expired session token', { scope: 'llmProxy' });
515
+ return c.json({ error: { code: 'unauthorized', message: 'Invalid or expired session token' } }, 401);
516
+ }
517
+ // Parse + harden the request: lock the model to the session's, and ask for
518
+ // usage on the final streamed chunk so we can always meter. Parsed before the
519
+ // spend gate so a refusal is still recorded with its prompt/shape for analysis.
520
+ let payload;
521
+ try {
522
+ payload = (await c.req.json());
523
+ }
524
+ catch {
525
+ return c.json({ error: { code: 'validation', message: 'Invalid JSON body' } }, 400);
526
+ }
527
+ payload.model = session.model;
528
+ // Prompt caching: route this conversation's calls to the same cached prefix on
529
+ // providers that support it (keyed on the execution id, stable across the run's
530
+ // turns). A no-op for providers that cache automatically on the prefix or not at
531
+ // all — see `promptCacheParams`.
532
+ Object.assign(payload, promptCacheParams(session.provider, session.executionId));
533
+ // The run phase this call belongs to, off the (optional) path segment the caller was
534
+ // pointed at. Read through the untyped `param()` map because the SAME handler serves the
535
+ // unphased path too. Untrusted like any request path — a session token is all it takes to
536
+ // write here — so it is normalised to the phase alphabet before it becomes a grouping
537
+ // key; anything else lands in the unattributed `''` slice.
538
+ const phase = normalizeCallPhase(c.req.param().phase);
539
+ const streaming = payload.stream === true;
540
+ const toolCount = Array.isArray(payload.tools) ? payload.tools.length : 0;
541
+ const messageCount = Array.isArray(payload.messages) ? payload.messages.length : 0;
542
+ // The EFFECTIVE output ceiling: updated below if the proxy overrides max_tokens
543
+ // (e.g. the Workers AI floor), so the recorded metric reflects what actually
544
+ // applied, not just what the client asked for.
545
+ let requestMaxTokens = typeof payload.max_tokens === 'number' ? payload.max_tokens : null;
546
+ const promptText = JSON.stringify(payload.messages ?? []);
547
+ // Correlate every proxied call with its run so a bootstrap/execution can be
548
+ // traced end to end. We log the tool count explicitly: an agent (Pi) that gets
549
+ // no tools back can't edit files, so a toolless call is the signature of a no-op.
550
+ const log = logger.child({
551
+ scope: 'llmProxy',
552
+ workspaceId: session.workspaceId,
553
+ executionId: session.executionId,
554
+ agentKind: session.agentKind,
555
+ provider: session.provider,
556
+ model: session.model,
557
+ });
558
+ log.info('llm proxy: forwarding chat completion', { streaming, toolCount });
559
+ const waitUntil = makeWaitUntil(c);
560
+ // One id per proxied call, minted here so the SAME id rides both the live
561
+ // `llmCall` activity event and the persisted metric row — the drill-down panel
562
+ // keys its lazy body-load by it, and a live-appended summary row reconciles with
563
+ // the stored row on reload instead of duplicating.
564
+ const callId = `llm_${crypto.randomUUID()}`;
565
+ // Per-call observation handling, off the response path — see `makeCallObserver`.
566
+ const observe = makeCallObserver({
567
+ session,
568
+ callId,
569
+ startedAt: t0,
570
+ streaming,
571
+ phase,
572
+ messageCount,
573
+ toolCount,
574
+ promptText,
575
+ // Read lazily: the proxy may still raise `max_tokens` below (the Workers AI floor),
576
+ // and the metric must report the ceiling that actually applied, not the one asked for.
577
+ requestMaxTokens: () => requestMaxTokens,
578
+ }, { waitUntil, log, executionEventPublisher, llmObservability });
579
+ // Spend gate: refuse once the monthly budget is exhausted, mirroring the
580
+ // engine's pre-step check so a container can't keep spending.
581
+ if (await spendService.isOverBudget(session.workspaceId, {
582
+ accountId: session.accountId,
583
+ userId: session.userId,
584
+ })) {
585
+ logger.warn('llm proxy: spend budget exhausted — refusing call', {
586
+ scope: 'llmProxy',
587
+ workspaceId: session.workspaceId,
588
+ executionId: session.executionId,
407
589
  });
408
- log.info('llm proxy: forwarding chat completion', { streaming, toolCount });
409
- const waitUntil = makeWaitUntil(c);
410
- // One id per proxied call, minted here so the SAME id rides both the live
411
- // `llmCall` activity event and the persisted metric row — the drill-down panel
412
- // keys its lazy body-load by it, and a live-appended summary row reconciles with
413
- // the stored row on reload instead of duplicating.
414
- const callId = `llm_${crypto.randomUUID()}`;
415
- // Per-call observation handling, off the response path: (1) push a COMPACT live
416
- // activity event (no prompt/response bodies) so an open "Model activity" panel
417
- // updates in real time, independent of the durable driver the proxy records
418
- // calls even while the run's poll loop is evicted; (2) persist the full metric to
419
- // the observability sink when it is wired. `upstreamMs` is supplied by whichever
420
- // path made the call; `totalMs` is the proxy's end-to-end time. Both are
421
- // best-effort and must never break the proxy.
422
- const observe = (obs) => {
423
- const completionTokens = obs.usage?.completion_tokens ?? 0;
424
- // The three input classes are recorded ORTHOGONALLY, so total input is their sum. An
425
- // upstream path that already knows the split (a gateway whose own shape carries it)
426
- // hands all three over together; otherwise they are read off the usage payload, which
427
- // is where the inclusive-vs-exclusive reconciliation lives.
428
- const input = obs.inputTokens ?? readInputTokenClasses(obs.usage);
429
- const totalMs = Date.now() - t0;
430
- // Live activity event emitted regardless of whether the persistence sink is
431
- // wired, so the live view works even on a deployment that does not retain
432
- // metrics. This fires on EVERY observed outcome, including refusals/errors (spend
433
- // exhausted, unavailable provider, upstream non-2xx) where no model work ran:
434
- // surfacing those live (with `ok:false`) is intentional and matches what the sink
435
- // persists. Best-effort: a publish failure (no subscribers, transient hub error)
436
- // must not break metering.
437
- waitUntil(runBestEffort(log, 'llmProxy.publishCallObserved', () =>
438
- // `?.` on the publisher itself, not just the method: a minimal container
439
- // (e.g. the harness's real-proxy acceptance test) may omit it, and the live
440
- // emit is best-effort — a missing publisher must never break metering.
441
- executionEventPublisher?.llmCallObserved?.(session.workspaceId, {
442
- id: callId,
443
- workspaceId: session.workspaceId,
444
- executionId: session.executionId,
445
- agentKind: session.agentKind,
446
- provider: session.provider,
447
- model: session.model,
448
- createdAt: Date.now(),
449
- streaming,
450
- messageCount,
451
- toolCount,
452
- requestMaxTokens,
453
- promptTokens: input.fresh,
454
- cacheReadTokens: input.cacheRead,
455
- cacheWriteTokens: input.cacheWrite,
456
- completionTokens,
457
- totalTokens: input.fresh + input.cacheRead + input.cacheWrite + completionTokens,
458
- finishReason: obs.finishReason,
459
- upstreamMs: obs.upstreamMs,
460
- overheadMs: Math.max(0, totalMs - obs.upstreamMs),
461
- totalMs,
462
- ok: obs.ok,
463
- httpStatus: obs.httpStatus,
464
- errorMessage: obs.errorMessage,
465
- })));
466
- if (!llmObservability)
467
- return;
468
- waitUntil(llmObservability
469
- .record({
470
- id: callId,
471
- workspaceId: session.workspaceId,
472
- executionId: session.executionId,
473
- agentKind: session.agentKind,
474
- provider: session.provider,
475
- model: session.model,
476
- streaming,
477
- messageCount,
478
- toolCount,
479
- requestMaxTokens,
480
- promptTokens: input.fresh,
481
- cacheReadTokens: input.cacheRead,
482
- cacheWriteTokens: input.cacheWrite,
483
- completionTokens,
484
- totalTokens: input.fresh + input.cacheRead + input.cacheWrite + completionTokens,
485
- finishReason: obs.finishReason,
486
- totalMs,
487
- upstreamMs: obs.upstreamMs,
488
- ok: obs.ok,
489
- httpStatus: obs.httpStatus,
490
- errorMessage: obs.errorMessage,
491
- promptText,
492
- responseText: obs.responseText,
493
- reasoningText: obs.reasoningText ?? '',
494
- })
495
- // Observability must never break the proxy.
496
- .catch((err) => log.warn('llm proxy: failed to record metric', {
497
- err: err instanceof Error ? err.message : String(err),
498
- })));
499
- };
500
- // Spend gate: refuse once the monthly budget is exhausted, mirroring the
501
- // engine's pre-step check so a container can't keep spending.
502
- if (await spendService.isOverBudget(session.workspaceId, {
590
+ observe({
591
+ usage: null,
592
+ finishReason: null,
593
+ responseText: '',
594
+ ok: false,
595
+ httpStatus: 402,
596
+ errorMessage: 'Spend budget exhausted',
597
+ upstreamMs: 0,
598
+ });
599
+ return c.json({ error: { code: 'spend_exhausted', message: 'Spend budget exhausted' } }, 402);
600
+ }
601
+ // Give container agents generous output room for in-process Workers AI models (a no-op
602
+ // for other providers); records the ceiling actually applied so the metric is accurate.
603
+ requestMaxTokens = applyWorkersAiCeiling(session, payload, promptText, requestMaxTokens);
604
+ // The pooled API key leased for this call (non-binding providers), so usage can be
605
+ // folded back into its rolling-window rotation counters when the call completes.
606
+ let leasedApiKeyId = null;
607
+ const record = (usage) => {
608
+ if (!usage)
609
+ return Promise.resolve(0);
610
+ const inputTokens = usage.prompt_tokens ?? 0;
611
+ const outputTokens = usage.completion_tokens ?? 0;
612
+ // Fold usage into the leased key's rotation counters (best-effort, off the meter).
613
+ if (leasedApiKeyId && apiKeys) {
614
+ void runBestEffort(log, 'apiKeys.recordUsage', () => apiKeys.recordUsage(leasedApiKeyId, { inputTokens, outputTokens }), { apiKeyId: leasedApiKeyId });
615
+ }
616
+ return spendService.record({
617
+ workspaceId: session.workspaceId,
503
618
  accountId: session.accountId,
504
619
  userId: session.userId,
505
- })) {
506
- logger.warn('llm proxy: spend budget exhausted — refusing call', {
507
- scope: 'llmProxy',
508
- workspaceId: session.workspaceId,
509
- executionId: session.executionId,
510
- });
511
- observe({
512
- usage: null,
513
- finishReason: null,
514
- responseText: '',
515
- ok: false,
516
- httpStatus: 402,
517
- errorMessage: 'Spend budget exhausted',
518
- upstreamMs: 0,
519
- });
520
- return c.json({ error: { message: 'Spend budget exhausted' } }, 402);
521
- }
522
- // Give container agents generous output room for in-process Workers AI models (a no-op
523
- // for other providers); records the ceiling actually applied so the metric is accurate.
524
- requestMaxTokens = applyWorkersAiCeiling(session, payload, promptText, requestMaxTokens);
525
- // The pooled API key leased for this call (non-binding providers), so usage can be
526
- // folded back into its rolling-window rotation counters when the call completes.
527
- let leasedApiKeyId = null;
528
- const record = (usage) => {
529
- if (!usage)
530
- return Promise.resolve(0);
531
- const inputTokens = usage.prompt_tokens ?? 0;
532
- const outputTokens = usage.completion_tokens ?? 0;
533
- // Fold usage into the leased key's rotation counters (best-effort, off the meter).
534
- if (leasedApiKeyId && apiKeys) {
535
- void runBestEffort(log, 'apiKeys.recordUsage', () => apiKeys.recordUsage(leasedApiKeyId, { inputTokens, outputTokens }), { apiKeyId: leasedApiKeyId });
536
- }
537
- return spendService.record({
538
- workspaceId: session.workspaceId,
539
- accountId: session.accountId,
540
- userId: session.userId,
541
- executionId: session.executionId,
542
- agentKind: session.agentKind,
543
- model: `${session.provider}:${session.model}`,
544
- usage: { inputTokens, outputTokens },
545
- });
546
- };
547
- // The per-call state the dispatch helpers below thread through; `observe` / `record` stay
548
- // bound to this handler's mutable `requestMaxTokens` / `leasedApiKeyId`.
549
- const ctx = {
550
- session,
551
- payload,
552
- streaming,
553
- promptText,
554
- log,
555
- gateways,
556
- apiKeys,
557
- localModelEndpoints,
558
- waitUntil,
559
- observe,
560
- record,
561
- };
562
- // Workers AI (and any binding-reached provider) has no external upstream: run it in-process
563
- // via the facade's gateway. Null-provider (e.g. Node can't) surfaces as unavailable.
564
- if (session.provider === 'workers-ai')
565
- return dispatchInProcess(c, ctx);
566
- // Resolve the upstream base URL + bearer key (local runner vs the DB-backed key pool), then
567
- // forward + meter. A failure is already observed and returned ready-to-send.
568
- const target = await resolveUpstreamTarget(c, ctx);
569
- if ('failure' in target)
570
- return target.failure;
571
- leasedApiKeyId = target.leasedApiKeyId;
572
- return relayUpstream(c, ctx, target);
620
+ executionId: session.executionId,
621
+ agentKind: session.agentKind,
622
+ model: `${session.provider}:${session.model}`,
623
+ usage: { inputTokens, outputTokens },
624
+ });
625
+ };
626
+ // The per-call state the dispatch helpers below thread through; `observe` / `record` stay
627
+ // bound to this handler's mutable `requestMaxTokens` / `leasedApiKeyId`.
628
+ const ctx = {
629
+ session,
630
+ payload,
631
+ streaming,
632
+ promptText,
633
+ log,
634
+ gateways,
635
+ apiKeys,
636
+ localModelEndpoints,
637
+ waitUntil,
638
+ observe,
639
+ record,
640
+ };
641
+ // Workers AI (and any binding-reached provider) has no external upstream: run it in-process
642
+ // via the facade's gateway. Null-provider (e.g. Node can't) surfaces as unavailable.
643
+ if (session.provider === 'workers-ai')
644
+ return dispatchInProcess(c, ctx);
645
+ // Resolve the upstream base URL + bearer key (local runner vs the DB-backed key pool), then
646
+ // forward + meter. A failure is already observed and returned ready-to-send.
647
+ const target = await resolveUpstreamTarget(c, ctx);
648
+ if ('failure' in target)
649
+ return target.failure;
650
+ leasedApiKeyId = target.leasedApiKeyId;
651
+ return relayUpstream(c, ctx, target);
652
+ }
653
+ export function llmProxyController() {
654
+ const app = new Hono();
655
+ const limit = bodyLimit({
656
+ maxSize: MAX_PROXY_BODY_BYTES,
657
+ onError: (c) => c.json({ error: { code: 'payload_too_large', message: 'Request body exceeds size limit' } }, 413),
573
658
  });
659
+ for (const path of COMPLETIONS_PATHS)
660
+ app.post(path, limit, handleChatCompletion);
574
661
  return app;
575
662
  }
576
663
  /** Pull the assistant text out of a buffered completion (empty when tool-only). */