@bitkyc08/opencodex 2.52.0 → 2.53.0-preview.20260913
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/gui/dist/assets/index-BBOZWGB6.css +1 -0
- package/gui/dist/assets/index-D7ynYo2K.js +128 -0
- package/gui/dist/index.html +2 -2
- package/native/remote-workspace-helper/Cargo.lock +130 -0
- package/native/remote-workspace-helper/Cargo.toml +24 -0
- package/native/remote-workspace-helper/src/main.rs +49 -0
- package/native/remote-workspace-helper/src/protocol.rs +246 -0
- package/native/remote-workspace-helper/src/sandbox/macos.rs +19 -0
- package/native/remote-workspace-helper/src/sandbox/mod.rs +77 -0
- package/native/remote-workspace-helper/src/sandbox/windows.rs +15 -0
- package/package.json +6 -1
- package/src/adapters/anthropic-image-normalize.ts +30 -2
- package/src/adapters/anthropic.ts +1 -1
- package/src/adapters/base.ts +8 -2
- package/src/adapters/cursor/cursor-errors.ts +12 -0
- package/src/adapters/cursor/thread-continuity.ts +93 -0
- package/src/adapters/cursor.ts +104 -73
- package/src/adapters/devin/cloud-direct/chat.ts +312 -23
- package/src/adapters/devin/cloud-direct/metadata.ts +31 -2
- package/src/adapters/devin/live-models.ts +70 -3
- package/src/adapters/devin.ts +281 -21
- package/src/adapters/google-wire-compiler.ts +14 -6
- package/src/adapters/google.ts +22 -8
- package/src/adapters/kiro/adapter.ts +316 -0
- package/src/adapters/kiro/conversation.ts +136 -0
- package/src/adapters/kiro/payload.ts +432 -0
- package/src/adapters/kiro/reasoning.ts +56 -0
- package/src/adapters/kiro/stream.ts +1153 -0
- package/src/adapters/kiro/usage.ts +223 -0
- package/src/adapters/kiro/wire.ts +76 -0
- package/src/adapters/kiro.ts +8 -2319
- package/src/adapters/mimo-free.ts +1 -1
- package/src/adapters/openai-chat-images.ts +101 -0
- package/src/adapters/openai-chat.ts +201 -181
- package/src/adapters/openai-responses.ts +92 -224
- package/src/adapters/registry.ts +0 -7
- package/src/adapters/run-turn-queue.ts +13 -6
- package/src/bridge.ts +14 -15
- package/src/chat/inbound.ts +29 -4
- package/src/chat/outbound.ts +145 -107
- package/src/claude/desktop-profile.ts +4 -6
- package/src/cli/account-api.ts +14 -0
- package/src/cli/account-extended.ts +1 -1
- package/src/cli/account-history.ts +60 -0
- package/src/cli/account-main.ts +80 -0
- package/src/cli/account.ts +11 -3
- package/src/cli/capabilities.ts +113 -0
- package/src/cli/catalog.ts +109 -0
- package/src/cli/dispatch.ts +9 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/index.ts +2 -2
- package/src/cli/observe.ts +28 -1
- package/src/cli/opencode.ts +42 -8
- package/src/cli/provider-runtime.ts +11 -1
- package/src/cli/provider.ts +22 -2
- package/src/cli/registry.ts +21 -0
- package/src/cli/remote-workspace.ts +154 -0
- package/src/cli/status.ts +39 -7
- package/src/cli/usage-report.ts +14 -2
- package/src/client/hub-client.ts +34 -0
- package/src/client/hub-state.ts +9 -1
- package/src/codex/account-store.ts +78 -0
- package/src/codex/auth-api.ts +81 -54
- package/src/codex/auth-context.ts +45 -16
- package/src/codex/catalog/effort.ts +1 -1
- package/src/codex/catalog/metadata.ts +3 -6
- package/src/codex/catalog/native-models.ts +4 -4
- package/src/codex/catalog/parsing.ts +2 -20
- package/src/codex/catalog/provider-fetch.ts +10 -1
- package/src/codex/catalog/remote.ts +233 -0
- package/src/codex/catalog/sync.ts +403 -35
- package/src/codex/convergence.ts +1 -1
- package/src/codex/history-manifest.ts +36 -0
- package/src/codex/history-provider.ts +32 -5
- package/src/codex/inject.ts +9 -0
- package/src/codex/main-account.ts +113 -0
- package/src/codex/main-device-reauth-api.ts +89 -0
- package/src/codex/main-device-reauth.ts +217 -0
- package/src/codex/native-residue.ts +9 -2
- package/src/codex/quota-auto-refresh.ts +3 -2
- package/src/codex/quota-capacity.ts +98 -0
- package/src/codex/quota-history.ts +160 -0
- package/src/codex/quota-types.ts +8 -0
- package/src/codex/quota.ts +118 -91
- package/src/codex/refresh.ts +2 -1
- package/src/codex/routing.ts +90 -17
- package/src/codex/sync.ts +33 -4
- package/src/combos/request.ts +19 -1
- package/src/config/multi-agent-surface.ts +61 -0
- package/src/config/provider-validation.ts +176 -0
- package/src/config.ts +213 -11
- package/src/generated/compatibility-version.json +436 -168
- package/src/images/loop.ts +119 -36
- package/src/lib/admission.ts +12 -6
- package/src/lib/redact.ts +7 -0
- package/src/lib/translator-budget.ts +4 -3
- package/src/lib/windows-atomic-replace.ts +1 -0
- package/src/lib/windows-elevation.ts +1 -1
- package/src/oauth/chatgpt-device.ts +62 -5
- package/src/oauth/devin/cli-import.ts +130 -0
- package/src/oauth/devin.ts +63 -8
- package/src/oauth/index.ts +29 -14
- package/src/oauth/kiro.ts +18 -6
- package/src/oauth/login-cli.ts +9 -1
- package/src/oauth/meta-muse-device.ts +464 -0
- package/src/oauth/meta-muse.ts +123 -32
- package/src/oauth/pool-kernel.ts +9 -0
- package/src/oauth/pool-settings-capability.ts +2 -2
- package/src/oauth/store.ts +57 -0
- package/src/oauth/types.ts +31 -0
- package/src/providers/derive.ts +13 -3
- package/src/providers/devin-cli-authmode-migration.ts +57 -35
- package/src/providers/devin-provider-merge-migration.ts +240 -0
- package/src/providers/muse-key-quota.ts +117 -0
- package/src/providers/muse-subscription-usage.ts +14 -2
- package/src/providers/openai-sidecar.ts +25 -3
- package/src/providers/opencode-zen-rate-limit.ts +58 -0
- package/src/providers/provider-id-rewrite.ts +20 -5
- package/src/providers/quota-types.ts +12 -0
- package/src/providers/quota.ts +143 -102
- package/src/providers/reasoning-metadata.ts +543 -0
- package/src/providers/registry.ts +80 -49
- package/src/reasoning-effort.ts +26 -2
- package/src/remote/hub-usage.ts +32 -0
- package/src/remote-control/index.ts +192 -41
- package/src/remote-control/workspace-activation.ts +9 -0
- package/src/remote-control/workspace-agent-connection.ts +366 -0
- package/src/remote-control/workspace-claude-runtime.ts +243 -0
- package/src/remote-control/workspace-codex-runtime.ts +531 -0
- package/src/remote-control/workspace-codex-sandbox.ts +115 -0
- package/src/remote-control/workspace-command-runner.ts +748 -0
- package/src/remote-control/workspace-coordinator.ts +231 -0
- package/src/remote-control/workspace-device.ts +585 -0
- package/src/remote-control/workspace-executable.ts +43 -0
- package/src/remote-control/workspace-executor.ts +397 -0
- package/src/remote-control/workspace-hub.ts +519 -0
- package/src/remote-control/workspace-pi-runtime.ts +382 -0
- package/src/remote-control/workspace-process.ts +129 -0
- package/src/remote-control/workspace-rpc.ts +304 -0
- package/src/remote-control/workspace-runtime.ts +60 -0
- package/src/remote-control/workspace-secret-store.ts +39 -0
- package/src/remote-control/workspace-sessions.ts +799 -0
- package/src/remote-control/workspace-tool-bridge.ts +192 -0
- package/src/responses/code-mode-helper-compat.ts +22 -3
- package/src/responses/hosted-tool-policy.ts +0 -1
- package/src/responses/muse-tool-name-alias.ts +379 -0
- package/src/responses/plaintext-v2-agent-messages.ts +902 -0
- package/src/router.ts +7 -0
- package/src/routing/compatibility/behavior.ts +0 -1
- package/src/server/audio-client.ts +64 -0
- package/src/server/audio-dictation.ts +91 -0
- package/src/server/audio-live.ts +185 -0
- package/src/server/audio-transcriptions.ts +183 -0
- package/src/server/audio-upstream.ts +153 -0
- package/src/server/auth-cors.ts +61 -2
- package/src/server/chat-completions.ts +1 -1
- package/src/server/chat-native-sse.ts +92 -48
- package/src/server/chat-native.ts +37 -15
- package/src/server/hub-usage.ts +57 -0
- package/src/server/images.ts +4 -0
- package/src/server/index.ts +722 -57
- package/src/server/lifecycle.ts +5 -6
- package/src/server/live-call-bindings.ts +60 -0
- package/src/server/live.ts +12 -1
- package/src/server/management/agent-settings-routes.ts +25 -4
- package/src/server/management/api-access.ts +37 -0
- package/src/server/management/api-key-usage.ts +7 -2
- package/src/server/management/config-routes.ts +1 -18
- package/src/server/management/context.ts +15 -0
- package/src/server/management/logs-usage-routes.ts +2 -0
- package/src/server/management/oauth-account-routes.ts +39 -12
- package/src/server/management/provider-routes.ts +125 -2
- package/src/server/management/remote-workspace-routes.ts +140 -0
- package/src/server/management/route-registry.ts +15 -0
- package/src/server/management/usage-aggregate-cache.ts +14 -15
- package/src/server/management/usage-summary-cache.ts +2 -0
- package/src/server/management-api.ts +23 -0
- package/src/server/ports.ts +17 -0
- package/src/server/relay-eager.ts +4 -1
- package/src/server/relay.ts +70 -10
- package/src/server/request-decompress.ts +6 -3
- package/src/server/responses/agent-task-recovery.ts +25 -32
- package/src/server/responses/codex-auth-error.ts +11 -0
- package/src/server/responses/codex-ws-exchange.ts +52 -3
- package/src/server/responses/codex-ws-wire.ts +55 -0
- package/src/server/responses/compact.ts +9 -1
- package/src/server/responses/core.ts +337 -73
- package/src/server/responses/encrypted-payload.ts +45 -2
- package/src/server/responses/ws-upstream.ts +4 -1
- package/src/server/responses-self-named-namespace-scrub.ts +1 -3
- package/src/server/responses-undeclared-tool-guard.ts +1 -1
- package/src/server/search.ts +3 -0
- package/src/server/sse-payload-rewrite.ts +136 -51
- package/src/server/ws-bridge.ts +35 -1
- package/src/service/cli.ts +372 -0
- package/src/service/diagnostics.ts +340 -0
- package/src/service/guards.ts +303 -0
- package/src/service/health.ts +222 -0
- package/src/service/launchd.ts +853 -0
- package/src/service/orchestration.ts +617 -0
- package/src/service/repair.ts +334 -0
- package/src/service/state.ts +363 -0
- package/src/service/systemd.ts +229 -0
- package/src/service/windows-ops.ts +690 -0
- package/src/service/windows-scheduler.ts +769 -0
- package/src/service/windows-taskxml.ts +613 -0
- package/src/service.ts +22 -5550
- package/src/storage/cleanup/db.ts +258 -0
- package/src/storage/cleanup/execute.ts +358 -0
- package/src/storage/cleanup/paths.ts +189 -0
- package/src/storage/cleanup/pending.ts +140 -0
- package/src/storage/cleanup/preview.ts +292 -0
- package/src/storage/cleanup/reconcile.ts +347 -0
- package/src/storage/cleanup/restore.ts +932 -0
- package/src/storage/cleanup/satellite.ts +474 -0
- package/src/storage/cleanup/staging.ts +129 -0
- package/src/storage/cleanup/types.ts +98 -0
- package/src/storage/cleanup.ts +49 -3127
- package/src/types/accounts.ts +2 -0
- package/src/types/config.ts +13 -12
- package/src/types/provider.ts +37 -0
- package/src/types/request.ts +2 -0
- package/src/types/tools.ts +17 -5
- package/src/types.ts +1 -0
- package/src/usage/expected-prices.ts +127 -0
- package/src/usage/log.ts +58 -1
- package/src/vision/eligibility.ts +13 -2
- package/src/web-search/loop.ts +56 -3
- package/gui/dist/assets/index-CWXut3rG.js +0 -115
- package/gui/dist/assets/index-EdoPnm9_.css +0 -1
- package/src/adapters/devin-cli/acp.ts +0 -204
- package/src/adapters/devin-cli/adapter.ts +0 -345
- package/src/adapters/devin-cli/binary.ts +0 -69
- package/src/adapters/devin-cli/models.ts +0 -57
- package/src/oauth/devin-cli.ts +0 -149
- package/src/server/responses-reasoning-summary-rewrite.ts +0 -178
package/src/codex/routing.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth }
|
|
|
10
10
|
import {
|
|
11
11
|
POOL_KEY_CODEX,
|
|
12
12
|
normalizeAccountPoolStickyLimit,
|
|
13
|
-
|
|
13
|
+
normalizeCodexAccountPoolStrategy,
|
|
14
14
|
notePoolRotationFailure,
|
|
15
15
|
notePoolRotationSuccess,
|
|
16
16
|
peekRoundRobinAccount,
|
|
@@ -18,7 +18,13 @@ import {
|
|
|
18
18
|
seedPoolRotationAccount,
|
|
19
19
|
selectPriorityTier,
|
|
20
20
|
} from "./pool-rotation";
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
CODEX_EXHAUSTED_USAGE_PERCENT,
|
|
23
|
+
CODEX_UNKNOWN_USAGE_SCORE,
|
|
24
|
+
getAccountQuota,
|
|
25
|
+
isRetiredCodexSparkModel,
|
|
26
|
+
resetAtToMs,
|
|
27
|
+
} from "./quota";
|
|
22
28
|
import { codexPlanKey, isThirtyDayOnlyCodexPlan } from "./plan";
|
|
23
29
|
import {
|
|
24
30
|
MAIN_CODEX_ACCOUNT_ID,
|
|
@@ -199,7 +205,7 @@ export type CodexCooldownSource = "retry-after" | "reset-derived" | "default";
|
|
|
199
205
|
* Add a new explicit group here only when its independent upstream quota is
|
|
200
206
|
* confirmed, so shared limits never receive cross-model bypasses.
|
|
201
207
|
*/
|
|
202
|
-
export type CodexQuotaScope = "shared" | "
|
|
208
|
+
export type CodexQuotaScope = "shared" | "reserve";
|
|
203
209
|
|
|
204
210
|
export type CodexQuotaRecoveryProbeClaim = {
|
|
205
211
|
accountId: string;
|
|
@@ -218,7 +224,7 @@ export type CodexQuotaRecoveryProbeProof = {
|
|
|
218
224
|
/**
|
|
219
225
|
* Requests without a resolved native model retain the historic one-account-per-
|
|
220
226
|
* thread behavior. Requests with a known quota scope get an independent
|
|
221
|
-
* affinity so a
|
|
227
|
+
* affinity so a Reserve failover cannot displace the same thread's Terra/Luna
|
|
222
228
|
* account (and vice versa).
|
|
223
229
|
*/
|
|
224
230
|
type BaseThreadAffinityScope = CodexQuotaScope | "legacy";
|
|
@@ -233,7 +239,6 @@ function isModelDetourAffinityScope(scope: ThreadAffinityScope): scope is ModelD
|
|
|
233
239
|
}
|
|
234
240
|
|
|
235
241
|
const NATIVE_MODEL_QUOTA_SCOPES: Readonly<Record<string, CodexQuotaScope>> = {
|
|
236
|
-
"gpt-5.3-codex-spark": "spark",
|
|
237
242
|
[NATIVE_RESERVE_MODEL]: "reserve",
|
|
238
243
|
};
|
|
239
244
|
|
|
@@ -688,7 +693,7 @@ export function claimDueCodexQuotaRecoveryProbes(
|
|
|
688
693
|
{ scope: undefined, health: upstreamHealth.get(account.id) },
|
|
689
694
|
...[...(quotaScopedHealth.get(account.id) ?? [])].map(([scope, health]) => ({ scope, health })),
|
|
690
695
|
].filter((entry): entry is { scope?: CodexQuotaScope; health: CodexUpstreamHealth } =>
|
|
691
|
-
// Generic WHAM evidence can recover only ordinary quota, never
|
|
696
|
+
// Generic WHAM evidence can recover only ordinary quota, never Reserve.
|
|
692
697
|
// Do not spend this account's one claim per pass on an independent scope and
|
|
693
698
|
// delay the shared scope that the response can actually recover.
|
|
694
699
|
(entry.scope === undefined || entry.scope === "shared")
|
|
@@ -1169,7 +1174,7 @@ function excludedCodexPoolPlanKeys(config: OcxConfig): ReadonlySet<string> | und
|
|
|
1169
1174
|
* selection-only drain so routing never reads the fenced native credential for it, so a rule that
|
|
1170
1175
|
* covered main would disagree with itself between drain and ordinary routing.
|
|
1171
1176
|
*/
|
|
1172
|
-
function isCodexAccountPlanExcluded(
|
|
1177
|
+
export function isCodexAccountPlanExcluded(
|
|
1173
1178
|
config: OcxConfig,
|
|
1174
1179
|
accountId: string,
|
|
1175
1180
|
precomputed?: ReadonlySet<string>,
|
|
@@ -1474,6 +1479,12 @@ function listEligibleCodexAccountIds(
|
|
|
1474
1479
|
return getEligiblePoolAccounts(config, undefined, now, quotaScope, selectionOptions);
|
|
1475
1480
|
}
|
|
1476
1481
|
|
|
1482
|
+
/** Shared reset timestamps are not evidence for independent model-quota groups. */
|
|
1483
|
+
function accountPoolStrategyForScope(config: OcxConfig, quotaScope?: CodexQuotaScope) {
|
|
1484
|
+
const strategy = normalizeCodexAccountPoolStrategy(config.accountPoolStrategy);
|
|
1485
|
+
return strategy === "reset-first" && isIndependentCodexQuotaScope(quotaScope) ? "quota" : strategy;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1477
1488
|
function stickyLimitForConfig(config: OcxConfig): number {
|
|
1478
1489
|
return normalizeAccountPoolStickyLimit(config.accountPoolStickyLimit);
|
|
1479
1490
|
}
|
|
@@ -1505,6 +1516,32 @@ function hasCodexQuotaHeadroom(
|
|
|
1505
1516
|
return usage < threshold;
|
|
1506
1517
|
}
|
|
1507
1518
|
|
|
1519
|
+
/** Earliest future shared short/weekly reset; missing evidence and ties use usage order. */
|
|
1520
|
+
function pickResetFirstCodexAccount(
|
|
1521
|
+
config: OcxConfig,
|
|
1522
|
+
ids: readonly string[],
|
|
1523
|
+
now: number,
|
|
1524
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
1525
|
+
): string | null {
|
|
1526
|
+
const available = ids.filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions, now));
|
|
1527
|
+
if (available.length === 0) return pickLowestUsageAmong(config, ids, selectionOptions, now);
|
|
1528
|
+
let earliest = Number.POSITIVE_INFINITY;
|
|
1529
|
+
let candidates: string[] = [];
|
|
1530
|
+
for (const id of available) {
|
|
1531
|
+
const quota = getAccountQuota(id);
|
|
1532
|
+
const resets = [quota?.shortResetAt, quota?.weeklyResetAt]
|
|
1533
|
+
.filter((reset): reset is number => typeof reset === "number" && Number.isFinite(reset))
|
|
1534
|
+
.map(resetAtToMs)
|
|
1535
|
+
.filter(reset => reset > now);
|
|
1536
|
+
const next = Math.min(...resets);
|
|
1537
|
+
if (next < earliest) {
|
|
1538
|
+
earliest = next;
|
|
1539
|
+
candidates = [id];
|
|
1540
|
+
} else if (next === earliest) candidates.push(id);
|
|
1541
|
+
}
|
|
1542
|
+
return pickLowestUsageAmong(config, candidates, selectionOptions, now);
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1508
1545
|
/**
|
|
1509
1546
|
* Fill-first: keep selectable active under threshold; otherwise advance to the next
|
|
1510
1547
|
* eligible id in stable sorted order after the current active (wrapping).
|
|
@@ -1595,7 +1632,7 @@ function pickUnboundStrategyAccount(
|
|
|
1595
1632
|
commitSharedActive = commit,
|
|
1596
1633
|
commitAffinity = commit,
|
|
1597
1634
|
): string | null {
|
|
1598
|
-
const strategy =
|
|
1635
|
+
const strategy = accountPoolStrategyForScope(config, quotaScope);
|
|
1599
1636
|
if (strategy === "quota") return null;
|
|
1600
1637
|
const poolKey = codexPoolKeyForScope(quotaScope);
|
|
1601
1638
|
|
|
@@ -1619,8 +1656,10 @@ function pickUnboundStrategyAccount(
|
|
|
1619
1656
|
return picked;
|
|
1620
1657
|
}
|
|
1621
1658
|
|
|
1622
|
-
if (strategy === "fill-first") {
|
|
1623
|
-
picked =
|
|
1659
|
+
if (strategy === "fill-first" || strategy === "reset-first") {
|
|
1660
|
+
picked = strategy === "reset-first"
|
|
1661
|
+
? pickResetFirstCodexAccount(config, listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions), now, selectionOptions)
|
|
1662
|
+
: pickFillFirstCodexAccount(config, now, quotaScope, selectionOptions);
|
|
1624
1663
|
if (!picked) return null;
|
|
1625
1664
|
if (commitSharedActive) {
|
|
1626
1665
|
if (!isIndependentCodexQuotaScope(quotaScope)
|
|
@@ -1753,7 +1792,7 @@ export function pickAlternateCodexAccount(
|
|
|
1753
1792
|
quotaScope?: CodexQuotaScope,
|
|
1754
1793
|
selectionOptions?: CodexAccountUsabilityOptions,
|
|
1755
1794
|
): string | null {
|
|
1756
|
-
const strategy =
|
|
1795
|
+
const strategy = accountPoolStrategyForScope(config, quotaScope);
|
|
1757
1796
|
// The exclusion is passed into eligibility rather than post-filtered off its
|
|
1758
1797
|
// result: when the excluded account is the only healthy member of the top
|
|
1759
1798
|
// tier, the tier walk must be free to descend instead of selecting that tier
|
|
@@ -1766,6 +1805,9 @@ export function pickAlternateCodexAccount(
|
|
|
1766
1805
|
const eligible = getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions);
|
|
1767
1806
|
return pickNextFillFirstCodexAccount(config, excludeId, eligible, now, selectionOptions);
|
|
1768
1807
|
}
|
|
1808
|
+
if (strategy === "reset-first") {
|
|
1809
|
+
return pickResetFirstCodexAccount(config, getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions), now, selectionOptions);
|
|
1810
|
+
}
|
|
1769
1811
|
return pickLowestUsageCodexAccount(config, excludeId, now, quotaScope, selectionOptions);
|
|
1770
1812
|
}
|
|
1771
1813
|
|
|
@@ -1862,7 +1904,7 @@ function setActiveCodexAccount(config: OcxConfig, accountId: string): void {
|
|
|
1862
1904
|
|
|
1863
1905
|
/** Quota strategy persists; RR/fill-first keep a process-local cursor only. */
|
|
1864
1906
|
function promoteActiveCodexAccount(config: OcxConfig, accountId: string): void {
|
|
1865
|
-
if (
|
|
1907
|
+
if (normalizeCodexAccountPoolStrategy(config.accountPoolStrategy) === "quota") {
|
|
1866
1908
|
setActiveCodexAccount(config, accountId);
|
|
1867
1909
|
return;
|
|
1868
1910
|
}
|
|
@@ -2147,9 +2189,12 @@ function previewReusableAffinityAccount(
|
|
|
2147
2189
|
) {
|
|
2148
2190
|
return null;
|
|
2149
2191
|
}
|
|
2192
|
+
if (accountPoolStrategyForScope(config, quotaScope) === "reset-first") {
|
|
2193
|
+
return resetFirstAffinityReplacement(entry, config, now, quotaScope, selectionOptions) ?? entry.accountId;
|
|
2194
|
+
}
|
|
2150
2195
|
// Quota strategy only: non-quota strategies keep affinity for ongoing threads
|
|
2151
2196
|
// (new-session-only rotation — docs / affinity policy A).
|
|
2152
|
-
if (
|
|
2197
|
+
if (accountPoolStrategyForScope(config, quotaScope) === "quota") {
|
|
2153
2198
|
const threshold = config.autoSwitchThreshold ?? 80;
|
|
2154
2199
|
if (threshold > 0) {
|
|
2155
2200
|
const usage = computeCodexUsageScore(
|
|
@@ -2202,6 +2247,23 @@ function mayRebindAffinityForQuota(
|
|
|
2202
2247
|
|| (!isUnknownUsage(usage) && usage >= 100);
|
|
2203
2248
|
}
|
|
2204
2249
|
|
|
2250
|
+
/** Reset ordering may move a binding only under the existing cache-affinity release policy. */
|
|
2251
|
+
function resetFirstAffinityReplacement(
|
|
2252
|
+
entry: ThreadAffinityEntry,
|
|
2253
|
+
config: OcxConfig,
|
|
2254
|
+
now: number,
|
|
2255
|
+
quotaScope?: CodexQuotaScope,
|
|
2256
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
2257
|
+
): string | null {
|
|
2258
|
+
const threshold = config.autoSwitchThreshold ?? 80;
|
|
2259
|
+
if (threshold <= 0) return null;
|
|
2260
|
+
const usage = computeCodexUsageScore(getAccountQuota(entry.accountId), getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions), now);
|
|
2261
|
+
if (!mayRebindAffinityForQuota(config, entry.accountId, usage, threshold, selectionOptions)) return null;
|
|
2262
|
+
const candidates = getEligiblePoolAccounts(config, entry.accountId, now, quotaScope, selectionOptions, true)
|
|
2263
|
+
.filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions, now));
|
|
2264
|
+
return pickResetFirstCodexAccount(config, candidates, now, selectionOptions);
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2205
2267
|
/**
|
|
2206
2268
|
* Re-evaluate an affined account under the quota strategy. Returns a strictly
|
|
2207
2269
|
* cooler replacement, or null when the current binding should remain.
|
|
@@ -2213,7 +2275,13 @@ function reevaluateAffinityQuota(
|
|
|
2213
2275
|
quotaScope?: CodexQuotaScope,
|
|
2214
2276
|
selectionOptions?: CodexAccountUsabilityOptions,
|
|
2215
2277
|
): string | null {
|
|
2216
|
-
|
|
2278
|
+
const strategy = accountPoolStrategyForScope(config, quotaScope);
|
|
2279
|
+
if (strategy === "reset-first") {
|
|
2280
|
+
const replacement = resetFirstAffinityReplacement(entry, config, now, quotaScope, selectionOptions);
|
|
2281
|
+
if (replacement || now - entry.lastReevalAt >= CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS) entry.lastReevalAt = now;
|
|
2282
|
+
return replacement;
|
|
2283
|
+
}
|
|
2284
|
+
if (strategy !== "quota") return null;
|
|
2217
2285
|
const threshold = config.autoSwitchThreshold ?? 80;
|
|
2218
2286
|
const usage = threshold > 0
|
|
2219
2287
|
? computeCodexUsageScore(
|
|
@@ -2306,6 +2374,7 @@ export function previewCodexAccountForRequest(
|
|
|
2306
2374
|
else if (
|
|
2307
2375
|
hasConfiguredPoolAccount(config, active, selectionOptions)
|
|
2308
2376
|
&& !isCodexAccountPaused(config, active)
|
|
2377
|
+
&& !isCodexAccountPlanExcluded(config, active)
|
|
2309
2378
|
) return active;
|
|
2310
2379
|
else return null;
|
|
2311
2380
|
}
|
|
@@ -2440,7 +2509,7 @@ export function resolveCodexAccountForThreadDetailed(
|
|
|
2440
2509
|
const cooler = reevaluateAffinityQuota(entry, config, now, quotaScope, selectionOptions);
|
|
2441
2510
|
if (cooler) {
|
|
2442
2511
|
if (!isIndependentCodexQuotaScope(quotaScope)) {
|
|
2443
|
-
|
|
2512
|
+
promoteActiveCodexAccount(config, cooler);
|
|
2444
2513
|
}
|
|
2445
2514
|
bindThreadAffinity(threadId, cooler, now, quotaScope); // rebinds + resets clocks
|
|
2446
2515
|
return { status: "selected", accountId: cooler };
|
|
@@ -2548,6 +2617,7 @@ export function resolveCodexAccountForThreadDetailed(
|
|
|
2548
2617
|
} else if (
|
|
2549
2618
|
hasConfiguredPoolAccount(config, active, selectionOptions)
|
|
2550
2619
|
&& !isCodexAccountPaused(config, active)
|
|
2620
|
+
&& !isCodexAccountPlanExcluded(config, active)
|
|
2551
2621
|
) {
|
|
2552
2622
|
return { status: "selected", accountId: active };
|
|
2553
2623
|
} else {
|
|
@@ -2628,6 +2698,9 @@ export function recordCodexUpstreamOutcome(
|
|
|
2628
2698
|
if (writerGeneration < lastReconciledGeneration && !liveHealthAccountIds.has(accountId)) return;
|
|
2629
2699
|
const now = meta.now ?? Date.now();
|
|
2630
2700
|
const outcomeClass = classifyCodexUpstreamOutcome(outcome, meta.denial);
|
|
2701
|
+
// Reject retired quota evidence before stale-credential cleanup or any shared mutation.
|
|
2702
|
+
if (outcomeClass === "quota" && isRetiredCodexSparkModel(meta.modelId)
|
|
2703
|
+
&& computeQuotaCooldown(meta).source === "reset-derived") return;
|
|
2631
2704
|
const quotaScope = codexQuotaScopeForModel(meta.modelId);
|
|
2632
2705
|
/*
|
|
2633
2706
|
* Spend a stale credential failure BEFORE any branch reads health (#2892 gap 4 review).
|
|
@@ -2799,7 +2872,7 @@ export function recordCodexUpstreamOutcome(
|
|
|
2799
2872
|
const { until, source } = computeQuotaCooldown(meta);
|
|
2800
2873
|
// A reset timestamp is an advisory quota-window announcement. When the
|
|
2801
2874
|
// selected native model belongs to a confirmed independent group, preserve
|
|
2802
|
-
// it there so a different group (
|
|
2875
|
+
// it there so a different group (Reserve versus the shared native quota) can
|
|
2803
2876
|
// still reach upstream. Explicit Retry-After/default 429s remain account-wide.
|
|
2804
2877
|
if (source === "reset-derived" && quotaScope) {
|
|
2805
2878
|
const prior = scopedHealthFor(accountId, quotaScope);
|
|
@@ -2824,7 +2897,7 @@ export function recordCodexUpstreamOutcome(
|
|
|
2824
2897
|
});
|
|
2825
2898
|
// The shared native scope is the existing account-wide native behavior:
|
|
2826
2899
|
// threads must leave it and new requests should prefer an eligible account.
|
|
2827
|
-
//
|
|
2900
|
+
// Reserve remains isolated so a same-account Terra/Luna combo fallback can run.
|
|
2828
2901
|
if (quotaScope === "shared" && !meta.fixedAccount) {
|
|
2829
2902
|
clearThreadAccountMapForAccount(accountId);
|
|
2830
2903
|
notePoolRotationFailure(POOL_KEY_CODEX, accountId);
|
package/src/codex/sync.ts
CHANGED
|
@@ -19,7 +19,7 @@ export interface CodexSyncResult {
|
|
|
19
19
|
/**
|
|
20
20
|
* `skipped` is policy truth, never evidence that Codex was written.
|
|
21
21
|
* `catalog-only` means an explicit sync refreshed the catalog/cache while
|
|
22
|
-
*
|
|
22
|
+
* config/history injection was skipped (OFF, externally owned, or protected history).
|
|
23
23
|
*/
|
|
24
24
|
status: "applied" | "skipped" | "catalog-only" | "refused";
|
|
25
25
|
ok: boolean;
|
|
@@ -32,6 +32,12 @@ export interface CodexSyncResult {
|
|
|
32
32
|
catalogExists: boolean;
|
|
33
33
|
catalogWritten: boolean;
|
|
34
34
|
cacheSynced: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Whether the catalog owner committed a validated catalog or refused the
|
|
37
|
+
* refresh. Only a `catalog-only` result carries it; `ok` already answers the
|
|
38
|
+
* question for callers that do not care which half refused.
|
|
39
|
+
*/
|
|
40
|
+
refreshOutcome?: "committed" | "refused";
|
|
35
41
|
message: string;
|
|
36
42
|
warning?: string;
|
|
37
43
|
comboOmissions?: ComboCatalogOmission[];
|
|
@@ -45,8 +51,8 @@ export interface CodexSyncOptions {
|
|
|
45
51
|
* Explicit `ocx sync` is also the refresh path for side profiles that consume
|
|
46
52
|
* the OpenCodex catalog without injection. When set, the sync still refreshes
|
|
47
53
|
* the catalog and models cache even if the Codex integration toggle is OFF or
|
|
48
|
-
* an external `model_provider` owns config.toml
|
|
49
|
-
*
|
|
54
|
+
* an external `model_provider` owns config.toml, or paginated history refuses
|
|
55
|
+
* injection. Config/history injection is skipped in those cases.
|
|
50
56
|
*/
|
|
51
57
|
catalogEvenWhenNotInjected?: boolean;
|
|
52
58
|
}
|
|
@@ -203,6 +209,25 @@ export async function syncModelsToCodex(
|
|
|
203
209
|
// working catalog/cache into the partial result of an otherwise unnecessary refresh.
|
|
204
210
|
const preflight = await deps.injectCodexConfig(p, config, { validateOnly: true });
|
|
205
211
|
if (!preflight.success) {
|
|
212
|
+
// Explicit model refresh does not require legacy history relabeling. Keep the
|
|
213
|
+
// injector's refusal intact and publish only through the existing catalog owner.
|
|
214
|
+
// Unattended sync and other config/integrity refusals retain their hard failure.
|
|
215
|
+
if (catalogEvenWhenNotInjected
|
|
216
|
+
&& preflight.historyPreflightFailureReason === "history_paginated_requires_native_writer") {
|
|
217
|
+
applyProxyEnv(config);
|
|
218
|
+
const refreshed = await refreshCatalogForSync(config, deps, undefined, log);
|
|
219
|
+
const ok = refreshed.refreshOutcome === "committed" && refreshed.catalogExists;
|
|
220
|
+
const message = ok
|
|
221
|
+
? "Model catalog synchronized; Codex config and conversation history left unchanged because paginated history requires its native writer."
|
|
222
|
+
: "Model catalog refresh did not complete; Codex config and conversation history were left unchanged.";
|
|
223
|
+
reportCodexHomeTarget(log, deps.collectCodexHomeDiagnostic ?? collectOrcaCodexHomeDiagnostic);
|
|
224
|
+
return {
|
|
225
|
+
...refreshed,
|
|
226
|
+
status: "catalog-only",
|
|
227
|
+
ok,
|
|
228
|
+
message,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
206
231
|
log?.error(preflight.message);
|
|
207
232
|
reportCodexHomeTarget(log, deps.collectCodexHomeDiagnostic ?? collectOrcaCodexHomeDiagnostic);
|
|
208
233
|
return {
|
|
@@ -308,6 +333,7 @@ async function refreshCatalogForSync(
|
|
|
308
333
|
catalogWritten: boolean;
|
|
309
334
|
cacheSynced: boolean;
|
|
310
335
|
comboOmissions: ComboCatalogOmission[];
|
|
336
|
+
refreshOutcome?: "committed" | "refused";
|
|
311
337
|
warning?: string;
|
|
312
338
|
}> {
|
|
313
339
|
let added = 0;
|
|
@@ -316,9 +342,11 @@ async function refreshCatalogForSync(
|
|
|
316
342
|
let catalogWritten = false;
|
|
317
343
|
let cacheSynced = false;
|
|
318
344
|
let warning: string | undefined;
|
|
345
|
+
let refreshOutcome: "committed" | "refused" | undefined;
|
|
319
346
|
let comboOmissions: ComboCatalogOmission[] = [];
|
|
320
347
|
try {
|
|
321
348
|
const cat = await deps.refreshCodexModelCatalog(config, undefined, catalogOptions);
|
|
349
|
+
refreshOutcome = cat.refreshOutcome;
|
|
322
350
|
added = cat.added;
|
|
323
351
|
catalogExists = cat.catalogExists;
|
|
324
352
|
catalogWritten = cat.catalogWritten;
|
|
@@ -340,5 +368,6 @@ async function refreshCatalogForSync(
|
|
|
340
368
|
warning = `catalog sync skipped: ${e instanceof Error ? e.message : String(e)}`;
|
|
341
369
|
log?.error(warning);
|
|
342
370
|
}
|
|
343
|
-
return { added, catalogPath, catalogExists, catalogWritten, cacheSynced, comboOmissions,
|
|
371
|
+
return { added, catalogPath, catalogExists, catalogWritten, cacheSynced, comboOmissions,
|
|
372
|
+
...(refreshOutcome ? { refreshOutcome } : {}), ...(warning ? { warning } : {}) };
|
|
344
373
|
}
|
package/src/combos/request.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { OcxComboDefaultEffort, OcxComboTarget, OcxConfig } from "../types";
|
|
1
|
+
import type { OcxComboDefaultEffort, OcxComboReasoningEffortMode, OcxComboTarget, OcxConfig } from "../types";
|
|
2
2
|
import { resolveEffortAtOrBelow } from "../reasoning-effort";
|
|
3
3
|
import { resolveComboId } from "./types";
|
|
4
4
|
|
|
@@ -59,9 +59,14 @@ export function concreteComboRequestBody(
|
|
|
59
59
|
target: Pick<OcxComboTarget, "provider" | "model">,
|
|
60
60
|
defaultEffort: OcxComboDefaultEffort | null,
|
|
61
61
|
targetReasoningEfforts: readonly string[] | undefined,
|
|
62
|
+
reasoningEffortMode: OcxComboReasoningEffortMode = "strict",
|
|
62
63
|
): Record<string, unknown> {
|
|
63
64
|
const clone = structuredClone(body) as Record<string, unknown>;
|
|
64
65
|
clone.model = `${target.provider}/${target.model}`;
|
|
66
|
+
if (targetReasoningEfforts?.length === 0
|
|
67
|
+
|| (reasoningEffortMode === "adaptive" && targetReasoningEfforts === undefined)) {
|
|
68
|
+
stripUnsupportedReasoningControls(clone);
|
|
69
|
+
}
|
|
65
70
|
if (!defaultEffort) return clone;
|
|
66
71
|
const reasoning = clone.reasoning;
|
|
67
72
|
const needsDefault = reasoning === undefined || (
|
|
@@ -104,3 +109,16 @@ export function concreteComboRequestBody(
|
|
|
104
109
|
}
|
|
105
110
|
return clone;
|
|
106
111
|
}
|
|
112
|
+
|
|
113
|
+
function stripUnsupportedReasoningControls(body: Record<string, unknown>): void {
|
|
114
|
+
const reasoning = body.reasoning;
|
|
115
|
+
if (reasoning && typeof reasoning === "object" && !Array.isArray(reasoning)) {
|
|
116
|
+
const next = { ...(reasoning as Record<string, unknown>) };
|
|
117
|
+
delete next.effort;
|
|
118
|
+
if (Object.keys(next).length > 0) body.reasoning = next;
|
|
119
|
+
else delete body.reasoning;
|
|
120
|
+
}
|
|
121
|
+
delete body.reasoning_effort;
|
|
122
|
+
delete body.thinking_budget;
|
|
123
|
+
delete body.thinking;
|
|
124
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { MultiAgentMode } from "../codex/catalog/parsing";
|
|
2
|
+
import type { OcxConfig } from "../types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Bump to raise the sub-agent surface advisory again for installs that already answered
|
|
6
|
+
* the previous one. A version rather than a boolean, so that the day the upstream
|
|
7
|
+
* encrypted-task limitation is fixed, the notice saying so reaches the same operators
|
|
8
|
+
* without a second config key.
|
|
9
|
+
*/
|
|
10
|
+
export const MULTI_AGENT_SURFACE_ADVISORY_VERSION = 1;
|
|
11
|
+
|
|
12
|
+
/** The published explanation both advisory surfaces link to. */
|
|
13
|
+
export const SUBAGENT_SURFACE_GUIDE_URL = "https://opencodex.me/guides/subagent-v1-default/";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The surface an operator effectively runs. An absent key still means base: the key is
|
|
17
|
+
* deleted when base is selected, so its absence cannot be read as "never configured".
|
|
18
|
+
* What changed in this release is the written default, not this resolution.
|
|
19
|
+
*/
|
|
20
|
+
export function resolveMultiAgentMode(config: Pick<OcxConfig, "multiAgentMode">): MultiAgentMode {
|
|
21
|
+
return config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type AdvisoryConfig = Pick<OcxConfig, "multiAgentMode" | "multiAgentSurfaceAdvisoryVersion">;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* True when this install should be told once that v1 is now the default surface.
|
|
28
|
+
*
|
|
29
|
+
* A v1 install is silent because it has nothing to decide. A fresh install is silent
|
|
30
|
+
* because `getDefaultConfig()` writes the current version alongside the v1 default. What
|
|
31
|
+
* is left is the case this exists for: an install that predates the change and is running
|
|
32
|
+
* base or v2, where a ChatGPT-native parent cannot hand a task to a routed child.
|
|
33
|
+
*/
|
|
34
|
+
export function multiAgentSurfaceAdvisoryRequired(config: AdvisoryConfig): boolean {
|
|
35
|
+
if (resolveMultiAgentMode(config) === "v1") return false;
|
|
36
|
+
return (config.multiAgentSurfaceAdvisoryVersion ?? 0) < MULTI_AGENT_SURFACE_ADVISORY_VERSION;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type MultiAgentSurfaceAdvisory = {
|
|
40
|
+
/** Whether the dashboard should raise the notice. */
|
|
41
|
+
required: boolean;
|
|
42
|
+
/** The surface stored today. */
|
|
43
|
+
mode: MultiAgentMode;
|
|
44
|
+
/** What the notice recommends. */
|
|
45
|
+
recommended: "v1";
|
|
46
|
+
/** The advisory revision a client acknowledges. */
|
|
47
|
+
version: number;
|
|
48
|
+
/** Where the notice sends a reader for the explanation. */
|
|
49
|
+
docsUrl: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Response-only projection; none of this is a writable config field. */
|
|
53
|
+
export function multiAgentSurfaceAdvisory(config: AdvisoryConfig): MultiAgentSurfaceAdvisory {
|
|
54
|
+
return {
|
|
55
|
+
required: multiAgentSurfaceAdvisoryRequired(config),
|
|
56
|
+
mode: resolveMultiAgentMode(config),
|
|
57
|
+
recommended: "v1",
|
|
58
|
+
version: MULTI_AGENT_SURFACE_ADVISORY_VERSION,
|
|
59
|
+
docsUrl: SUBAGENT_SURFACE_GUIDE_URL,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -5,12 +5,14 @@ import {
|
|
|
5
5
|
MODEL_DISCOVERY_MAX_MODELS,
|
|
6
6
|
} from "../providers/model-discovery-limits";
|
|
7
7
|
import { isDeclaredReasoningEffort, modelRecordValue } from "../reasoning-effort";
|
|
8
|
+
import { encodeRoutedModelId } from "../providers/slug-codec";
|
|
8
9
|
import {
|
|
9
10
|
isWirePinnedModel,
|
|
10
11
|
MODEL_ADAPTER_OVERRIDE_ALLOWED,
|
|
11
12
|
REASONING_SUMMARY_DELIVERY_VALUES,
|
|
12
13
|
UPSTREAM_HTTP_VERSION_VALUES,
|
|
13
14
|
type OcxProviderConfig,
|
|
15
|
+
type ModelCapabilities,
|
|
14
16
|
} from "../types";
|
|
15
17
|
|
|
16
18
|
const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
@@ -233,6 +235,85 @@ export function modelDisplayNamesConfigError(
|
|
|
233
235
|
return null;
|
|
234
236
|
}
|
|
235
237
|
|
|
238
|
+
/** Characters that make a Codex catalog selector ambiguous or unrepresentable. */
|
|
239
|
+
export const AUTO_REVIEW_MODEL_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\s]/;
|
|
240
|
+
|
|
241
|
+
/** Validate one auto-review target (provider-wide value or map value). */
|
|
242
|
+
export function autoReviewModelTargetConfigError(
|
|
243
|
+
value: unknown,
|
|
244
|
+
field = "autoReviewModel",
|
|
245
|
+
allowClear = false,
|
|
246
|
+
): string | null {
|
|
247
|
+
if (value === undefined || (allowClear && (value === null || value === ""))) return null;
|
|
248
|
+
if (typeof value !== "string") return `${field} must be a string`;
|
|
249
|
+
const trimmed = value.trim();
|
|
250
|
+
if (!trimmed) return `${field} must be nonblank`;
|
|
251
|
+
if (trimmed.length > 1024 || AUTO_REVIEW_MODEL_CONTROL_CHARS.test(trimmed)) {
|
|
252
|
+
return `${field} must be a catalog selector without whitespace or control characters`;
|
|
253
|
+
}
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** True when the value is a valid Codex catalog auto-review selector. */
|
|
258
|
+
export function isValidAutoReviewModel(value: unknown): value is string {
|
|
259
|
+
return typeof value === "string" && autoReviewModelTargetConfigError(value) === null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Canonical model key used for map matching, duplicate detection, and route tombstones. */
|
|
263
|
+
export function canonicalAutoReviewModelKey(modelId: string): string {
|
|
264
|
+
return encodeRoutedModelId(modelId.trim());
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Validate a per-model auto-review override map. */
|
|
268
|
+
export function autoReviewModelOverridesConfigError(
|
|
269
|
+
value: unknown,
|
|
270
|
+
field = "autoReviewModelOverrides",
|
|
271
|
+
allowTombstones = false,
|
|
272
|
+
): string | null {
|
|
273
|
+
if (value === undefined) return null;
|
|
274
|
+
if (value === null && allowTombstones) return null;
|
|
275
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
|
|
276
|
+
const prototype = Object.getPrototypeOf(value);
|
|
277
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
278
|
+
return `${field} must be a plain object with own properties`;
|
|
279
|
+
}
|
|
280
|
+
const entries = Object.entries(value);
|
|
281
|
+
if (entries.length > MODEL_DISCOVERY_MAX_MODELS) {
|
|
282
|
+
return `${field} must contain at most ${MODEL_DISCOVERY_MAX_MODELS} entries`;
|
|
283
|
+
}
|
|
284
|
+
const canonicalKeys = new Set<string>();
|
|
285
|
+
for (const [modelId, target] of entries) {
|
|
286
|
+
if (!isValidModelDiscoveryModelId(modelId) || ["__proto__", "prototype", "constructor"].includes(modelId)) {
|
|
287
|
+
return `${field} keys must be valid non-reserved model ids`;
|
|
288
|
+
}
|
|
289
|
+
const safeModelId = JSON.stringify(redactSecretString(modelId));
|
|
290
|
+
const canonicalKey = canonicalAutoReviewModelKey(modelId);
|
|
291
|
+
if (canonicalKeys.has(canonicalKey)) {
|
|
292
|
+
return `${field} keys must be unique after trimming and slash normalization`;
|
|
293
|
+
}
|
|
294
|
+
canonicalKeys.add(canonicalKey);
|
|
295
|
+
if (allowTombstones && (target === null || target === "")) continue;
|
|
296
|
+
const targetError = autoReviewModelTargetConfigError(target, `${field}.${safeModelId}`);
|
|
297
|
+
if (targetError) return targetError;
|
|
298
|
+
}
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Normalize a persisted auto-review override map (trim, drop blanks, keep insertion order). */
|
|
303
|
+
export function normalizeAutoReviewModelOverrides(value: unknown): Record<string, string> | undefined {
|
|
304
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
305
|
+
const out = Object.create(null) as Record<string, string>;
|
|
306
|
+
for (const [modelId, target] of Object.entries(value)) {
|
|
307
|
+
const key = modelId.trim();
|
|
308
|
+
if (!key) continue;
|
|
309
|
+
if (target === null || typeof target !== "string") continue;
|
|
310
|
+
const trimmed = target.trim();
|
|
311
|
+
if (!trimmed) continue;
|
|
312
|
+
out[key] = trimmed;
|
|
313
|
+
}
|
|
314
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
315
|
+
}
|
|
316
|
+
|
|
236
317
|
/** Validate the management DTO boundary for the opt-in empty-tool-output annotation. */
|
|
237
318
|
export function providerEmptyToolOutputConfigError(name: string, provider: unknown): string | null {
|
|
238
319
|
const raw = provider as Record<string, unknown> | null | undefined;
|
|
@@ -295,3 +376,98 @@ export function modelAdapterRecordConfigError(
|
|
|
295
376
|
}
|
|
296
377
|
return null;
|
|
297
378
|
}
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
function capabilityRecord(value: unknown): value is Record<string, unknown> {
|
|
382
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
383
|
+
&& [Object.prototype, null].includes(Object.getPrototypeOf(value));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Strict writes; only PATCH may carry deletion tombstones. Model IDs are exact. */
|
|
387
|
+
export function modelCapabilitiesConfigError(value: unknown, allowTombstones = false): string | null {
|
|
388
|
+
if (value === undefined || (allowTombstones && value === null)) return null;
|
|
389
|
+
if (!capabilityRecord(value)) return "modelCapabilities must be a plain object";
|
|
390
|
+
if (Object.keys(value).length > MODEL_DISCOVERY_MAX_MODELS) return "modelCapabilities has too many models";
|
|
391
|
+
for (const [id, row] of Object.entries(value)) {
|
|
392
|
+
if (!isValidModelDiscoveryModelId(id) || ["__proto__", "prototype", "constructor"].includes(id)) {
|
|
393
|
+
return "modelCapabilities keys must be exact non-reserved model ids without surrounding whitespace";
|
|
394
|
+
}
|
|
395
|
+
if (allowTombstones && row === null) continue;
|
|
396
|
+
if (!capabilityRecord(row)) return "modelCapabilities rows must be plain objects";
|
|
397
|
+
for (const [axis, declaration] of Object.entries(row)) {
|
|
398
|
+
if (!["inputModalities", "contextTier", "video"].includes(axis)) return "modelCapabilities contains an unknown axis";
|
|
399
|
+
if (allowTombstones && declaration === null) continue;
|
|
400
|
+
if (axis === "inputModalities") {
|
|
401
|
+
if (!Array.isArray(declaration) || declaration.length === 0
|
|
402
|
+
|| declaration.some(item => typeof item !== "string" || !["text", "image", "audio", "video"].includes(item))) {
|
|
403
|
+
return "modelCapabilities inputModalities must be a nonempty array of text, image, audio or video";
|
|
404
|
+
}
|
|
405
|
+
} else if (axis === "contextTier") {
|
|
406
|
+
if (declaration !== "default" && declaration !== "long_context") return "modelCapabilities contextTier must be default or long_context";
|
|
407
|
+
} else {
|
|
408
|
+
if (!capabilityRecord(declaration) || Object.keys(declaration).some(key => key !== "processing")) {
|
|
409
|
+
return "modelCapabilities video must be a plain object containing only processing";
|
|
410
|
+
}
|
|
411
|
+
if (Object.hasOwn(declaration, "processing") && declaration.processing !== "static" && declaration.processing !== "agentic"
|
|
412
|
+
&& !(allowTombstones && declaration.processing === null)) return "modelCapabilities video processing must be static or agentic";
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Merge a validated patch without sharing nested objects with the live provider. */
|
|
420
|
+
export function mergeModelCapabilities(
|
|
421
|
+
current: Record<string, ModelCapabilities> | undefined,
|
|
422
|
+
patch: unknown,
|
|
423
|
+
): Record<string, ModelCapabilities> | undefined {
|
|
424
|
+
if (patch === null) return undefined;
|
|
425
|
+
const next = Object.fromEntries(Object.entries(current ?? {}).map(([id, row]) => [id, structuredClone(row)]));
|
|
426
|
+
if (patch !== undefined) for (const [id, raw] of Object.entries(patch as Record<string, Record<string, unknown> | null>)) {
|
|
427
|
+
if (raw === null) { delete next[id]; continue; }
|
|
428
|
+
const row: ModelCapabilities = Object.hasOwn(next, id) ? next[id]! : {};
|
|
429
|
+
for (const [axis, value] of Object.entries(raw)) {
|
|
430
|
+
if (axis === "inputModalities") {
|
|
431
|
+
if (value === null) delete row.inputModalities;
|
|
432
|
+
else row.inputModalities = [...(value as NonNullable<ModelCapabilities["inputModalities"]>)];
|
|
433
|
+
} else if (axis === "contextTier") {
|
|
434
|
+
if (value === null) delete row.contextTier;
|
|
435
|
+
else row.contextTier = value as ModelCapabilities["contextTier"];
|
|
436
|
+
} else if (axis === "video") {
|
|
437
|
+
if (value === null) delete row.video;
|
|
438
|
+
else {
|
|
439
|
+
const video = { ...(row.video ?? {}) };
|
|
440
|
+
const change = value as { processing?: "static" | "agentic" | null };
|
|
441
|
+
if (Object.hasOwn(change, "processing")) {
|
|
442
|
+
if (change.processing === null) delete video.processing;
|
|
443
|
+
else video.processing = change.processing;
|
|
444
|
+
}
|
|
445
|
+
if (Object.keys(video).length) row.video = video;
|
|
446
|
+
else delete row.video;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (Object.keys(row).length) next[id] = row;
|
|
451
|
+
else delete next[id];
|
|
452
|
+
}
|
|
453
|
+
return Object.keys(next).length ? next : undefined;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Load-only repair preserves independent valid axes; malformed explicit modalities restrict to text. */
|
|
457
|
+
export function sanitizeModelCapabilitiesForLoad(value: unknown): Record<string, ModelCapabilities> | undefined {
|
|
458
|
+
if (!capabilityRecord(value)) return undefined;
|
|
459
|
+
const rows: Record<string, ModelCapabilities> = Object.create(null);
|
|
460
|
+
for (const [id, raw] of Object.entries(value).slice(0, MODEL_DISCOVERY_MAX_MODELS)) {
|
|
461
|
+
if (!capabilityRecord(raw) || !isValidModelDiscoveryModelId(id) || ["__proto__", "prototype", "constructor"].includes(id)) continue;
|
|
462
|
+
const row: Record<string, unknown> = {};
|
|
463
|
+
for (const axis of ["inputModalities", "contextTier", "video"] as const) {
|
|
464
|
+
if (!Object.hasOwn(raw, axis)) continue;
|
|
465
|
+
const declaration = raw[axis];
|
|
466
|
+
if (modelCapabilitiesConfigError({ [id]: { [axis]: declaration } }) === null) row[axis] = declaration;
|
|
467
|
+
else if (axis === "inputModalities") row.inputModalities = ["text"];
|
|
468
|
+
}
|
|
469
|
+
const normalized = mergeModelCapabilities(undefined, { [id]: row });
|
|
470
|
+
if (normalized?.[id]) rows[id] = normalized[id];
|
|
471
|
+
}
|
|
472
|
+
return Object.keys(rows).length ? rows : undefined;
|
|
473
|
+
}
|