@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.
- package/dist/{agent-RJQLKFIW.js → agent-ME6HT4DH.js} +2 -2
- package/dist/assets/codemode-client.json +1 -1
- package/dist/browser-action-json-schema.d.ts +4 -0
- package/dist/{chunk-J5AR5XAJ.js → chunk-FAFZCSN7.js} +695 -210
- package/dist/chunk-FAFZCSN7.js.map +1 -0
- package/dist/{chunk-ZG2YH25H.js → chunk-JAYHA6BF.js} +2 -2
- package/dist/{chunk-UNICCRW5.js → chunk-LZIXCZUV.js} +6 -3
- package/dist/{chunk-UNICCRW5.js.map → chunk-LZIXCZUV.js.map} +1 -1
- package/dist/{chunk-J6J3VYFW.js → chunk-P4V7JGQ2.js} +1037 -105
- package/dist/chunk-P4V7JGQ2.js.map +1 -0
- package/dist/{chunk-SPT3PLMX.js → chunk-PRHMSM6C.js} +2 -2
- package/dist/codex-tool-search.d.ts +12 -0
- package/dist/context-compaction.d.ts +4 -2
- package/dist/index.d.ts +27 -8
- package/dist/index.js +23 -7
- package/dist/interaction-tools.d.ts +1 -1
- package/dist/{lazy-manifest-OEYEHNVY.js → lazy-manifest-GCGUFIWP.js} +3 -3
- package/dist/{localSources-TWHTEKGO.js → localSources-FDH65AFI.js} +4 -4
- package/dist/metrics.d.ts +17 -0
- package/dist/model-provider-routing.d.ts +14 -0
- package/dist/operational-instructions.d.ts +1 -1
- package/dist/provider-quota.d.ts +76 -0
- package/dist/sandbox/browser-control-client.d.ts +28 -1
- package/dist/sandbox/channel-a.d.ts +43 -0
- package/dist/sandbox/index.d.ts +3 -3
- package/dist/sandbox/index.js +10 -6
- package/dist/sandbox/providers/modal-command-router-wire.d.ts +8 -0
- package/dist/sandbox/providers/modal.d.ts +5 -6
- package/dist/workspace-tool-gateway.js +5 -5
- package/package.json +11 -11
- package/src/browser-action-json-schema.ts +10 -0
- package/src/bundled_artifact_skills/opengeni-documents/SKILL.md +4 -1
- package/src/bundled_artifact_skills/opengeni-documents/references/api.md +20 -0
- package/src/bundled_default_skills/opengeni-client/SKILL.md +4 -1
- package/src/bundled_default_skills/opengeni-client/references/discovery-and-autonomy.md +31 -0
- package/src/bundled_default_skills/opengeni-client/references/runtime-profile-and-verification.md +32 -0
- package/src/codex-tool-search.ts +54 -6
- package/src/context-compaction.ts +19 -3
- package/src/index.ts +285 -56
- package/src/interaction-tools.ts +656 -48
- package/src/lazy-tool-transport.ts +4 -0
- package/src/metrics.ts +26 -0
- package/src/model-context-inspector.ts +1 -0
- package/src/model-provider-client.ts +37 -16
- package/src/model-provider-routing.ts +32 -6
- package/src/operational-instructions.ts +1 -1
- package/src/provider-quota.ts +412 -0
- package/src/sandbox/browser-control-client.ts +241 -26
- package/src/sandbox/channel-a.ts +370 -0
- package/src/sandbox/index.ts +6 -1
- package/src/sandbox/providers/modal-command-control.ts +7 -1
- package/src/sandbox/providers/modal-command-router-wire.ts +47 -6
- package/src/sandbox/providers/modal.ts +16 -24
- package/src/sandbox/routing/routing-session.ts +29 -6
- package/dist/chunk-J5AR5XAJ.js.map +0 -1
- package/dist/chunk-J6J3VYFW.js.map +0 -1
- /package/dist/{agent-RJQLKFIW.js.map → agent-ME6HT4DH.js.map} +0 -0
- /package/dist/{chunk-ZG2YH25H.js.map → chunk-JAYHA6BF.js.map} +0 -0
- /package/dist/{chunk-SPT3PLMX.js.map → chunk-PRHMSM6C.js.map} +0 -0
- /package/dist/{lazy-manifest-OEYEHNVY.js.map → lazy-manifest-GCGUFIWP.js.map} +0 -0
- /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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
:
|
|
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
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
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
|
+
}
|