@opengeni/runtime 4.0.1 → 4.0.3-canary.36199476632001

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 (61) hide show
  1. package/dist/{agent-RJQLKFIW.js → agent-ME6HT4DH.js} +2 -2
  2. package/dist/assets/codemode-client.json +1 -1
  3. package/dist/browser-action-json-schema.d.ts +4 -0
  4. package/dist/{chunk-J5AR5XAJ.js → chunk-FAFZCSN7.js} +695 -210
  5. package/dist/chunk-FAFZCSN7.js.map +1 -0
  6. package/dist/{chunk-ZG2YH25H.js → chunk-JAYHA6BF.js} +2 -2
  7. package/dist/{chunk-UNICCRW5.js → chunk-LZIXCZUV.js} +6 -3
  8. package/dist/{chunk-UNICCRW5.js.map → chunk-LZIXCZUV.js.map} +1 -1
  9. package/dist/{chunk-J6J3VYFW.js → chunk-P4V7JGQ2.js} +1037 -105
  10. package/dist/chunk-P4V7JGQ2.js.map +1 -0
  11. package/dist/{chunk-SPT3PLMX.js → chunk-PRHMSM6C.js} +2 -2
  12. package/dist/codex-tool-search.d.ts +12 -0
  13. package/dist/context-compaction.d.ts +4 -2
  14. package/dist/index.d.ts +27 -8
  15. package/dist/index.js +23 -7
  16. package/dist/interaction-tools.d.ts +1 -1
  17. package/dist/{lazy-manifest-OEYEHNVY.js → lazy-manifest-GCGUFIWP.js} +3 -3
  18. package/dist/{localSources-TWHTEKGO.js → localSources-FDH65AFI.js} +4 -4
  19. package/dist/metrics.d.ts +17 -0
  20. package/dist/model-provider-routing.d.ts +14 -0
  21. package/dist/operational-instructions.d.ts +1 -1
  22. package/dist/provider-quota.d.ts +76 -0
  23. package/dist/sandbox/browser-control-client.d.ts +28 -1
  24. package/dist/sandbox/channel-a.d.ts +43 -0
  25. package/dist/sandbox/index.d.ts +3 -3
  26. package/dist/sandbox/index.js +10 -6
  27. package/dist/sandbox/providers/modal-command-router-wire.d.ts +8 -0
  28. package/dist/sandbox/providers/modal.d.ts +5 -6
  29. package/dist/workspace-tool-gateway.js +5 -5
  30. package/package.json +11 -11
  31. package/src/browser-action-json-schema.ts +10 -0
  32. package/src/bundled_artifact_skills/opengeni-documents/SKILL.md +4 -1
  33. package/src/bundled_artifact_skills/opengeni-documents/references/api.md +20 -0
  34. package/src/bundled_default_skills/opengeni-client/SKILL.md +4 -1
  35. package/src/bundled_default_skills/opengeni-client/references/discovery-and-autonomy.md +31 -0
  36. package/src/bundled_default_skills/opengeni-client/references/runtime-profile-and-verification.md +32 -0
  37. package/src/codex-tool-search.ts +54 -6
  38. package/src/context-compaction.ts +19 -3
  39. package/src/index.ts +285 -56
  40. package/src/interaction-tools.ts +656 -48
  41. package/src/lazy-tool-transport.ts +4 -0
  42. package/src/metrics.ts +26 -0
  43. package/src/model-context-inspector.ts +1 -0
  44. package/src/model-provider-client.ts +37 -16
  45. package/src/model-provider-routing.ts +32 -6
  46. package/src/operational-instructions.ts +1 -1
  47. package/src/provider-quota.ts +412 -0
  48. package/src/sandbox/browser-control-client.ts +241 -26
  49. package/src/sandbox/channel-a.ts +370 -0
  50. package/src/sandbox/index.ts +6 -1
  51. package/src/sandbox/providers/modal-command-control.ts +7 -1
  52. package/src/sandbox/providers/modal-command-router-wire.ts +47 -6
  53. package/src/sandbox/providers/modal.ts +16 -24
  54. package/src/sandbox/routing/routing-session.ts +29 -6
  55. package/dist/chunk-J5AR5XAJ.js.map +0 -1
  56. package/dist/chunk-J6J3VYFW.js.map +0 -1
  57. /package/dist/{agent-RJQLKFIW.js.map → agent-ME6HT4DH.js.map} +0 -0
  58. /package/dist/{chunk-ZG2YH25H.js.map → chunk-JAYHA6BF.js.map} +0 -0
  59. /package/dist/{chunk-SPT3PLMX.js.map → chunk-PRHMSM6C.js.map} +0 -0
  60. /package/dist/{lazy-manifest-OEYEHNVY.js.map → lazy-manifest-GCGUFIWP.js.map} +0 -0
  61. /package/dist/{localSources-TWHTEKGO.js.map → localSources-FDH65AFI.js.map} +0 -0
@@ -38,6 +38,10 @@ const ALWAYS_VISIBLE_BASE_TOOL_NAMES: ReadonlySet<string> = new Set([
38
38
  "repository_skill_read",
39
39
  "request_human_input",
40
40
  "list_models",
41
+ // Jev-backed code search. Present only when the deployment and workspace
42
+ // enable it; hiding it behind search would spend a model round trip to find
43
+ // the tool that exists to save round trips.
44
+ "code_search",
41
45
  ]);
42
46
  const DISPATCH_MARKER_KEY = "opengeni.lazy_dispatch.v1";
43
47
  const SEARCH_MARKER_KEY = "opengeni.lazy_search.v1";
package/src/metrics.ts CHANGED
@@ -15,6 +15,23 @@ export const MCP_LIFECYCLE_PHASES = ["connect", "close"] as const;
15
15
  export const MCP_LIFECYCLE_POLICIES = ["strict", "best_effort"] as const;
16
16
  export const MCP_LIFECYCLE_OUTCOMES = ["completed", "failed"] as const;
17
17
 
18
+ /**
19
+ * Closed outcomes of one fresh-box command-readiness replacement decision:
20
+ * the replacement became ready, it also missed readiness, it failed for another
21
+ * typed reason, cancellation won the pause or the replacement, or the turn
22
+ * attempt had already spent its single replacement.
23
+ */
24
+ export const SANDBOX_READINESS_REPLACEMENT_OUTCOMES = [
25
+ "replaced",
26
+ "failed_again",
27
+ "replacement_failed",
28
+ "cancelled",
29
+ "budget_spent",
30
+ ] as const;
31
+
32
+ export type SandboxReadinessReplacementOutcome =
33
+ (typeof SANDBOX_READINESS_REPLACEMENT_OUTCOMES)[number];
34
+
18
35
  export type McpLifecyclePhase = (typeof MCP_LIFECYCLE_PHASES)[number];
19
36
  export type McpLifecyclePolicy = (typeof MCP_LIFECYCLE_POLICIES)[number];
20
37
  export type McpLifecycleOutcome = (typeof MCP_LIFECYCLE_OUTCOMES)[number];
@@ -35,6 +52,15 @@ export type RuntimeMetricsHooks = {
35
52
  backend: string;
36
53
  stage: "exec_readiness" | "sibling_warming";
37
54
  }) => void;
55
+ /**
56
+ * One fresh-box readiness replacement decision. The first readiness miss is
57
+ * still counted by `onSandboxWarmingTimeout`; this hook says whether the
58
+ * single per-turn-attempt replacement happened and whether it worked.
59
+ */
60
+ onSandboxReadinessReplacement?: (input: {
61
+ backend: string;
62
+ outcome: SandboxReadinessReplacementOutcome;
63
+ }) => void;
38
64
  onSandboxProviderApiThrottle?: (input: {
39
65
  backend: string;
40
66
  operation: "create" | "renew";
@@ -101,6 +101,7 @@ const LAYER_TITLES: Record<ModelContextInstructionLayerId, string> = {
101
101
  session_instructions: "Session instructions",
102
102
  workspace_memory: "Workspace memory",
103
103
  codemode: "Codemode",
104
+ code_search: "Code search",
104
105
  git_bindings: "Git credential bindings",
105
106
  genesis_title: "Missing-title directive",
106
107
  sdk_capability_instructions: "SDK capability instructions",
@@ -28,6 +28,7 @@ import { isModelCallFetch, vercelGatewayRoutingFetch } from "./model-provider-tr
28
28
  import { ReplayableJsonOpenAI } from "./replayable-json-body";
29
29
  import { recordModelTransportStarted } from "./model-preparation-diagnostics";
30
30
  import { captureProviderRequestBody } from "./model-request-capture";
31
+ import { withoutQuotaExhaustedRetries } from "./provider-quota";
31
32
 
32
33
  let runtimeMetricsHooks: RuntimeMetricsHooks | null = null;
33
34
 
@@ -85,7 +86,10 @@ export function buildOpenAIClientFromSettings(
85
86
  settings.azureOpenaiAdToken && !settings.azureOpenaiApiKey
86
87
  ? { Authorization: `Bearer ${settings.azureOpenaiAdToken}` }
87
88
  : undefined,
88
- fetch: instrumentedModelFetch(providerId, globalThis.fetch),
89
+ fetch: sdkRetryingModelFetch(
90
+ settings.openaiMaxRetries,
91
+ instrumentedModelFetch(providerId, globalThis.fetch),
92
+ ),
89
93
  },
90
94
  { modelRequestPolicy: azureModelRequestPolicy },
91
95
  );
@@ -94,10 +98,23 @@ export function buildOpenAIClientFromSettings(
94
98
  apiKey: settings.openaiApiKey ?? process.env.OPENAI_API_KEY,
95
99
  ...(settings.openaiBaseUrl ? { baseURL: settings.openaiBaseUrl } : {}),
96
100
  maxRetries: settings.openaiMaxRetries,
97
- fetch: instrumentedModelFetch(providerId, globalThis.fetch),
101
+ fetch: sdkRetryingModelFetch(
102
+ settings.openaiMaxRetries,
103
+ instrumentedModelFetch(providerId, globalThis.fetch),
104
+ ),
98
105
  });
99
106
  }
100
107
 
108
+ /**
109
+ * A client that lets the OpenAI SDK retry 429s must not replay an exhausted
110
+ * provider quota: the refusal cannot clear within the SDK's backoff, and each
111
+ * retry only delays the typed turn failure. Clients with SDK retries disabled
112
+ * need no veto.
113
+ */
114
+ function sdkRetryingModelFetch(maxRetries: number, inner: typeof fetch): typeof fetch {
115
+ return maxRetries > 0 ? withoutQuotaExhaustedRetries(inner) : inner;
116
+ }
117
+
101
118
  /**
102
119
  * One OpenAI client per deployment-scoped provider configuration, built lazily
103
120
  * and cached for the process. Workspace-connection providers are deliberately
@@ -282,6 +299,11 @@ export function buildProviderClient(provider: ResolvedModelProvider, settings: S
282
299
  throw new WorkspaceGatewayUnavailableError();
283
300
  }
284
301
  const anonymousProvider = provider.kind === "anonymous";
302
+ // Gateway and OpenRouter requests can incur upstream work or charges
303
+ // before a retryable response failure reaches this process. Neither
304
+ // transport has a provider idempotency key tied to our durable call,
305
+ // so never let the SDK replay them blindly.
306
+ const registryMaxRetries = gatewayProvider || openRouterProvider ? 0 : settings.openaiMaxRetries;
285
307
  const client = provider.builtin
286
308
  ? buildOpenAIClientFromSettings(settings, provider.id)
287
309
  : provider.kind === "codex-subscription"
@@ -342,24 +364,23 @@ export function buildProviderClient(provider: ResolvedModelProvider, settings: S
342
364
  ? { apiKey: provider.apiKey }
343
365
  : {}),
344
366
  ...(provider.baseUrl ? { baseURL: provider.baseUrl } : {}),
345
- // Gateway and OpenRouter requests can incur upstream work or charges
346
- // before a retryable response failure reaches this process. Neither
347
- // transport has a provider idempotency key tied to our durable call,
348
- // so never let the SDK replay them blindly.
349
- maxRetries: gatewayProvider || openRouterProvider ? 0 : settings.openaiMaxRetries,
367
+ maxRetries: registryMaxRetries,
350
368
  ...(provider.defaultQuery ? { defaultQuery: provider.defaultQuery } : {}),
351
369
  ...(provider.defaultHeaders ? { defaultHeaders: provider.defaultHeaders } : {}),
352
- fetch: anonymousProvider
353
- ? withoutAuthenticationHeaders(
354
- instrumentedModelFetch(provider.id, globalThis.fetch),
355
- )
356
- : gatewayProvider
357
- ? vercelGatewayRoutingFetch(
358
- provider.kind as "vercel-gateway-managed" | "vercel-gateway-workspace",
370
+ fetch: sdkRetryingModelFetch(
371
+ registryMaxRetries,
372
+ anonymousProvider
373
+ ? withoutAuthenticationHeaders(
359
374
  instrumentedModelFetch(provider.id, globalThis.fetch),
360
- gatewayPolicies,
361
375
  )
362
- : instrumentedModelFetch(provider.id, globalThis.fetch),
376
+ : gatewayProvider
377
+ ? vercelGatewayRoutingFetch(
378
+ provider.kind as "vercel-gateway-managed" | "vercel-gateway-workspace",
379
+ instrumentedModelFetch(provider.id, globalThis.fetch),
380
+ gatewayPolicies,
381
+ )
382
+ : instrumentedModelFetch(provider.id, globalThis.fetch),
383
+ ),
363
384
  },
364
385
  { modelRequestPolicy: modelRequestPolicyForProvider(provider, gatewayPolicies) },
365
386
  );
@@ -163,6 +163,16 @@ export class MultiProviderModelProvider implements ModelProvider {
163
163
  constructor(private readonly settings: Settings) {}
164
164
 
165
165
  async getModel(modelName?: string): Promise<Model> {
166
+ return this.resolveBinding(modelName).model;
167
+ }
168
+
169
+ /**
170
+ * The provider, owned client, and upstream model id that `getModel` binds.
171
+ * Standalone requests outside an agent run (the session-title sidecar) use
172
+ * this to call the provider directly instead of the runner-facing
173
+ * `Model.getResponse()`, which requires an active trace.
174
+ */
175
+ resolveBinding(modelName?: string): ModelProviderBinding {
166
176
  if (modelName) {
167
177
  const resolved = resolveTurnModel(
168
178
  settingsForRunScopedModelResolution(this.settings, modelName),
@@ -189,7 +199,12 @@ export class MultiProviderModelProvider implements ModelProvider {
189
199
  ) {
190
200
  throw new XaiSubscriptionUnavailableError(modelName);
191
201
  }
192
- return resolved.model;
202
+ return {
203
+ provider: resolved.provider,
204
+ client: resolved.client,
205
+ model: resolved.model,
206
+ modelId: resolved.configured.upstreamModelId,
207
+ };
193
208
  }
194
209
  // A `codex/<slug>` id only resolves when the per-workspace worker overlay
195
210
  // (settingsWithCodexCredential) has injected the synthetic codex-subscription
@@ -215,14 +230,25 @@ export class MultiProviderModelProvider implements ModelProvider {
215
230
  // JSON parse/stringify transport wrapper on the fallback path.
216
231
  const builtin = configuredProviders(this.settings)[0];
217
232
  if (!builtin) throw new Error("Built-in model provider is unavailable");
218
- return new OpenGeniResponsesModel(
219
- buildProviderClient(builtin, this.settings),
220
- modelName ?? this.settings.openaiModel,
221
- builtin,
222
- );
233
+ const client = buildProviderClient(builtin, this.settings);
234
+ const modelId = modelName ?? this.settings.openaiModel;
235
+ return {
236
+ provider: builtin,
237
+ client,
238
+ model: new OpenGeniResponsesModel(client, modelId, builtin),
239
+ modelId,
240
+ };
223
241
  }
224
242
  }
225
243
 
244
+ export type ModelProviderBinding = {
245
+ provider: ResolvedModelProvider;
246
+ client: OpenAI;
247
+ model: Model;
248
+ /** The id sent on the provider wire (the upstream id for a registry model). */
249
+ modelId: string;
250
+ };
251
+
226
252
  function settingsForRunScopedModelResolution(settings: Settings, modelName: string): Settings {
227
253
  if (modelName !== settings.openaiModel) {
228
254
  return settings;
@@ -156,7 +156,7 @@ Skills are reusable instructions supplied dynamically for the current session. W
156
156
 
157
157
  # Integration setup
158
158
 
159
- Use available integration tools directly. If the task needs an integration you cannot access, discover it with \`capability_catalog_search\` before declaring a setup blocker. For a suitable match with \`setup.nextAction\`, call \`capability_authorization_request\` with the returned capability ID and a brief task-specific rationale to show its Connect card in chat. Requesting the card does not need integration-management permission; the authenticated human must authorize setup. A card is for an integration required by the authorized design, including established or delegated choices. Resolve out-of-scope architecture choices before requesting setup. After setup, rediscover the tools and continue the task, verifying the access it needs. If access remains blocked, explain the specific blocker from the returned facts. If either setup tool is unavailable, report the missing setup path.
159
+ Use available integration tools directly. If access is missing, search \`capability_catalog_search\` first. For a suitable match with \`setup.nextAction\`, call \`capability_authorization_request\` with its ID and a task-specific rationale. If no match exists and the task needs a remote MCP whose exact HTTPS URL the user supplied or reliable documentation establishes, call \`custom_mcp_setup_request\` with its name, URL, and rationale. Never invent URLs or request credentials in chat. Showing either card does not need integration-management permission and grants no access; the authenticated human must authorize setup. A card is for an integration required by the authorized design, including established or delegated choices. Resolve out-of-scope architecture choices before requesting setup. After setup, rediscover tools and verify access. If blocked or a setup tool is unavailable, explain the specific gap.
160
160
 
161
161
  # Session coordination
162
162
 
@@ -0,0 +1,412 @@
1
+ import { APIError } from "openai";
2
+
3
+ /**
4
+ * Provider quota exhaustion versus ordinary rate limiting.
5
+ *
6
+ * Model providers use HTTP 429 for two different conditions. An ordinary rate
7
+ * limit (requests or tokens per minute) clears within seconds, so pacing the
8
+ * same request again is correct. An exhausted quota (a daily or monthly
9
+ * allowance, a free-tier day cap, or an account with no credits left) cannot
10
+ * clear inside a bounded retry budget, so every retry only delays the failure.
11
+ *
12
+ * This module is the single classifier for API-key provider responses. It
13
+ * reads only provider-owned evidence: the HTTP status, provider message/code
14
+ * strings, and the provider's own retry hint. The OpenAI SDK retry veto and the
15
+ * worker's turn classification both read that evidence from the same thrown
16
+ * SDK error shape (`classifyProviderQuotaError`), so one response can never be
17
+ * classified two ways. Subscription transports (Codex, SuperGrok) own their
18
+ * quota semantics through credential rotation and durable capacity waits and
19
+ * must not route through it.
20
+ */
21
+
22
+ export const PROVIDER_QUOTA_EXHAUSTED_CODE = "provider_quota_exhausted";
23
+
24
+ /** What ran out. Chooses plain-language copy only; every scope is non-retryable. */
25
+ export type ProviderQuotaScope = "daily" | "monthly" | "credits" | "quota";
26
+
27
+ export type ProviderQuotaExhaustion = { scope: ProviderQuotaScope };
28
+
29
+ export type ProviderQuotaEvidence = {
30
+ /** HTTP status when one survived the error wrappers; null when statusless. */
31
+ status: number | null;
32
+ /** Provider-authored message, code and type strings, in any order. */
33
+ texts: readonly string[];
34
+ /** Provider retry hint from a header or structured field, in milliseconds. */
35
+ retryAfterMs: number | null;
36
+ };
37
+
38
+ /** A provider-stated wait at or below this is an ordinary short rate limit. */
39
+ export const PROVIDER_SHORT_RETRY_HINT_MS = 60_000;
40
+
41
+ /**
42
+ * Without any quota wording, only a provider-stated wait beyond this proves a
43
+ * quota window rather than per-minute pacing.
44
+ */
45
+ export const PROVIDER_QUOTA_RETRY_HINT_MS = 15 * 60_000;
46
+
47
+ // Bound the text inspected per field and in total. Provider errors are short;
48
+ // a pathological body must not turn failure classification into a hot loop.
49
+ const MAX_FIELD_CHARS = 4_096;
50
+ const MAX_FIELDS = 64;
51
+
52
+ // Account-level exhaustion that nothing clears in seconds. Structured codes are
53
+ // matched exactly; wording is matched as a phrase. OpenAI's
54
+ // `billing_hard_limit_reached` is the organization's own spending cap.
55
+ const CREDIT_CODES = new Set([
56
+ "insufficient_credits",
57
+ "insufficient_balance",
58
+ "billing_hard_limit_reached",
59
+ ]);
60
+ const QUOTA_CODES = new Set(["insufficient_quota"]);
61
+ const CREDIT_TEXT =
62
+ /\binsufficient[ _](?:credits?|balance|funds)\b|\bcredit balance is too low\b|\brequires more credits\b|\bout of credits\b|\bused all (?:of )?(?:your |the )?(?:available )?credits\b|\bspending limit\b|\bbilling[ _]hard[ _]limit\b|\breached your specified api usage limits?\b/;
63
+
64
+ // Generic quota wording, including a reached "usage limit". Google reports
65
+ // per-minute limits with the same "exceeded your current quota" sentence, so
66
+ // this alone is not decisive: an explicit per-minute scope or a short provider
67
+ // retry hint keeps it retryable.
68
+ // Google's `RESOURCE_EXHAUSTED` status is deliberately absent: it accompanies
69
+ // every Google 429, including short dynamic-shared-quota capacity refusals, so
70
+ // it only marks the response as rate-shaped.
71
+ const QUOTA_TEXT =
72
+ /\bexceeded (?:your |the )?(?:current |allotted |daily |monthly )?quota\b|\bquota (?:has been |was |is )?(?:exceeded|exhausted|reached)\b|\bout of (?:call volume )?quota\b|\bquota[ _]?exceeded\b|\b(?:hit|reached|exceeded) (?:your |the |its )?(?:[a-z]+ )?usage limits?\b/;
73
+
74
+ // Explicit limit windows. "free-models-per-min" is OpenRouter's per-minute
75
+ // free tier; "free-models-per-day" its daily cap. GitHub/Azure AI inference
76
+ // name windows like "UserByModelByDay" or "per 86400s", and Google/Vertex
77
+ // metric ids join words with underscores
78
+ // ("generate_content_requests_per_minute_per_project"). Letter lookarounds
79
+ // rather than `\b` keep snake_case ids matching, since `_` is a word character.
80
+ const MINUTE_SCOPE =
81
+ /(?<![a-z])per[ _-]?min(?:ute)?s?(?![a-z])|perminute|byminute|(?<![a-z])(?:rpm|tpm)(?![a-z])|\/min(?![a-z])|\bper 60 ?s\b|(?<![a-z])per[ _-]?second(?![a-z])/;
82
+ const DAY_SCOPE =
83
+ /(?<![a-z])per[ _-]?day(?![a-z])|perday|byday|(?<![a-z])(?:rpd|tpd)(?![a-z])|(?<![a-z])daily(?![a-z])|\bper 86400 ?s\b/;
84
+ const MONTH_SCOPE = /(?<![a-z])per[ _-]?month(?![a-z])|permonth|bymonth|(?<![a-z])monthly(?![a-z])/;
85
+
86
+ const RATE_TEXT = /too many requests|rate.?limit|\b429\b|\bresource[ _]exhausted\b/;
87
+
88
+ const UNIT_MS: Record<string, number> = {
89
+ ms: 1,
90
+ millisecond: 1,
91
+ milliseconds: 1,
92
+ s: 1_000,
93
+ sec: 1_000,
94
+ secs: 1_000,
95
+ second: 1_000,
96
+ seconds: 1_000,
97
+ m: 60_000,
98
+ min: 60_000,
99
+ mins: 60_000,
100
+ minute: 60_000,
101
+ minutes: 60_000,
102
+ h: 3_600_000,
103
+ hr: 3_600_000,
104
+ hrs: 3_600_000,
105
+ hour: 3_600_000,
106
+ hours: 3_600_000,
107
+ d: 86_400_000,
108
+ day: 86_400_000,
109
+ days: 86_400_000,
110
+ };
111
+
112
+ const DURATION_TOKEN =
113
+ /^\s*(\d{1,9}(?:\.\d{1,12})?)\s*(milliseconds?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)(?![a-z])/i;
114
+
115
+ /** Parse a leading compound duration such as "16m32.64s", "6 seconds" or "7 days". */
116
+ function leadingDurationMs(text: string): number | null {
117
+ let rest = text;
118
+ let total = 0;
119
+ let matched = false;
120
+ for (let index = 0; index < 4; index += 1) {
121
+ const token = DURATION_TOKEN.exec(rest);
122
+ if (!token) break;
123
+ const unit = UNIT_MS[token[2]!.toLowerCase()];
124
+ if (unit === undefined) break;
125
+ total += Number(token[1]) * unit;
126
+ matched = true;
127
+ rest = rest.slice(token[0].length);
128
+ }
129
+ return matched && Number.isFinite(total) ? Math.ceil(total) : null;
130
+ }
131
+
132
+ /**
133
+ * Read a retry hint a provider wrote into its message ("Please retry after 6
134
+ * seconds", "Please try again in 16m32.64s", "Please wait 42567 seconds before
135
+ * retrying"). Used only to tell a short limit from an exhausted quota; it never
136
+ * paces a retry.
137
+ */
138
+ export function providerMessageRetryHintMs(text: string): number | null {
139
+ let longest: number | null = null;
140
+ const prefixes = /\b(?:retry|try again)\s+(?:after|in)\s+|\bwait\s+/gi;
141
+ for (const match of text.slice(0, MAX_FIELD_CHARS * 4).matchAll(prefixes)) {
142
+ const start = (match.index ?? 0) + match[0].length;
143
+ const duration = leadingDurationMs(text.slice(start, start + 64));
144
+ if (duration !== null) longest = longest === null ? duration : Math.max(longest, duration);
145
+ }
146
+ return longest;
147
+ }
148
+
149
+ /**
150
+ * Decide whether a provider refusal is an exhausted quota. Returns null for an
151
+ * ordinary short rate limit and for anything that is not provider quota
152
+ * evidence at all (a sandbox "disk quota exceeded", an OpenGeni credit
153
+ * refusal, a 5xx), so those keep their existing classification.
154
+ */
155
+ export function classifyProviderQuotaExhaustion(
156
+ evidence: ProviderQuotaEvidence,
157
+ ): ProviderQuotaExhaustion | null {
158
+ const fields = evidence.texts
159
+ .slice(0, MAX_FIELDS)
160
+ .filter((value): value is string => typeof value === "string" && value.length > 0)
161
+ .map((value) => value.slice(0, MAX_FIELD_CHARS).toLowerCase());
162
+ const text = fields.join("\n");
163
+ if (text.includes("opengeni credits")) return null;
164
+
165
+ const explicitStatus =
166
+ evidence.status !== null && Number.isInteger(evidence.status) ? evidence.status : null;
167
+ // SDK wrappers sometimes keep only the "429 ..." message prefix.
168
+ const prefixedStatus = fields
169
+ .map((value) => /^\s*(4\d\d)\b/.exec(value)?.[1])
170
+ .find((value) => value !== undefined);
171
+ const status = explicitStatus ?? (prefixedStatus ? Number(prefixedStatus) : null);
172
+ // Only a rate-limit or payment refusal can be quota exhaustion. Every other
173
+ // status keeps its existing classification (a 5xx is transient, other 4xx
174
+ // are already terminal request faults).
175
+ if (status !== null && status !== 429 && status !== 402) return null;
176
+
177
+ const hasCode = (codes: ReadonlySet<string>) => fields.some((value) => codes.has(value.trim()));
178
+ const creditCode = hasCode(CREDIT_CODES);
179
+ const quotaCode = hasCode(QUOTA_CODES);
180
+ const rateShaped = status === 429 || RATE_TEXT.test(text);
181
+ // A statusless error must still look like a provider refusal; free text such
182
+ // as a sandbox "Disk quota exceeded" is not model-provider evidence.
183
+ if (status === null && !rateShaped && !creditCode && !quotaCode) return null;
184
+
185
+ // Payment Required and account credit/billing exhaustion never clear by
186
+ // waiting, whatever retry hint accompanies them.
187
+ if (status === 402 || creditCode || CREDIT_TEXT.test(text)) return { scope: "credits" };
188
+ if (quotaCode) return { scope: "quota" };
189
+
190
+ const hint = evidence.retryAfterMs ?? providerMessageRetryHintMs(text);
191
+ const minute = MINUTE_SCOPE.test(text);
192
+ const day = DAY_SCOPE.test(text);
193
+ const month = MONTH_SCOPE.test(text);
194
+ const quotaWording = QUOTA_TEXT.test(text);
195
+
196
+ if (!day && !month && !quotaWording) {
197
+ // No quota wording: only a provider wait far beyond per-minute pacing
198
+ // proves an exhausted window.
199
+ return hint !== null && hint > PROVIDER_QUOTA_RETRY_HINT_MS ? { scope: "quota" } : null;
200
+ }
201
+ // An explicit per-minute window is ordinary pacing even when the provider's
202
+ // message also advertises its daily allowance.
203
+ if (minute) return null;
204
+ // The provider itself says the limit clears within a minute.
205
+ if (hint !== null && hint <= PROVIDER_SHORT_RETRY_HINT_MS) return null;
206
+ if (day) return { scope: "daily" };
207
+ if (month) return { scope: "monthly" };
208
+ return { scope: "quota" };
209
+ }
210
+
211
+ /** Plain-language turn failure copy for an exhausted provider quota. */
212
+ export function providerQuotaExhaustedMessage(scope: ProviderQuotaScope): string {
213
+ switch (scope) {
214
+ case "daily":
215
+ return "This model's daily limit at the model provider has been reached, so automatic retries stopped. Choose another model, or try again after the limit resets.";
216
+ case "monthly":
217
+ return "This model's monthly limit at the model provider has been reached, so automatic retries stopped. Choose another model, or try again after the limit resets.";
218
+ case "credits":
219
+ return "The model provider account for this model is out of credits, so automatic retries stopped. Choose another model, or add credits with the provider and try again.";
220
+ case "quota":
221
+ return "The model provider's usage quota for this model is used up, so automatic retries stopped. Choose another model, or try again after the quota resets.";
222
+ }
223
+ }
224
+
225
+ const MAX_RESPONSE_BODY_BYTES = 64 * 1024;
226
+
227
+ /** Read at most `maxBytes` of a cloned body; the original stays unread. */
228
+ async function boundedCloneText(response: Response, maxBytes: number): Promise<string | null> {
229
+ const body = response.clone().body;
230
+ if (!body) return "";
231
+ const reader = body.getReader();
232
+ const decoder = new TextDecoder();
233
+ let text = "";
234
+ let bytes = 0;
235
+ try {
236
+ for (;;) {
237
+ const chunk = await reader.read();
238
+ if (chunk.done) break;
239
+ bytes += chunk.value.byteLength;
240
+ if (bytes > maxBytes) return null;
241
+ text += decoder.decode(chunk.value, { stream: true });
242
+ }
243
+ return text + decoder.decode();
244
+ } finally {
245
+ await reader.cancel().catch(() => undefined);
246
+ }
247
+ }
248
+
249
+ function statusOf(value: Record<string, unknown>): number | null {
250
+ const body =
251
+ value.error && typeof value.error === "object"
252
+ ? (value.error as Record<string, unknown>)
253
+ : null;
254
+ const status = Number(value.status ?? value.statusCode ?? body?.status ?? body?.statusCode);
255
+ return Number.isInteger(status) && status >= 100 && status <= 599 ? status : null;
256
+ }
257
+
258
+ /** The first HTTP status along an error and its cause chain. */
259
+ function errorHttpStatus(error: unknown): number | null {
260
+ let current: unknown = error;
261
+ for (let depth = 0; depth < 6 && current && typeof current === "object"; depth += 1) {
262
+ const status = statusOf(current as Record<string, unknown>);
263
+ if (status !== null) return status;
264
+ current = (current as Record<string, unknown>).cause;
265
+ }
266
+ return null;
267
+ }
268
+
269
+ /**
270
+ * Provider-authored strings on an SDK error and its wrappers: message, code,
271
+ * type, name and param, recursing through `error`, `cause`, `response` and
272
+ * `data`. These are exactly the fields the worker's other failure classifiers
273
+ * read, so quota evidence never depends on a key only one reader sees.
274
+ */
275
+ function errorTexts(value: unknown, out: string[], seen: WeakSet<object>): void {
276
+ if (out.length >= MAX_FIELDS) return;
277
+ if (typeof value === "string") {
278
+ out.push(value);
279
+ return;
280
+ }
281
+ if (!value || typeof value !== "object" || seen.has(value)) return;
282
+ seen.add(value);
283
+ const record = value as Record<string, unknown>;
284
+ for (const key of ["message", "code", "type", "name", "param"]) {
285
+ const field = record[key];
286
+ if (typeof field === "string" && field.length > 0) out.push(field);
287
+ }
288
+ for (const key of ["error", "cause", "response", "data"]) errorTexts(record[key], out, seen);
289
+ }
290
+
291
+ function headerValue(headers: unknown, name: string): string | null {
292
+ if (!headers || typeof headers !== "object") return null;
293
+ const getter = (headers as { get?: unknown }).get;
294
+ if (typeof getter === "function") {
295
+ const value = getter.call(headers, name);
296
+ return typeof value === "string" ? value : null;
297
+ }
298
+ const entry = Object.entries(headers as Record<string, unknown>).find(
299
+ ([key, value]) => key.toLowerCase() === name && typeof value === "string",
300
+ );
301
+ return typeof entry?.[1] === "string" ? entry[1] : null;
302
+ }
303
+
304
+ /** A provider retry hint (`retry-after-ms`, `retry-after`, or a structured field) in milliseconds. */
305
+ function errorRetryAfterMs(error: unknown, nowMs: number): number | null {
306
+ let current: unknown = error;
307
+ for (let depth = 0; depth < 6 && current && typeof current === "object"; depth += 1) {
308
+ const value = current as Record<string, unknown>;
309
+ const body =
310
+ value.error && typeof value.error === "object"
311
+ ? (value.error as Record<string, unknown>)
312
+ : null;
313
+ for (const headers of [value.headers, value.responseHeaders, body?.headers]) {
314
+ const millis = Number(headerValue(headers, "retry-after-ms") ?? Number.NaN);
315
+ if (Number.isFinite(millis) && millis > 0) return Math.ceil(millis);
316
+ const header = headerValue(headers, "retry-after");
317
+ if (header === null) continue;
318
+ const seconds = Number(header);
319
+ if (Number.isFinite(seconds) && seconds > 0) return Math.ceil(seconds * 1_000);
320
+ const date = Date.parse(header);
321
+ if (Number.isFinite(date) && date > nowMs) return Math.ceil(date - nowMs);
322
+ }
323
+ const seconds = Number(
324
+ value.retry_after_seconds ?? body?.retry_after_seconds ?? value.retryAfterSeconds,
325
+ );
326
+ if (Number.isFinite(seconds) && seconds > 0) return Math.ceil(seconds * 1_000);
327
+ current = value.cause;
328
+ }
329
+ return null;
330
+ }
331
+
332
+ /**
333
+ * Classify a thrown provider error: an OpenAI SDK `APIError`, or any wrapper
334
+ * whose `error`/`cause` chain reaches one. This is the one evidence reader for
335
+ * both the SDK retry veto and the worker's turn failure.
336
+ */
337
+ export function classifyProviderQuotaError(
338
+ error: unknown,
339
+ nowMs = Date.now(),
340
+ ): ProviderQuotaExhaustion | null {
341
+ try {
342
+ const texts: string[] = [];
343
+ errorTexts(error, texts, new WeakSet());
344
+ return classifyProviderQuotaExhaustion({
345
+ status: errorHttpStatus(error),
346
+ texts,
347
+ retryAfterMs: errorRetryAfterMs(error, nowMs),
348
+ });
349
+ } catch {
350
+ // A hostile getter is not quota evidence; callers keep their existing path.
351
+ return null;
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Classify a raw provider HTTP response without consuming its body. It builds
357
+ * the exact error the OpenAI SDK throws for this response (the same
358
+ * `APIError.generate` inputs as the SDK's own failure path) and classifies
359
+ * that, so the veto and the worker read identical evidence.
360
+ */
361
+ export async function classifyProviderQuotaResponse(
362
+ response: Response,
363
+ nowMs = Date.now(),
364
+ ): Promise<ProviderQuotaExhaustion | null> {
365
+ if (response.status !== 429) return null;
366
+ const text = await boundedCloneText(response, MAX_RESPONSE_BODY_BYTES);
367
+ if (text === null) return null;
368
+ let json: unknown;
369
+ try {
370
+ json = JSON.parse(text);
371
+ } catch {
372
+ json = undefined;
373
+ }
374
+ const sdkError = APIError.generate(
375
+ response.status,
376
+ json as object | undefined,
377
+ json ? undefined : text,
378
+ response.headers,
379
+ );
380
+ return classifyProviderQuotaError(sdkError, nowMs);
381
+ }
382
+
383
+ /**
384
+ * Tell the OpenAI SDK not to replay an exhausted-quota 429. The SDK otherwise
385
+ * retries every 429 up to `maxRetries` times, which only delays a refusal that
386
+ * cannot clear. The SDK honors the standard-for-it `x-should-retry` header, so
387
+ * the exact response (status, headers, body) is passed through with only that
388
+ * header added. Ordinary rate limits and every other response are untouched.
389
+ */
390
+ export function withoutQuotaExhaustedRetries(inner: typeof fetch): typeof fetch {
391
+ return (async (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => {
392
+ const response = await inner(input, init);
393
+ if (response.status !== 429 || response.headers.has("x-should-retry")) return response;
394
+ let exhausted: ProviderQuotaExhaustion | null = null;
395
+ try {
396
+ exhausted = await classifyProviderQuotaResponse(response);
397
+ } catch {
398
+ // Classification is advisory; the SDK keeps its ordinary retry decision.
399
+ }
400
+ if (!exhausted) return response;
401
+ const headers = new Headers(response.headers);
402
+ headers.set("x-should-retry", "false");
403
+ const vetoed = new Response(response.body, {
404
+ status: response.status,
405
+ statusText: response.statusText,
406
+ headers,
407
+ });
408
+ // Keep the request URL the SDK reports in its request logs.
409
+ if (response.url) Object.defineProperty(vetoed, "url", { value: response.url });
410
+ return vetoed;
411
+ }) as typeof fetch;
412
+ }