@bitkyc08/opencodex 2.42.0 → 2.43.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.
- package/AGENTS_INSTALL.md +2 -2
- package/README.md +31 -0
- package/bin/ocx.mjs +10 -1
- package/gui/dist/assets/index-DS1NE4Jn.css +1 -0
- package/gui/dist/assets/index-Djowl68T.js +112 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-codec.ts +304 -0
- package/src/adapters/anthropic-image-normalize.ts +8 -298
- package/src/adapters/anthropic.ts +30 -7
- package/src/adapters/command-code.ts +7 -5
- package/src/adapters/cursor/desktop-executor-contract.ts +15 -0
- package/src/adapters/cursor/images.ts +36 -6
- package/src/adapters/cursor/live-transport.ts +7 -2
- package/src/adapters/cursor/native-exec-desktop.ts +2 -15
- package/src/adapters/cursor/protobuf-request.ts +13 -3
- package/src/adapters/cursor/tool-definitions.ts +5 -670
- package/src/adapters/cursor/tool-guidance.ts +236 -0
- package/src/adapters/cursor/tool-naming.ts +252 -0
- package/src/adapters/cursor/tool-schemas.ts +195 -0
- package/src/adapters/cursor/types.ts +6 -3
- package/src/adapters/exec-tool-result-normalize.ts +1 -1
- package/src/adapters/google-errors.ts +9 -1
- package/src/adapters/google.ts +1 -0
- package/src/adapters/kiro-calibration.ts +181 -0
- package/src/adapters/kiro.ts +135 -3
- package/src/adapters/openai-responses.ts +216 -26
- package/src/adapters/responses-code-mode.ts +59 -0
- package/src/adapters/tool-catalog-nudge.ts +1 -1
- package/src/adapters/xai-schema-analysis.ts +86 -0
- package/src/adapters/xai-tool-schema.ts +2 -87
- package/src/adapters/xai-web-search.ts +1 -1
- package/src/bridge.ts +22 -10
- package/src/chat/inbound.ts +11 -3
- package/src/claude/inbound-content-options.ts +60 -0
- package/src/claude/inbound-model-options.ts +142 -0
- package/src/claude/inbound-records.ts +7 -0
- package/src/claude/inbound.ts +10 -202
- package/src/claude/model-info.ts +45 -0
- package/src/cli/account-auth.ts +21 -6
- package/src/cli/capabilities.ts +13 -2
- package/src/cli/claude.ts +232 -39
- package/src/cli/config-command.ts +9 -1
- package/src/cli/dispatch.ts +5 -1
- package/src/cli/doctor.ts +10 -0
- package/src/cli/effort.ts +372 -0
- package/src/cli/export-command.ts +3 -9
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +13 -0
- package/src/cli/init.ts +4 -0
- package/src/cli/model-selection-guidance.ts +30 -0
- package/src/cli/models-runtime.ts +3 -2
- package/src/cli/models.ts +8 -3
- package/src/cli/opencode.ts +4 -1
- package/src/cli/provider-runtime.ts +65 -0
- package/src/cli/provider.ts +8 -0
- package/src/cli/registry.ts +16 -2
- package/src/cli/runtime-api.ts +3 -1
- package/src/cli/star-prompt.ts +22 -6
- package/src/cli/status-probes.ts +168 -0
- package/src/cli/status.ts +5 -168
- package/src/clients/config-export/constants.ts +69 -0
- package/src/clients/config-export/contracts.ts +154 -0
- package/src/clients/config-export/dsh.ts +132 -0
- package/src/clients/config-export/fast-models.ts +29 -0
- package/src/clients/config-export/mcode.ts +83 -0
- package/src/clients/config-export/model-metadata.ts +108 -0
- package/src/clients/config-export/omp.ts +104 -0
- package/src/clients/config-export/zcode.ts +92 -0
- package/src/clients/config-export.ts +18 -710
- package/src/codex/account-lifecycle.ts +20 -3
- package/src/codex/account-usability.ts +2 -0
- package/src/codex/auth-api.ts +170 -23
- package/src/codex/auth-context.ts +328 -24
- package/src/codex/catalog/effort.ts +30 -4
- package/src/codex/catalog/metadata.ts +39 -9
- package/src/codex/catalog/native-models.ts +53 -12
- package/src/codex/catalog/parsing.ts +119 -5
- package/src/codex/catalog/provider-fetch.ts +150 -20
- package/src/codex/catalog/reserve.ts +52 -0
- package/src/codex/catalog/sync.ts +89 -16
- package/src/codex/convergence-types.ts +1 -0
- package/src/codex/convergence.ts +2 -0
- package/src/codex/data/upstream-models.json +1 -1
- package/src/codex/forward-transport-headers.ts +25 -0
- package/src/codex/inject.ts +3 -28
- package/src/codex/legacy-config-keys.ts +68 -0
- package/src/codex/log-guard/inspect-schema.ts +137 -0
- package/src/codex/log-guard/inspect.ts +2 -134
- package/src/codex/loopback-target.ts +54 -0
- package/src/codex/main-account-cache.ts +63 -1
- package/src/codex/main-account-hard-lock.ts +52 -0
- package/src/codex/main-account.ts +3 -1
- package/src/codex/management-convergence.ts +3 -0
- package/src/codex/model-entitlements.ts +54 -4
- package/src/codex/project-config-warnings.ts +92 -2
- package/src/codex/prompt-layers/encoding.ts +80 -0
- package/src/codex/prompt-layers/paths.ts +54 -0
- package/src/codex/prompt-layers/revision.ts +55 -0
- package/src/codex/prompt-layers/toml-edit.ts +163 -0
- package/src/codex/prompt-layers/toml-read.ts +181 -0
- package/src/codex/prompt-layers.ts +14 -520
- package/src/codex/quota-auto-refresh-state.ts +16 -0
- package/src/codex/quota-auto-refresh.ts +219 -0
- package/src/codex/quota-types.ts +51 -0
- package/src/codex/quota.ts +252 -93
- package/src/codex/reserve-availability.ts +177 -0
- package/src/codex/routing.ts +28 -9
- package/src/codex/shim.ts +53 -11
- package/src/codex/subagent-model-fallback.ts +23 -3
- package/src/combos/failover.ts +125 -7
- package/src/combos/identifiers.ts +89 -0
- package/src/combos/index.ts +4 -0
- package/src/combos/resolve.ts +80 -9
- package/src/combos/types.ts +20 -93
- package/src/config/subagent-models.ts +24 -0
- package/src/config.ts +156 -13
- package/src/generated/compatibility-version.json +454 -166
- package/src/generated/model-metadata.ts +1 -1
- package/src/integrations/journal.ts +65 -4
- package/src/integrations/store.ts +5 -0
- package/src/lab/events/limits.ts +4 -0
- package/src/lib/destination-policy.ts +31 -2
- package/src/lib/errors.ts +39 -0
- package/src/lib/provider-outbound.ts +69 -3
- package/src/lib/proxy-env.ts +22 -0
- package/src/lib/redact-folding.ts +176 -0
- package/src/lib/redact.ts +2 -175
- package/src/lib/state-store-sweeper.ts +20 -6
- package/src/lib/token-estimate.ts +94 -27
- package/src/lib/windows-user-principal.ts +53 -5
- package/src/oauth/anthropic-routing.ts +99 -3
- package/src/oauth/generic-account-failover.ts +36 -13
- package/src/oauth/index.ts +127 -26
- package/src/oauth/login-cli.ts +5 -0
- package/src/oauth/meta-muse.ts +117 -15
- package/src/oauth/pool-settings-capability.ts +15 -4
- package/src/providers/api-keys.ts +8 -10
- package/src/providers/default-aliases.ts +39 -0
- package/src/providers/derive.ts +10 -2
- package/src/providers/fastwire.ts +36 -7
- package/src/providers/initial-model-selection-runtime.ts +90 -0
- package/src/providers/initial-model-selection.ts +120 -0
- package/src/providers/key-failover.ts +134 -54
- package/src/providers/key-store.ts +11 -1
- package/src/providers/label.ts +1 -1
- package/src/providers/model-discovery.ts +76 -0
- package/src/providers/model-rename-startup.ts +72 -8
- package/src/providers/openai-sidecar.ts +17 -5
- package/src/providers/openai-tiers-destination.ts +102 -0
- package/src/providers/openai-tiers.ts +2 -99
- package/src/providers/opencode-go-transport.ts +41 -0
- package/src/providers/quota-key-accounts.ts +141 -0
- package/src/providers/quota-types.ts +9 -0
- package/src/providers/quota.ts +535 -104
- package/src/providers/registry.ts +60 -17
- package/src/providers/xai-responses-opt-in.ts +31 -5
- package/src/quota/reset-activation.ts +81 -0
- package/src/quota/reset-detector.ts +305 -0
- package/src/quota/reset-notify-config.ts +162 -0
- package/src/quota/reset-observer.ts +125 -0
- package/src/quota/reset-poller.ts +160 -0
- package/src/quota/reset-seen-store.ts +385 -0
- package/src/quota/reset-sinks.ts +199 -0
- package/src/quota/window-mapping.ts +106 -0
- package/src/responses/apply-patch-envelope.ts +46 -0
- package/src/responses/code-mode-helper-compat.ts +39 -1
- package/src/responses/custom-tool-compat.ts +10 -4
- package/src/responses/hosted-tool-policy.ts +12 -4
- package/src/responses/parser-content.ts +133 -0
- package/src/responses/parser-text-format.ts +24 -0
- package/src/responses/parser-tools.ts +188 -0
- package/src/responses/parser.ts +3 -326
- package/src/responses/state.ts +124 -28
- package/src/router.ts +48 -13
- package/src/routing/analytics.ts +1 -0
- package/src/routing/capability.ts +17 -4
- package/src/server/auth-cors.ts +7 -1
- package/src/server/background-lifecycle.ts +23 -1
- package/src/server/chat-completions.ts +25 -3
- package/src/server/claude-messages.ts +62 -5
- package/src/server/effort-row.ts +1 -1
- package/src/server/fast-row.ts +295 -0
- package/src/server/gui-static.ts +30 -4
- package/src/server/index.ts +107 -21
- package/src/server/management/agent-settings-routes.ts +2 -2
- package/src/server/management/combo-routes.ts +37 -9
- package/src/server/management/config-routes.ts +93 -2
- package/src/server/management/integration-routes.ts +108 -0
- package/src/server/management/model-routes.ts +13 -3
- package/src/server/management/model-rows.ts +20 -1
- package/src/server/management/native-integration-routes.ts +4 -1
- package/src/server/management/oauth-account-routes.ts +38 -10
- package/src/server/management/provider-routes.ts +34 -3
- package/src/server/management/quota-reset-routes.ts +57 -0
- package/src/server/management/route-registry.ts +7 -4
- package/src/server/management/shared.ts +19 -5
- package/src/server/management/system-routes.ts +3 -2
- package/src/server/management-api.ts +14 -2
- package/src/server/ports.ts +12 -2
- package/src/server/relay-eager.ts +38 -23
- package/src/server/relay.ts +4 -0
- package/src/server/request-log.ts +6 -0
- package/src/server/responses/codex-ws-correlation.ts +65 -0
- package/src/server/responses/codex-ws-exchange.ts +261 -0
- package/src/server/responses/codex-ws-metadata.ts +134 -0
- package/src/server/responses/codex-ws-pool.ts +162 -0
- package/src/server/responses/codex-ws-request.ts +87 -0
- package/src/server/responses/codex-ws-session.ts +93 -0
- package/src/server/responses/codex-ws-wire.ts +144 -0
- package/src/server/responses/collaboration.ts +41 -1
- package/src/server/responses/compact.ts +105 -12
- package/src/server/responses/core.ts +476 -57
- package/src/server/responses/fetch-helpers.ts +10 -3
- package/src/server/responses/input-admission.ts +16 -9
- package/src/server/responses/responses-field-backfill.ts +1 -1
- package/src/server/responses/ws-upstream.ts +34 -318
- package/src/server/responses-custom-tool-repair.ts +20 -4
- package/src/server/responses-undeclared-tool-guard.ts +100 -8
- package/src/server/safe-response-headers.ts +23 -0
- package/src/server/search.ts +9 -0
- package/src/server/subagent-models-startup.ts +27 -0
- package/src/server/system-env-shell.ts +238 -0
- package/src/server/system-env.ts +7 -234
- package/src/server/ws-bridge.ts +3 -25
- package/src/server/xai-responses-startup.ts +21 -0
- package/src/service-manager-probe.ts +1 -1
- package/src/service.ts +55 -16
- package/src/types/config.ts +108 -12
- package/src/types/provider.ts +36 -7
- package/src/types/tools.ts +26 -1
- package/src/types.ts +2 -0
- package/src/update/notify.ts +8 -2
- package/src/usage/cost.ts +38 -28
- package/src/usage/expected-prices.ts +34 -15
- package/src/usage/log.ts +2 -0
- package/src/usage/model-identity.ts +26 -0
- package/src/usage/summary.ts +15 -1
- package/src/vision/describe.ts +6 -0
- package/src/vision/image-rewrite.ts +108 -0
- package/src/vision/index.ts +19 -306
- package/src/vision/plan.ts +205 -0
- package/src/web-search/executor.ts +6 -0
- package/src/web-search/index.ts +8 -1
- package/gui/dist/assets/index-BU1tE0sr.js +0 -112
- package/gui/dist/assets/index-DL9-iS6J.css +0 -1
|
@@ -127,6 +127,7 @@ export interface ProviderRegistryEntry {
|
|
|
127
127
|
adapter: string;
|
|
128
128
|
baseUrl: string;
|
|
129
129
|
apiKeyTransport?: OcxProviderConfig["apiKeyTransport"];
|
|
130
|
+
alias?: string;
|
|
130
131
|
authKind: ProviderAuthKind;
|
|
131
132
|
codexAccountMode?: CodexAccountMode;
|
|
132
133
|
/** OAuth preset may explicitly honor a persisted API-key billing mode. */
|
|
@@ -349,6 +350,38 @@ export type ProviderConfigSeed = Pick<
|
|
|
349
350
|
// always on, per the official models overview and pricing page (platform.claude.com).
|
|
350
351
|
const ANTHROPIC_MODELS = ["claude-fable-5-1", "claude-fable-5", "claude-sonnet-5", "claude-opus-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"];
|
|
351
352
|
const ANTHROPIC_MODEL_CONTEXT_WINDOWS: Record<string, number> = { "claude-fable-5-1": 1_000_000, "claude-sonnet-5": 1_000_000, "claude-fable-5": 1_000_000, "claude-opus-5": 1_000_000, "claude-opus-4-8": 1_000_000, "claude-opus-4-7": 1_000_000, "claude-opus-4-6": 1_000_000, "claude-sonnet-4-6": 1_000_000, "claude-haiku-4-5": 200_000 };
|
|
353
|
+
// Every current Claude family accepts at least 64k output tokens (Haiku 4.5 / Sonnet 4.x
|
|
354
|
+
// through Opus 5 and Fable 5). Anthropic caps max_tokens per model server-side, so a
|
|
355
|
+
// larger request never over-allocates; it only stops the 8192 truncation.
|
|
356
|
+
const ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS = 64_000;
|
|
357
|
+
/**
|
|
358
|
+
* The effort rungs opencodex exposes for native Anthropic models. Without this the
|
|
359
|
+
* providers advertised no ladder at all, so every client that keys its effort control off
|
|
360
|
+
* `reasoningEfforts` — Aside and the rest of the Pi-shaped exports — wrote these models
|
|
361
|
+
* with no control, while the SAME Claude models routed through `cursor` or
|
|
362
|
+
* `google-antigravity` had one.
|
|
363
|
+
*
|
|
364
|
+
* This is an opencodex ladder, not a claim that each model takes `output_config.effort`.
|
|
365
|
+
* The adapter serves two wire shapes (src/adapters/anthropic.ts): adaptive families
|
|
366
|
+
* (fable, sonnet >= 5, opus >= 4.7) send the effort directly, while opus 4.6, sonnet 4.6
|
|
367
|
+
* and haiku 4.5 take the legacy path where `reasoningBudget` TRANSLATES each rung into
|
|
368
|
+
* `thinking.budget_tokens`. Anthropic documents `low|medium|high|max` for the 4.6 models
|
|
369
|
+
* and no effort parameter at all for haiku 4.5; the budget translation is what makes five
|
|
370
|
+
* rungs meaningful there, and it clamps below `max_tokens` so none of them 400.
|
|
371
|
+
*
|
|
372
|
+
* Deliberately excluded, each because advertising it would offer a control that does not
|
|
373
|
+
* do what it says:
|
|
374
|
+
* - `minimal`: `adaptiveEffort` rewrites it to `low` (the adaptive wire 400s on it), so
|
|
375
|
+
* it is not a distinct setting.
|
|
376
|
+
* - `none`: only sonnet >= 5 accepts an explicit thinking disable
|
|
377
|
+
* (`EXPLICIT_THINKING_DISABLE_FAMILY_MINIMUMS`); Fable rejects one outright.
|
|
378
|
+
* - `ultra`: not an Anthropic concept, and it is degraded to `max` at the request
|
|
379
|
+
* boundary anyway (src/responses/parser.ts).
|
|
380
|
+
*/
|
|
381
|
+
const ANTHROPIC_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"];
|
|
382
|
+
const ANTHROPIC_MODEL_REASONING_EFFORTS: Record<string, string[]> = Object.fromEntries(
|
|
383
|
+
ANTHROPIC_MODELS.map(id => [id, [...ANTHROPIC_REASONING_EFFORTS]]),
|
|
384
|
+
);
|
|
352
385
|
|
|
353
386
|
// 260814 GLM-5.3 is registered pre-emptively alongside 5.2 everywhere 5.2 appears. Z.AI's
|
|
354
387
|
// devpack "How to Switch Models" page (docs.z.ai/devpack/latest-model) lists glm-5.3 and
|
|
@@ -600,7 +633,7 @@ const OPENCODE_FREE_DEEPSEEK_MODELS = ["deepseek-v4-flash-free"];
|
|
|
600
633
|
* `mimo-v2.5-free` and `longcat-2.0-free` ACCEPT images and are deliberately
|
|
601
634
|
* absent. Adding them would silently replace a working image with a caption,
|
|
602
635
|
* which is worse than the loud 400 this list exists to prevent — see the negative
|
|
603
|
-
* assertion in tests/provider-registry-parity.test.ts.
|
|
636
|
+
* assertion in tests/providers/provider-registry-parity.test.ts.
|
|
604
637
|
*
|
|
605
638
|
* Zen's roster is discovered live while this list is static, so it is a dated
|
|
606
639
|
* exception list, not a capability model. Re-probe before extending it.
|
|
@@ -656,7 +689,7 @@ const DEEPSEEK_FLASH_REASONING_MAP: Record<string, string> = {
|
|
|
656
689
|
/**
|
|
657
690
|
* Flash-versus-Pro classification for DeepSeek V4 model ids, including prefixed
|
|
658
691
|
* (`deepseek/deepseek-v4-pro`) and suffixed (`deepseek-v4-flash-free`) forms.
|
|
659
|
-
* `tests/provider-registry-parity.test.ts` enumerates every id the registry
|
|
692
|
+
* `tests/providers/provider-registry-parity.test.ts` enumerates every id the registry
|
|
660
693
|
* actually passes here, so a future id this substring test would misread cannot
|
|
661
694
|
* land silently.
|
|
662
695
|
*/
|
|
@@ -1222,20 +1255,20 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1222
1255
|
// than the seeded ones do.
|
|
1223
1256
|
supportsVerbosity: false,
|
|
1224
1257
|
defaultModel: "grok-4.5",
|
|
1225
|
-
//
|
|
1226
|
-
//
|
|
1227
|
-
// no Chat wire
|
|
1258
|
+
// Grok 4.6/4.5 subscription Responses callers use the native wire with the existing
|
|
1259
|
+
// namespace/web-search/replay normalization. Chat remains an explicit modelAdapters
|
|
1260
|
+
// opt-in. Multi-agent has no Chat wire and uses Responses under both auth modes.
|
|
1228
1261
|
// Caller-owned service tiers stay off the unclassified OAuth subscription route; key-auth
|
|
1229
1262
|
// Fast remains proxy-owned and is still selected through keyAuthServiceTier above.
|
|
1230
1263
|
modelWireDefaults: {
|
|
1231
1264
|
"grok-4.6": {
|
|
1232
|
-
wire: "openai-
|
|
1265
|
+
wire: "openai-responses",
|
|
1233
1266
|
inbound: ["responses"],
|
|
1234
1267
|
authModes: ["oauth"],
|
|
1235
1268
|
forwardCallerServiceTier: false,
|
|
1236
1269
|
},
|
|
1237
1270
|
"grok-4.5": {
|
|
1238
|
-
wire: "openai-
|
|
1271
|
+
wire: "openai-responses",
|
|
1239
1272
|
inbound: ["responses"],
|
|
1240
1273
|
authModes: ["oauth"],
|
|
1241
1274
|
forwardCallerServiceTier: false,
|
|
@@ -1340,6 +1373,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1340
1373
|
note: "Log in with your Claude account",
|
|
1341
1374
|
models: [...ANTHROPIC_MODELS],
|
|
1342
1375
|
modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS },
|
|
1376
|
+
modelReasoningEfforts: { ...ANTHROPIC_MODEL_REASONING_EFFORTS },
|
|
1377
|
+
// Codex omits max_output_tokens; without a provider budget the Anthropic adapter
|
|
1378
|
+
// falls back to 8192, which truncates long answers with stop_reason=max_tokens.
|
|
1379
|
+
defaultMaxOutputTokens: ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS,
|
|
1343
1380
|
defaultModel: "claude-sonnet-5",
|
|
1344
1381
|
},
|
|
1345
1382
|
{
|
|
@@ -1356,6 +1393,8 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1356
1393
|
models: [...ANTHROPIC_MODELS],
|
|
1357
1394
|
liveModels: true,
|
|
1358
1395
|
modelContextWindows: { ...ANTHROPIC_MODEL_CONTEXT_WINDOWS },
|
|
1396
|
+
modelReasoningEfforts: { ...ANTHROPIC_MODEL_REASONING_EFFORTS },
|
|
1397
|
+
defaultMaxOutputTokens: ANTHROPIC_DEFAULT_MAX_OUTPUT_TOKENS,
|
|
1359
1398
|
defaultModel: "claude-sonnet-5",
|
|
1360
1399
|
},
|
|
1361
1400
|
{
|
|
@@ -1458,12 +1497,13 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1458
1497
|
featured: true,
|
|
1459
1498
|
dashboardUrl: "https://platform.openai.com/api-keys",
|
|
1460
1499
|
defaultModel: "gpt-5.5",
|
|
1461
|
-
models: ["gpt-5.5", ...OPENAI_GPT56_MODELS, ...OPENAI_GPT56_PRO_MODELS, ...OPENAI_DAYBREAK_MODELS],
|
|
1500
|
+
models: ["gpt-5.5", ...OPENAI_GPT56_MODELS, ...OPENAI_GPT56_PRO_MODELS, ...OPENAI_DAYBREAK_MODELS, "gpt-6-astra"],
|
|
1462
1501
|
liveModels: true,
|
|
1463
|
-
modelContextWindows: { ...OPENAI_API_GPT56_CONTEXT_WINDOWS, ...OPENAI_DAYBREAK_CONTEXT_WINDOWS },
|
|
1464
|
-
modelMaxInputTokens: { ...OPENAI_API_GPT56_MAX_INPUT_TOKENS, ...OPENAI_DAYBREAK_MAX_INPUT_TOKENS },
|
|
1502
|
+
modelContextWindows: { ...OPENAI_API_GPT56_CONTEXT_WINDOWS, ...OPENAI_DAYBREAK_CONTEXT_WINDOWS, "gpt-6-astra": 1_050_000 },
|
|
1503
|
+
modelMaxInputTokens: { ...OPENAI_API_GPT56_MAX_INPUT_TOKENS, ...OPENAI_DAYBREAK_MAX_INPUT_TOKENS, "gpt-6-astra": 922_000 },
|
|
1504
|
+
modelMaxOutputTokens: { "gpt-6-astra": 128_000 },
|
|
1465
1505
|
modelInputModalities: Object.fromEntries(
|
|
1466
|
-
["gpt-5.5", ...OPENAI_GPT56_MODELS, ...OPENAI_GPT56_PRO_MODELS, ...OPENAI_DAYBREAK_MODELS]
|
|
1506
|
+
["gpt-5.5", ...OPENAI_GPT56_MODELS, ...OPENAI_GPT56_PRO_MODELS, ...OPENAI_DAYBREAK_MODELS, "gpt-6-astra"]
|
|
1467
1507
|
.map(id => [id, ["text", "image"]]),
|
|
1468
1508
|
),
|
|
1469
1509
|
modelReasoningEfforts: {
|
|
@@ -1471,6 +1511,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1471
1511
|
[...OPENAI_GPT56_MODELS, ...OPENAI_GPT56_PRO_MODELS].map(id => [id, OPENAI_API_GPT56_REASONING_EFFORTS]),
|
|
1472
1512
|
),
|
|
1473
1513
|
...OPENAI_DAYBREAK_REASONING_EFFORTS,
|
|
1514
|
+
"gpt-6-astra": ["low", "medium", "high", "xhigh", "max"],
|
|
1474
1515
|
},
|
|
1475
1516
|
virtualModels: OPENAI_API_GPT56_VIRTUAL_MODELS,
|
|
1476
1517
|
},
|
|
@@ -1503,7 +1544,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1503
1544
|
modelContextWindows: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_CONTEXT_WINDOW])),
|
|
1504
1545
|
// text+image only. Meta also documents video, audio (degraded on 1.3), and PDF, but
|
|
1505
1546
|
// the catalog modality enum is text/image and over-advertising poisons the exported
|
|
1506
|
-
// client config (see tests/catalog-input-modality-enum.test.ts).
|
|
1547
|
+
// client config (see tests/codex-integration/catalog-input-modality-enum.test.ts).
|
|
1507
1548
|
modelInputModalities: Object.fromEntries(META_MUSE_MODELS.map(id => [id, ["text", "image"] as ["text", "image"]])),
|
|
1508
1549
|
modelReasoningEfforts: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORTS])),
|
|
1509
1550
|
modelReasoningEffortMap: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORT_MAP])),
|
|
@@ -1519,7 +1560,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1519
1560
|
- 목적과 의도: Let an operator who already signed the Muse Code CLI in reach Muse Spark with that credential, instead of provisioning a second key.
|
|
1520
1561
|
- 기존 구현 및 제약 조건: The CLI stores a pointer at ~/.config/muse/auth.json and the secret in the macOS Keychain (ai.meta.dev.credentials/meta). Measured: the OAuth access_token 401s on /v1/models while the sibling api_key returns 200, so the usable artifact is a static key, not a refreshable token.
|
|
1521
1562
|
- 검토한 주요 대안: spawn `muse login` and poll; reimplement Meta's device grant; treat it as a second key preset; ship nothing.
|
|
1522
|
-
- 선택한 방식: an
|
|
1563
|
+
- 선택한 방식: an OAuth provider that imports the existing credential on macOS and accepts a pasted key elsewhere, validates either once, and never spawns or reimplements anything.
|
|
1523
1564
|
- 다른 대안 대신 이 방식을 선택한 이유: `muse login` has no non-interactive mode, so a spawned child could outlive cancellation, and polling for the pointer file is satisfied instantly by the one already on disk — reimporting the OLD account on a force-login. Reimplementing the grant would mean guessing a client id the vendor does not publish.
|
|
1524
1565
|
- 장점, 단점 및 영향: no new credential to provision, and the id is distinct from meta-model so neither pool contaminates the other. Meta scopes this credential to its own CLI, so the provider carries a HIGH_RISK ToS warning, a CLI-side warning before any read, and a note that says plainly what is unsupported.
|
|
1525
1566
|
*/
|
|
@@ -1540,7 +1581,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1540
1581
|
modelInputModalities: Object.fromEntries(META_MUSE_MODELS.map(id => [id, ["text", "image"] as ["text", "image"]])),
|
|
1541
1582
|
modelReasoningEfforts: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORTS])),
|
|
1542
1583
|
modelReasoningEffortMap: Object.fromEntries(META_MUSE_MODELS.map(id => [id, META_MUSE_REASONING_EFFORT_MAP])),
|
|
1543
|
-
note: "Reuses the API key the Muse Code CLI stores after `muse login` (macOS only; requires the CLI installed and signed in). Meta scopes that credential to the Muse Code CLI, so this is an UNSUPPORTED use: Meta does not authorize subscription coverage outside its own CLI, how these calls settle is not observable from the API, and you should treat every call as billable against your account. The imported
|
|
1584
|
+
note: "Reuses the API key the Muse Code CLI stores after `muse login` (macOS only; requires the CLI installed and signed in). Meta ships no native Windows CLI and the Linux credential storage has not been measured, so on those platforms OpenCodex asks you to paste the Muse Code API key from https://dev.meta.ai instead of importing one; a pasted key faces the same format check and live validation as an imported one. Meta scopes that credential to the Muse Code CLI, so this is an UNSUPPORTED use: Meta does not authorize subscription coverage outside its own CLI, how these calls settle is not observable from the API, and you should treat every call as billable against your account. The key, imported or pasted, is copied into OpenCodex's auth store. OpenCodex reads Meta's subscription windows from streaming responses and shows the last observed value with its age; there is no endpoint to query them on demand, so refreshing one requires another streaming turn, and translated (non-passthrough) turns report none. Rate limits apply per team, not per key. For a supported path use the meta-model provider with your own key (export it as META_MODEL_API_KEY).",
|
|
1544
1585
|
},
|
|
1545
1586
|
{
|
|
1546
1587
|
id: "umans",
|
|
@@ -1576,7 +1617,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1576
1617
|
// finish_reason or [DONE] (#2260). The adapter still rejects incomplete argument JSON.
|
|
1577
1618
|
openaiChatEofTolerance: true,
|
|
1578
1619
|
/* [Decision Log]
|
|
1579
|
-
- 목적과 의도: Route the exact models OpenCode Go documents on the Responses endpoint — GPT 5.6 Luna, and Muse Spark
|
|
1620
|
+
- 목적과 의도: Route the exact models OpenCode Go documents on the Responses endpoint — GPT 5.6 Luna, Grok 4.6, and Muse Spark Contributor (#2617).
|
|
1580
1621
|
- 기존 구현 및 제약 조건: The provider is mixed-wire but its provider-wide `openai-chat` adapter sent Luna to `/chat/completions`; explicit user `modelAdapters` entries must remain authoritative.
|
|
1581
1622
|
- 검토한 주요 대안: Change the whole provider to Responses; infer the wire from model-family names; add one registry-only exact-model default.
|
|
1582
1623
|
- 선택한 방식: Declare only the named models as `openai-responses` through the existing registry default mechanism; the map stays an exact-model allowlist rather than a family or provider-wide rule.
|
|
@@ -1585,6 +1626,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1585
1626
|
*/
|
|
1586
1627
|
modelWireDefaults: {
|
|
1587
1628
|
"gpt-5.6-luna": "openai-responses",
|
|
1629
|
+
"grok-4.6": "openai-responses",
|
|
1588
1630
|
"muse-spark-1.3-contributor": "openai-responses",
|
|
1589
1631
|
"muse-spark-1.2-contributor": "openai-responses",
|
|
1590
1632
|
},
|
|
@@ -1614,6 +1656,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1614
1656
|
},
|
|
1615
1657
|
modelReasoningEfforts: {
|
|
1616
1658
|
"gpt-5.6-luna": OPENAI_API_GPT56_REASONING_EFFORTS,
|
|
1659
|
+
"grok-4.6": ["low", "medium", "high", "xhigh"],
|
|
1617
1660
|
"glm-5.3": ZAI_GLM_53_REASONING_EFFORTS,
|
|
1618
1661
|
"glm-5.3-flash": ZAI_GLM_53_REASONING_EFFORTS,
|
|
1619
1662
|
"glm-5.2": ZAI_GLM_52_REASONING_EFFORTS,
|
|
@@ -1625,7 +1668,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1625
1668
|
...Object.fromEntries(OPENCODE_GO_THINKING_BUDGET_MODELS.map(id => [id, THINKING_BUDGET_EFFORTS])),
|
|
1626
1669
|
...Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, deepseekThinkingEffortsFor(id)])),
|
|
1627
1670
|
},
|
|
1628
|
-
modelDefaultReasoningEfforts: { "kimi-k3": "max" },
|
|
1671
|
+
modelDefaultReasoningEfforts: { "grok-4.6": "high", "kimi-k3": "max" },
|
|
1629
1672
|
// glm-5.2 uses identity labels now that `max` is a native Codex level (no alias map);
|
|
1630
1673
|
// the thinking-toggle map is a REAL wire alias (effort -> enabled/disabled) and stays.
|
|
1631
1674
|
modelReasoningEffortMap: {
|
|
@@ -1858,7 +1901,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
1858
1901
|
// 2026-07-10: defaultModel is frozen pending Vertex-specific Tier-2 evidence; Gemini API
|
|
1859
1902
|
// evidence from ai.google.dev does not establish Vertex publisher availability.
|
|
1860
1903
|
{ id: "google-vertex", label: "Google Vertex AI", adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authKind: "key", dashboardUrl: "https://console.cloud.google.com/vertex-ai", defaultModel: "gemini-3-pro", googleMode: "vertex", jawcodeBundle: "google", extraMetadataAliases: ["gemini-vertex"] },
|
|
1861
|
-
{ id: "google-antigravity", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", allowBaseUrlOverride: true, dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.8-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] },
|
|
1904
|
+
{ id: "google-antigravity", alias: "agy", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", allowBaseUrlOverride: true, dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.8-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] },
|
|
1862
1905
|
{ id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" },
|
|
1863
1906
|
{ id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" },
|
|
1864
1907
|
{ id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" },
|
|
@@ -1,15 +1,41 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { MODEL_ADAPTER_OVERRIDE_ALLOWED, type OcxConfig, type OcxProviderConfig } from "../types";
|
|
2
|
+
import { providerModelWireDefault } from "./registry";
|
|
2
3
|
|
|
3
4
|
export const XAI_RESPONSES_OPT_IN_MODELS = ["grok-4.6", "grok-4.5"] as const;
|
|
5
|
+
export const XAI_RESPONSES_DEFAULT_VERSION = 1;
|
|
4
6
|
|
|
5
7
|
export type XaiResponsesOptInState = boolean | "mixed";
|
|
6
8
|
|
|
7
|
-
/**
|
|
9
|
+
/** Effective Responses-inbound wire; the legacy API field name remains compatible. */
|
|
8
10
|
export function xaiResponsesOptInState(provider: OcxProviderConfig): XaiResponsesOptInState {
|
|
9
|
-
const enabled = XAI_RESPONSES_OPT_IN_MODELS.map(
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
const enabled = XAI_RESPONSES_OPT_IN_MODELS.map(model => {
|
|
12
|
+
const configured = provider.modelAdapters?.[model];
|
|
13
|
+
const wire = configured && MODEL_ADAPTER_OVERRIDE_ALLOWED.has(configured)
|
|
14
|
+
? configured
|
|
15
|
+
: providerModelWireDefault("xai", provider, model, MODEL_ADAPTER_OVERRIDE_ALLOWED, "responses")
|
|
16
|
+
?? provider.adapter;
|
|
17
|
+
return wire === "openai-responses";
|
|
18
|
+
});
|
|
12
19
|
if (enabled.every(Boolean)) return true;
|
|
13
20
|
if (enabled.some(Boolean)) return "mixed";
|
|
14
21
|
return false;
|
|
15
22
|
}
|
|
23
|
+
|
|
24
|
+
/** Upgrade old Chat choices once; a later explicit Chat opt-in must survive restart. */
|
|
25
|
+
export function migrateXaiResponsesDefault(config: OcxConfig): boolean {
|
|
26
|
+
const provider = config.providers.xai;
|
|
27
|
+
if (!provider || (provider.xaiResponsesDefaultVersion ?? 0) >= XAI_RESPONSES_DEFAULT_VERSION) return false;
|
|
28
|
+
if (!XAI_RESPONSES_OPT_IN_MODELS.every(model =>
|
|
29
|
+
providerModelWireDefault("xai", provider, model, MODEL_ADAPTER_OVERRIDE_ALLOWED, "responses") === "openai-responses")) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
const modelAdapters = { ...provider.modelAdapters };
|
|
33
|
+
for (const model of XAI_RESPONSES_OPT_IN_MODELS) {
|
|
34
|
+
if (modelAdapters[model] === "openai-chat") delete modelAdapters[model];
|
|
35
|
+
}
|
|
36
|
+
const next = { ...provider, xaiResponsesDefaultVersion: XAI_RESPONSES_DEFAULT_VERSION };
|
|
37
|
+
if (Object.keys(modelAdapters).length) next.modelAdapters = modelAdapters;
|
|
38
|
+
else delete next.modelAdapters;
|
|
39
|
+
config.providers = { ...config.providers, xai: next };
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Activation: install the delivery sink so a detected reset actually reaches the operator.
|
|
3
|
+
*
|
|
4
|
+
* Separate from the poller because the two are independent. `pollSeconds: 0` is a supported
|
|
5
|
+
* configuration — observe live quota refreshes, never probe on a timer — and in that mode the
|
|
6
|
+
* sink must still be installed or detection would run its bookkeeping and deliver nowhere.
|
|
7
|
+
*
|
|
8
|
+
* The sink is what the wp3 seams gate on: with none installed, observeQuotaSnapshot returns
|
|
9
|
+
* immediately and no baseline is even stored. So this module is the single switch that turns the
|
|
10
|
+
* whole subsystem from inert to live, which is why it re-reads config on every event rather than
|
|
11
|
+
* capturing it once — an operator who changes `kinds` should not have to restart.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { QuotaResetEvent } from "./reset-detector";
|
|
15
|
+
|
|
16
|
+
let activated = false;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Install the sink when config asks for it, and remove it when config no longer does.
|
|
20
|
+
*
|
|
21
|
+
* Idempotent and cheap to call repeatedly: the enable check is the mtime-cached resolver, not a
|
|
22
|
+
* config parse. Returns whether a sink is now installed, for the caller's own reporting.
|
|
23
|
+
*/
|
|
24
|
+
export async function syncQuotaResetActivation(): Promise<boolean> {
|
|
25
|
+
const [{ isQuotaResetNotificationEnabled }, observer] = await Promise.all([
|
|
26
|
+
import("./reset-notify-config"),
|
|
27
|
+
import("./reset-observer"),
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
if (!isQuotaResetNotificationEnabled()) {
|
|
31
|
+
// Deliberately clears a previously installed sink. Disabling in config must actually stop
|
|
32
|
+
// delivery on the next check, not merely stop new observations.
|
|
33
|
+
if (activated) {
|
|
34
|
+
observer.setQuotaResetSink(null);
|
|
35
|
+
activated = false;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (activated) return true;
|
|
41
|
+
observer.setQuotaResetSink(dispatch);
|
|
42
|
+
activated = true;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Hand one event to the sinks.
|
|
48
|
+
*
|
|
49
|
+
* Synchronous by signature (the observer contract) and fire-and-forget in body: delivery must
|
|
50
|
+
* never delay the quota write that triggered it. The observer has already claimed the
|
|
51
|
+
* idempotence key by the time this runs, so a failed delivery is not retried — see reset-sinks.
|
|
52
|
+
*
|
|
53
|
+
* Config is resolved HERE, per event, so a changed `kinds` list or webhook URL takes effect
|
|
54
|
+
* without a restart.
|
|
55
|
+
*/
|
|
56
|
+
function dispatch(event: QuotaResetEvent): void {
|
|
57
|
+
void (async () => {
|
|
58
|
+
try {
|
|
59
|
+
const [{ currentQuotaResetNotify }, { deliverQuotaResetEvent }] = await Promise.all([
|
|
60
|
+
import("./reset-notify-config"),
|
|
61
|
+
import("./reset-sinks"),
|
|
62
|
+
]);
|
|
63
|
+
const config = currentQuotaResetNotify();
|
|
64
|
+
if (!config.enabled) return;
|
|
65
|
+
await deliverQuotaResetEvent(event, config);
|
|
66
|
+
} catch {
|
|
67
|
+
// Best-effort by contract. deliverQuotaResetEvent does not reject, so reaching here means
|
|
68
|
+
// the import itself failed, which the next event will retry.
|
|
69
|
+
}
|
|
70
|
+
})();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Test-only: forget activation state so a suite can re-activate against fresh config. */
|
|
74
|
+
export function resetQuotaResetActivationForTests(): void {
|
|
75
|
+
activated = false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Test-only: whether this module currently believes a sink is installed. */
|
|
79
|
+
export function isQuotaResetActivatedForTests(): boolean {
|
|
80
|
+
return activated;
|
|
81
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure quota-reset detection: two consecutive observations of one usage window in, at most
|
|
3
|
+
* one reset event out.
|
|
4
|
+
*
|
|
5
|
+
* Nothing here reads a clock, a config, or the disk. `now` is a parameter so a test can
|
|
6
|
+
* place a deadline in the past without waiting for it, and so the same snapshot pair always
|
|
7
|
+
* yields the same answer.
|
|
8
|
+
*
|
|
9
|
+
* Design and the seven false-positive traps this guards against:
|
|
10
|
+
* devlog/_plan/260828_quota_reset_detection/000_plan.md
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** One observed usage window, normalized away from provider-specific field names. */
|
|
14
|
+
export type QuotaWindowObservation = {
|
|
15
|
+
/** Window identity: "5h", "weekly", "monthly", or "custom:<label>". */
|
|
16
|
+
readonly window: string;
|
|
17
|
+
/** 0-100 used percent. Absent when upstream stopped reporting this window. */
|
|
18
|
+
readonly percent?: number;
|
|
19
|
+
/** Epoch ms. Absent when upstream declares no clock, or declared a sentinel. */
|
|
20
|
+
readonly resetAt?: number;
|
|
21
|
+
/**
|
|
22
|
+
* Window length in seconds, when upstream states it.
|
|
23
|
+
*
|
|
24
|
+
* Used only to bound natural decay in a rolling window (see the surprise branch). Absent
|
|
25
|
+
* for providers that never declare a length; the label table then supplies a conservative
|
|
26
|
+
* default.
|
|
27
|
+
*/
|
|
28
|
+
readonly windowSeconds?: number;
|
|
29
|
+
/**
|
|
30
|
+
* When this observation was taken, stamped by the observer as it stores the baseline.
|
|
31
|
+
*
|
|
32
|
+
* Needed because a rolling window's percent decays with WALL TIME, so distinguishing decay
|
|
33
|
+
* from a reset requires knowing how much time separates the two observations. Absent in
|
|
34
|
+
* baselines written before this field existed, and the decay bound is then skipped rather
|
|
35
|
+
* than guessed.
|
|
36
|
+
*/
|
|
37
|
+
readonly observedAt?: number;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type QuotaResetKind = "scheduled" | "surprise";
|
|
41
|
+
|
|
42
|
+
export type QuotaResetEvent = {
|
|
43
|
+
readonly kind: QuotaResetKind;
|
|
44
|
+
/** "codex" or a provider name. Never an account identifier. */
|
|
45
|
+
readonly scope: string;
|
|
46
|
+
/** Non-identifying account discriminator; see quotaAccountTag. */
|
|
47
|
+
readonly accountTag: string;
|
|
48
|
+
readonly window: string;
|
|
49
|
+
readonly percentBefore?: number;
|
|
50
|
+
readonly percentAfter?: number;
|
|
51
|
+
readonly previousResetAt?: number;
|
|
52
|
+
readonly resetAt?: number;
|
|
53
|
+
/**
|
|
54
|
+
* When WE noticed, not when the reset happened.
|
|
55
|
+
*
|
|
56
|
+
* Observation cadence is bounded by the 5-minute provider cache TTL and the 10-minute
|
|
57
|
+
* per-account TTL, so the reset instant can only ever be bracketed between two
|
|
58
|
+
* observations. Naming this field `detectedAt` keeps that limitation visible to every
|
|
59
|
+
* consumer instead of implying a precision we do not have.
|
|
60
|
+
*/
|
|
61
|
+
readonly detectedAt: number;
|
|
62
|
+
/** Idempotence key: scope|accountTag|window|resetAt. */
|
|
63
|
+
readonly key: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A drop smaller than this is rounding noise or a same-window correction, not a reset.
|
|
68
|
+
*
|
|
69
|
+
* Upstream percents are integers and a real window rollover drops by tens of points, so
|
|
70
|
+
* nothing genuine sits under this floor. It applies only to the surprise branch: a
|
|
71
|
+
* scheduled rollover is proven by its own expired deadline and needs no magnitude test.
|
|
72
|
+
*/
|
|
73
|
+
export const MIN_SURPRISE_DROP_PERCENT = 5;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Account discriminator: stable for this install, unlinkable outside it.
|
|
77
|
+
*
|
|
78
|
+
* Events must distinguish accounts — a provider report is keyed by provider only, so an
|
|
79
|
+
* account switch would otherwise inherit the previous account's history — while carrying no
|
|
80
|
+
* account identity, because the payload crosses a webhook boundary to a third party.
|
|
81
|
+
*
|
|
82
|
+
* The salt is what makes the second half true. An unsalted `Bun.hash` of an email is
|
|
83
|
+
* brute-forceable in tens of guesses against a small, highly guessable input space, which
|
|
84
|
+
* would let a webhook recipient confirm-or-deny any guessed account. Salted, the tag is
|
|
85
|
+
* meaningless to anyone without the install salt, and still stable across restarts because
|
|
86
|
+
* the salt is persisted — which is what the durable idempotence key depends on.
|
|
87
|
+
*
|
|
88
|
+
* Not a cryptographic commitment: it defeats an offline dictionary attack by a payload
|
|
89
|
+
* recipient, which is the threat the privacy constraint names.
|
|
90
|
+
*/
|
|
91
|
+
export function quotaAccountTag(accountKey: string, salt: string): string {
|
|
92
|
+
return Bun.hash(`${salt}\u0000${accountKey}`).toString(36).slice(0, 8).padStart(8, "0");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function quotaResetKey(input: {
|
|
96
|
+
readonly scope: string;
|
|
97
|
+
readonly accountTag: string;
|
|
98
|
+
readonly window: string;
|
|
99
|
+
readonly resetAt?: number;
|
|
100
|
+
readonly previousResetAt?: number;
|
|
101
|
+
}): string {
|
|
102
|
+
// Prefer the NEW deadline: every later observation of the same post-reset window computes
|
|
103
|
+
// the same key, which is what makes repeated detection idempotent.
|
|
104
|
+
//
|
|
105
|
+
// Fall back to the deadline that just expired when upstream reports no new one. A bare
|
|
106
|
+
// "none" discriminator would collapse every clockless reset of one window onto a single
|
|
107
|
+
// key, so the first claim would permanently suppress all later ones.
|
|
108
|
+
const discriminator = input.resetAt ?? input.previousResetAt ?? "none";
|
|
109
|
+
return [input.scope, input.accountTag, input.window, discriminator].join("|");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Percent guard applied at this boundary.
|
|
114
|
+
*
|
|
115
|
+
* Both upstream normalizers clamp to 0-100, but a value outside that range means the payload
|
|
116
|
+
* bypassed them, and admitting a negative would manufacture an enormous apparent drop. Same
|
|
117
|
+
* philosophy as the resetAt guard below: re-check rather than trust the caller.
|
|
118
|
+
*/
|
|
119
|
+
function finitePercent(value: number | undefined): number | undefined {
|
|
120
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
|
121
|
+
return value >= 0 && value <= 100 ? value : undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Epoch-ms guard applied at this boundary on purpose.
|
|
126
|
+
*
|
|
127
|
+
* The two callers normalize differently — src/providers/quota.ts:279 treats <= 0 as a
|
|
128
|
+
* sentinel and scales seconds to ms, while src/codex/quota.ts:192 admits 0 and does not
|
|
129
|
+
* scale — so the detector cannot trust either and re-checks here.
|
|
130
|
+
*/
|
|
131
|
+
function finiteResetAt(value: number | undefined): number | undefined {
|
|
132
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Decide whether one window transition is a reset.
|
|
137
|
+
*
|
|
138
|
+
* Returns null for every ambiguous case. The bias is deliberate: a missed notification is
|
|
139
|
+
* an inconvenience, while a false one trains the operator to ignore the channel.
|
|
140
|
+
*/
|
|
141
|
+
export function detectQuotaReset(input: {
|
|
142
|
+
readonly scope: string;
|
|
143
|
+
readonly accountTag: string;
|
|
144
|
+
readonly previous: QuotaWindowObservation | undefined;
|
|
145
|
+
readonly next: QuotaWindowObservation;
|
|
146
|
+
readonly now: number;
|
|
147
|
+
}): QuotaResetEvent | null {
|
|
148
|
+
const { scope, accountTag, previous, next, now } = input;
|
|
149
|
+
|
|
150
|
+
// No baseline, no transition. This one line is what stops a cold start (writers do not
|
|
151
|
+
// hydrate from disk), a reauth row clear, a reconciliation delete, and an account switch
|
|
152
|
+
// from each manufacturing an event out of thin air.
|
|
153
|
+
if (!previous) return null;
|
|
154
|
+
if (previous.window !== next.window) return null;
|
|
155
|
+
|
|
156
|
+
const percentBefore = finitePercent(previous.percent);
|
|
157
|
+
const percentAfter = finitePercent(next.percent);
|
|
158
|
+
const previousResetAt = finiteResetAt(previous.resetAt);
|
|
159
|
+
const resetAt = finiteResetAt(next.resetAt);
|
|
160
|
+
|
|
161
|
+
const build = (kind: QuotaResetKind): QuotaResetEvent => ({
|
|
162
|
+
kind,
|
|
163
|
+
scope,
|
|
164
|
+
accountTag,
|
|
165
|
+
window: next.window,
|
|
166
|
+
...(percentBefore !== undefined ? { percentBefore } : {}),
|
|
167
|
+
...(percentAfter !== undefined ? { percentAfter } : {}),
|
|
168
|
+
...(previousResetAt !== undefined ? { previousResetAt } : {}),
|
|
169
|
+
...(resetAt !== undefined ? { resetAt } : {}),
|
|
170
|
+
detectedAt: now,
|
|
171
|
+
key: quotaResetKey({
|
|
172
|
+
scope,
|
|
173
|
+
accountTag,
|
|
174
|
+
window: next.window,
|
|
175
|
+
...(resetAt !== undefined ? { resetAt } : {}),
|
|
176
|
+
...(previousResetAt !== undefined ? { previousResetAt } : {}),
|
|
177
|
+
}),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// A window whose percent vanished says nothing about a reset: upstream simply stopped
|
|
181
|
+
// reporting it. Treating absence as 0% would fire on every degraded payload.
|
|
182
|
+
if (percentAfter === undefined) return null;
|
|
183
|
+
|
|
184
|
+
const deadlinePassed = previousResetAt !== undefined && now >= previousResetAt;
|
|
185
|
+
|
|
186
|
+
if (deadlinePassed) {
|
|
187
|
+
if (percentBefore !== undefined && percentAfter > percentBefore) return null;
|
|
188
|
+
// An expired deadline alone is NOT enough. src/codex/quota.ts:323-329 carries the
|
|
189
|
+
// previous burst tuple forward verbatim when a header write omits it, so a partial
|
|
190
|
+
// write reproduces the old deadline and the old percent exactly. Once wall-clock passes
|
|
191
|
+
// that copied deadline, "the clock expired" would fire on a snapshot where upstream
|
|
192
|
+
// said nothing at all — a false positive on the highest-frequency write path in the
|
|
193
|
+
// system (one per pooled response).
|
|
194
|
+
//
|
|
195
|
+
// Require corroboration that the window actually turned over: either usage fell, or
|
|
196
|
+
// upstream issued a new deadline. A byte-identical carried-forward window gives
|
|
197
|
+
// neither, so it stays silent.
|
|
198
|
+
const usageFell = percentBefore !== undefined && percentAfter < percentBefore;
|
|
199
|
+
const deadlineAdvanced = resetAt !== undefined && resetAt > previousResetAt!;
|
|
200
|
+
if (!usageFell && !deadlineAdvanced) return null;
|
|
201
|
+
return build("scheduled");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Still inside the previous window, so quota coming back means upstream moved the window
|
|
205
|
+
// out of band.
|
|
206
|
+
//
|
|
207
|
+
// A material percent DROP is the only accepted evidence here. An advancing deadline is
|
|
208
|
+
// deliberately NOT sufficient, even though it looks like a fresh window: a ROLLING window
|
|
209
|
+
// (Anthropic's five_hour, Codex's burst window) reports a deadline that creeps forward on
|
|
210
|
+
// every poll by exactly the elapsed time, so "the deadline advanced" is true of every
|
|
211
|
+
// healthy observation of a rolling window and would fire continuously.
|
|
212
|
+
//
|
|
213
|
+
// Nothing is lost by requiring the drop. A surprise reset is worth telling an operator
|
|
214
|
+
// about because quota came BACK; if usage did not fall, none did, and there is nothing to
|
|
215
|
+
// report. The scheduled branch above can still accept an advancing deadline as evidence,
|
|
216
|
+
// because there the previous deadline had genuinely expired.
|
|
217
|
+
if (
|
|
218
|
+
previousResetAt !== undefined
|
|
219
|
+
&& percentBefore !== undefined
|
|
220
|
+
&& percentBefore - percentAfter >= MIN_SURPRISE_DROP_PERCENT
|
|
221
|
+
&& !isRollingWindowCreep({
|
|
222
|
+
previousResetAt,
|
|
223
|
+
resetAt,
|
|
224
|
+
previousObservedAt: previous.observedAt,
|
|
225
|
+
now,
|
|
226
|
+
})
|
|
227
|
+
) {
|
|
228
|
+
return build("surprise");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// A window with no deadline on either side is not evaluated. Several provider parsers
|
|
232
|
+
// never emit a reset clock at all (credit balances, prepaid pools), and a bare percent
|
|
233
|
+
// drop there is as likely to be a top-up or a plan change as a window rollover. Firing on
|
|
234
|
+
// it would make the channel noise; the honest answer is that those providers expose no
|
|
235
|
+
// window semantics to detect.
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* True when the deadline merely CREPT forward with the clock, which is what a rolling window
|
|
241
|
+
* does while nothing resets.
|
|
242
|
+
*
|
|
243
|
+
* A rolling window (Anthropic five_hour, Codex burst) has no rollover instant: usage ages out
|
|
244
|
+
* continuously, so its percent falls on its own and its deadline slides forward by roughly the
|
|
245
|
+
* elapsed time on every poll. MIN_SURPRISE_DROP_PERCENT only screens integer rounding, so a
|
|
246
|
+
* healthy rolling window fired false "surprise" events — measured: 88% -> 61% one hour into a
|
|
247
|
+
* 5h window, a 27-point drop with no reset involved.
|
|
248
|
+
*
|
|
249
|
+
* The discriminator is deadline MOVEMENT against elapsed time, not drop magnitude. Decay
|
|
250
|
+
* magnitude cannot be bounded from elapsed time alone, because the percent that ages out
|
|
251
|
+
* depends on WHEN the usage happened: an hour of idling can retire a large burst that all
|
|
252
|
+
* landed in one minute. Deadline movement behaves differently — while a window is merely
|
|
253
|
+
* rolling, its deadline advances by about the elapsed time, whereas a genuine out-of-band
|
|
254
|
+
* reset issues a deadline a FULL window into the future, jumping far beyond the elapsed gap.
|
|
255
|
+
*
|
|
256
|
+
* Fails OPEN (returns false, letting the event through) whenever the evidence is missing: no
|
|
257
|
+
* baseline timestamp, no deadline on either side, or a deadline that moved backwards. A
|
|
258
|
+
* missed reset is an inconvenience; a suppressed one on an unproven guess is a defect.
|
|
259
|
+
*/
|
|
260
|
+
function isRollingWindowCreep(input: {
|
|
261
|
+
readonly previousResetAt: number | undefined;
|
|
262
|
+
readonly resetAt: number | undefined;
|
|
263
|
+
readonly previousObservedAt: number | undefined;
|
|
264
|
+
readonly now: number;
|
|
265
|
+
}): boolean {
|
|
266
|
+
const observedAt = finiteResetAt(input.previousObservedAt);
|
|
267
|
+
const previousResetAt = input.previousResetAt;
|
|
268
|
+
const resetAt = input.resetAt;
|
|
269
|
+
if (observedAt === undefined || previousResetAt === undefined || resetAt === undefined) {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
const elapsedMs = input.now - observedAt;
|
|
273
|
+
if (elapsedMs <= 0) return false;
|
|
274
|
+
const deadlineShiftMs = resetAt - previousResetAt;
|
|
275
|
+
// A deadline that stood still or moved backwards is not creep. Standing still while usage
|
|
276
|
+
// fell is the clearest possible surprise-reset signature, so it must reach the operator.
|
|
277
|
+
if (deadlineShiftMs <= 0) return false;
|
|
278
|
+
// Creep tracks the clock. The 2x tolerance absorbs polling jitter and upstream rounding to
|
|
279
|
+
// whole minutes without approaching a real reset, which shifts the deadline by a whole
|
|
280
|
+
// window — hours, against a gap that is minutes on the observation paths that exist here.
|
|
281
|
+
return deadlineShiftMs <= elapsedMs * 2;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Pair two window lists by identity and yield every detected reset. */
|
|
285
|
+
export function detectQuotaResets(input: {
|
|
286
|
+
readonly scope: string;
|
|
287
|
+
readonly accountTag: string;
|
|
288
|
+
readonly previous: ReadonlyArray<QuotaWindowObservation>;
|
|
289
|
+
readonly next: ReadonlyArray<QuotaWindowObservation>;
|
|
290
|
+
readonly now: number;
|
|
291
|
+
}): QuotaResetEvent[] {
|
|
292
|
+
const before = new Map(input.previous.map(item => [item.window, item]));
|
|
293
|
+
const events: QuotaResetEvent[] = [];
|
|
294
|
+
for (const observation of input.next) {
|
|
295
|
+
const event = detectQuotaReset({
|
|
296
|
+
scope: input.scope,
|
|
297
|
+
accountTag: input.accountTag,
|
|
298
|
+
previous: before.get(observation.window),
|
|
299
|
+
next: observation,
|
|
300
|
+
now: input.now,
|
|
301
|
+
});
|
|
302
|
+
if (event) events.push(event);
|
|
303
|
+
}
|
|
304
|
+
return events;
|
|
305
|
+
}
|