@bitkyc08/opencodex 2.75.0 → 2.76.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/README.md +6 -1
- package/bin/ocx.mjs +12 -1
- package/gui/dist/assets/App-CddorHdA.js +51 -0
- package/gui/dist/assets/App-DMudtISW.css +1 -0
- package/gui/dist/assets/{Tray-CGAD2AGk.js → Tray-DjNwwrWS.js} +1 -1
- package/gui/dist/assets/index-Btze1I3T.css +1 -0
- package/gui/dist/assets/index-CvnY7E6i.js +86 -0
- package/gui/dist/assets/quota-summary-B_AWpHfP.js +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/opengateway.svg +12 -0
- package/package.json +1 -1
- package/src/adapters/anthropic/account-metadata.ts +17 -0
- package/src/adapters/anthropic/client-identity.ts +69 -0
- package/src/adapters/anthropic/passthrough.ts +23 -4
- package/src/adapters/anthropic.ts +18 -1
- package/src/adapters/command-code-project-context.ts +7 -11
- package/src/adapters/devin/cloud-direct/tool-schema.ts +12 -48
- package/src/adapters/kiro-retry.ts +23 -8
- package/src/adapters/openai-chat/messages.ts +1 -1
- package/src/adapters/registry.ts +8 -1
- package/src/adapters/zed.ts +377 -0
- package/src/bridge/sse.ts +4 -2
- package/src/chatgpt/app-server-shim/app-server-rewrite.ts +45 -0
- package/src/chatgpt/app-server-shim/bundle-trust.ts +77 -0
- package/src/chatgpt/app-server-shim/filter.ts +157 -0
- package/src/chatgpt/app-server-shim/gate-rewrite.ts +99 -0
- package/src/chatgpt/app-server-shim/launcher.ts +91 -0
- package/src/claude/desktop-3p-startup.ts +62 -0
- package/src/claude/desktop-3p.ts +5 -0
- package/src/claude/first-party-settings.ts +14 -0
- package/src/claude/intercept/cli-catalog.ts +150 -0
- package/src/claude/intercept/cli-picker.ts +82 -0
- package/src/claude/intercept/listener.ts +21 -6
- package/src/claude/intercept/picker-bootstrap.ts +23 -5
- package/src/claude/intercept/picker-models.ts +57 -13
- package/src/claude/intercept/runtime.ts +30 -2
- package/src/claude/intercept/settings.ts +3 -0
- package/src/cli/agent.ts +172 -0
- package/src/cli/alias.ts +2 -1
- package/src/cli/capabilities-command.ts +3 -2
- package/src/cli/capabilities.ts +87 -0
- package/src/cli/chatgpt-command.ts +140 -0
- package/src/cli/combo.ts +144 -19
- package/src/cli/config-command.ts +3 -1
- package/src/cli/dispatch.ts +10 -1
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +5 -24
- package/src/cli/init.ts +16 -2
- package/src/cli/integrations.ts +13 -0
- package/src/cli/internal-command.ts +12 -5
- package/src/cli/observe.ts +6 -1
- package/src/cli/registry.ts +6 -0
- package/src/cli/route-policy.ts +3 -1
- package/src/cli/runtime-api.ts +5 -2
- package/src/cli/status-oauth.ts +5 -0
- package/src/cli/status.ts +8 -1
- package/src/cli/stop-approval.ts +55 -16
- package/src/cli/stop-report.ts +5 -0
- package/src/clients/lazycodex.ts +48 -0
- package/src/clients/omo-role-models.ts +111 -0
- package/src/codex/account-credit-use.ts +99 -0
- package/src/codex/account-lifecycle.ts +2 -0
- package/src/codex/agent-role-models.ts +171 -0
- package/src/codex/auth-api/account-list.ts +10 -3
- package/src/codex/auth-api/routes.ts +34 -0
- package/src/codex/auth-context.ts +52 -11
- package/src/codex/catalog/discovered-natives.ts +11 -1
- package/src/codex/catalog/gather-capture.ts +6 -1
- package/src/codex/catalog/model-hints.ts +9 -1
- package/src/codex/catalog/provider-models.ts +60 -0
- package/src/codex/catalog/routed-gather.ts +30 -5
- package/src/codex/convergence-types.ts +2 -1
- package/src/codex/main-account-external-usage.ts +62 -0
- package/src/codex/main-account-hard-lock.ts +24 -13
- package/src/codex/native-profile-processes.ts +53 -16
- package/src/codex/prompt-layers/toml-edit.ts +1 -1
- package/src/codex/quota-types.ts +3 -7
- package/src/codex/quota.ts +19 -4
- package/src/codex/role-auto-assign.ts +159 -0
- package/src/codex/role-sizing-limits.ts +2 -0
- package/src/codex/role-sizing.ts +216 -0
- package/src/codex/routing/selection.ts +36 -0
- package/src/codex/routing/thread-affinity.ts +2 -0
- package/src/codex/routing.ts +4 -4
- package/src/codex/subagent-model-fallback.ts +82 -26
- package/src/codex/subagent-selectable-models.ts +32 -0
- package/src/combos/index.ts +18 -0
- package/src/combos/jev-decision-contract.ts +20 -0
- package/src/combos/jev-dispatch.ts +18 -0
- package/src/combos/jev-model-backend.ts +127 -0
- package/src/combos/jev.ts +249 -33
- package/src/combos/types.ts +125 -2
- package/src/config/diagnostics.ts +18 -0
- package/src/config/macos-system-proxy.ts +72 -15
- package/src/config/owner-registry.ts +13 -4
- package/src/config/process-state.ts +17 -8
- package/src/config/proxy-env.ts +35 -5
- package/src/config/schema/config-schema.ts +30 -1
- package/src/config/schema/leaf-validators.ts +4 -0
- package/src/config/serving-runtimes.ts +95 -1
- package/src/generated/compatibility-version.json +336 -156
- package/src/lib/errors.ts +7 -7
- package/src/lib/jsonc.ts +13 -0
- package/src/lib/local-chat-completion.ts +144 -0
- package/src/lib/provider-outbound.ts +87 -1
- package/src/lib/provider-runtime-fetch.ts +23 -0
- package/src/lib/provider-tls-profile.ts +221 -0
- package/src/lib/request-execution-budget.ts +16 -6
- package/src/lib/spend-reservation-ledger.ts +54 -20
- package/src/lib/sse-decoder.ts +20 -17
- package/src/lib/synced-state-location.ts +96 -0
- package/src/lib/winsw.ts +14 -5
- package/src/oauth/anthropic-account-refusal.ts +53 -4
- package/src/oauth/anthropic-model-quota.ts +83 -0
- package/src/oauth/anthropic-rate-limit-policy.ts +37 -0
- package/src/oauth/anthropic-routing.ts +112 -58
- package/src/oauth/health.ts +39 -3
- package/src/oauth/index.ts +14 -0
- package/src/oauth/kiro-account-load.ts +10 -0
- package/src/oauth/zed.ts +134 -0
- package/src/providers/model-discovery.ts +19 -8
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/provider-id-rewrite.ts +9 -2
- package/src/providers/quota/account-cache.ts +29 -7
- package/src/providers/quota/anthropic-cooldown-recovery.ts +18 -0
- package/src/providers/quota/anthropic-family-headers.ts +25 -0
- package/src/providers/quota/vendor-probes-oauth.ts +5 -1
- package/src/providers/quota-types.ts +4 -0
- package/src/providers/registry/entries-core.ts +18 -0
- package/src/providers/registry/entries-extended.ts +55 -0
- package/src/providers/registry/types.ts +3 -0
- package/src/providers/xai-fast-model.ts +12 -4
- package/src/providers/zed.ts +598 -0
- package/src/responses/state/snapshot-select.ts +22 -9
- package/src/responses/state.ts +2 -3
- package/src/router.ts +9 -0
- package/src/server/anthropic-rate-limit-headers.ts +7 -0
- package/src/server/auth-cors.ts +7 -0
- package/src/server/chat-completions.ts +9 -4
- package/src/server/claude-messages.ts +59 -21
- package/src/server/index/claude-intercept-lifecycle.ts +71 -25
- package/src/server/index/optional-listeners.ts +4 -0
- package/src/server/index/spend-ledger-lifecycle.ts +4 -0
- package/src/server/management/agent-settings-routes.ts +74 -37
- package/src/server/management/claude-desktop-picker-routes.ts +7 -0
- package/src/server/management/claude-intercept-routes.ts +35 -0
- package/src/server/management/codex-agent-role-routes.ts +122 -0
- package/src/server/management/codex-role-auto-assign.ts +208 -0
- package/src/server/management/combo-routes.ts +62 -5
- package/src/server/management/config-routes.ts +35 -3
- package/src/server/management/context.ts +4 -1
- package/src/server/management/decision-discovery.ts +74 -0
- package/src/server/management/decision-model-validation.ts +80 -0
- package/src/server/management/decision-routes.ts +160 -0
- package/src/server/management/native-integration-routes.ts +16 -0
- package/src/server/management/provider-routes.ts +30 -34
- package/src/server/management/route-registry.ts +12 -0
- package/src/server/management/sibling-guard.ts +1 -0
- package/src/server/management/zed-provider-probe.ts +38 -0
- package/src/server/management-api.ts +12 -1
- package/src/server/messages-native-oauth.ts +7 -2
- package/src/server/messages-native.ts +7 -3
- package/src/server/relay.ts +3 -1
- package/src/server/request-log.ts +2 -0
- package/src/server/responses/adapter-continuation.ts +3 -3
- package/src/server/responses/adapter-dispatch.ts +18 -3
- package/src/server/responses/compact.ts +14 -1
- package/src/server/responses/core-auth.ts +2 -0
- package/src/server/responses/core-combo.ts +67 -11
- package/src/server/responses/core-normalize.ts +21 -1
- package/src/server/responses/core-options.ts +11 -1
- package/src/server/responses/core.ts +5 -3
- package/src/server/responses/fetch-helpers.ts +6 -3
- package/src/server/responses/jev-model-invoke.ts +190 -0
- package/src/server/responses/passthrough-delivery.ts +16 -0
- package/src/server/responses/policy-fallback.ts +55 -3
- package/src/server/responses/policy-request-scope.ts +80 -0
- package/src/server/responses/request-prepare.ts +70 -18
- package/src/server/responses/request-transport.ts +54 -23
- package/src/server/responses/run-turn-execution.ts +1 -0
- package/src/server/responses/sidecar-execution.ts +9 -3
- package/src/server/responses-hosted-image-display.ts +225 -0
- package/src/service/managing-cli.ts +34 -4
- package/src/service/systemd-env.ts +75 -0
- package/src/service/windows-guarded-manager.ts +60 -19
- package/src/service/windows-scheduler.ts +22 -3
- package/src/service/windows-taskxml.ts +32 -10
- package/src/service-manager-probe.ts +72 -19
- package/src/types/config.ts +31 -1
- package/src/types/provider.ts +2 -0
- package/src/types/request.ts +2 -0
- package/src/update/index.ts +39 -6
- package/src/update/job.ts +63 -63
- package/src/update/npm-cache-preflight.d.mts +33 -1
- package/src/update/npm-cache-preflight.mjs +131 -18
- package/src/update/ownership-transaction.ts +19 -1
- package/src/update/restart-ownership.ts +80 -6
- package/src/update/transactional-install.d.mts +2 -0
- package/src/update/transactional-install.mjs +6 -1
- package/src/usage/jev-stats.ts +28 -1
- package/src/usage/log.ts +1 -1
- package/src/vision/anthropic-describe.ts +5 -3
- package/src/vision/index.ts +1 -0
- package/src/vision/plan.ts +2 -1
- package/src/vision/routed-describe.ts +29 -121
- package/src/web-search/alpha-search.ts +3 -3
- package/src/web-search/anthropic-executor.ts +6 -4
- package/src/web-search/index.ts +1 -1
- package/src/web-search/loop.ts +3 -3
- package/src/web-search/passthrough-bridge.ts +2 -1
- package/src/web-search/run-turn-loop.ts +1 -1
- package/src/web-search/sidecar-providers.ts +2 -1
- package/gui/dist/assets/App-ia4U0ufY.css +0 -1
- package/gui/dist/assets/App-xm0zGNOe.js +0 -51
- package/gui/dist/assets/index-DBT3Q31Y.css +0 -1
- package/gui/dist/assets/index-D_RGAT6Q.js +0 -86
- package/gui/dist/assets/quota-summary-CUFq48t-.js +0 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getMainAccountExternalUsageWarning, type MainAccountExternalUsageWarning } from "../main-account-external-usage";
|
|
1
2
|
import { codexCreditsDtoField, hasCodexCreditsObservation, pruneCodexCredits } from "../credits";
|
|
2
3
|
import type { CodexCredits } from "../credits";
|
|
3
4
|
import { getMainChatgptAccountId } from "../auth-collision";
|
|
@@ -8,6 +9,7 @@ import type { StoredAccountQuota } from "../quota";
|
|
|
8
9
|
import { ConfigMutationLockError, mutatePersistedConfig } from "../../config";
|
|
9
10
|
import { reconcileMainCodexAccountRuntimeState } from "../account-lifecycle";
|
|
10
11
|
import { isCodexAccountPaused, setCodexAccountPaused } from "../account-pause";
|
|
12
|
+
import { codexAccountUsesCreditsAfterLimit } from "../account-credit-use";
|
|
11
13
|
import { getCodexAccountPriority } from "../account-priority";
|
|
12
14
|
import { getCodexAccountAutoSwitchThresholdOverride } from "../account-auto-switch";
|
|
13
15
|
import { clearThreadAccountMapForAccount, isCodexAccountPlanExcluded, reconcileCodexActiveAfterExclusion } from "../routing";
|
|
@@ -15,7 +17,7 @@ import { codexPlanValue, isThirtyDayOnlyCodexPlan } from "../plan";
|
|
|
15
17
|
import { isAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state";
|
|
16
18
|
import { getValidMainAccountToken, MainAccountTokenRefreshError, MAIN_CODEX_ACCOUNT_ID } from "../main-account";
|
|
17
19
|
import { captureConfigGeneration } from "../../lib/state-store-sweeper";
|
|
18
|
-
import { captureMainAccountIdentityGeneration, getMainAccountCredentialPresence, getMainAccountInfoCache, isMainAccountIdentityGenerationLive } from "../main-account-cache";
|
|
20
|
+
import { captureMainAccountIdentityGeneration, getMainAccountCredentialPresence, getMainAccountInfoCache, getObservedMainQuotaIdentityKey, isMainAccountIdentityGenerationLive } from "../main-account-cache";
|
|
19
21
|
import type { CodexQuotaRefreshOutcome } from "../quota-refresh-outcome";
|
|
20
22
|
import { getMainAccountHardLockStatus } from "../main-account-hard-lock";
|
|
21
23
|
import type { MainAccountHardLockStatus } from "../main-account-hard-lock";
|
|
@@ -139,6 +141,7 @@ export function poolAccountDto(
|
|
|
139
141
|
paused,
|
|
140
142
|
priority,
|
|
141
143
|
autoSwitchThresholdOverride: getCodexAccountAutoSwitchThresholdOverride(config, account.id),
|
|
144
|
+
creditsAfterLimit: codexAccountUsesCreditsAfterLimit(config, account.id),
|
|
142
145
|
quota: quota ? { ...quota } : null,
|
|
143
146
|
...codexCreditsDtoField(config, account.id, poolQuotaHistoryIdentity(account.id) ?? null),
|
|
144
147
|
needsReauth: needsReauth || health.status === "reauth_required",
|
|
@@ -165,6 +168,8 @@ export interface CodexAuthAccountDto {
|
|
|
165
168
|
priority: number;
|
|
166
169
|
/** Null inherits the global usage-switch threshold; 0 disables it for this account. */
|
|
167
170
|
autoSwitchThresholdOverride: number | null;
|
|
171
|
+
/** False keeps the account out of selection while one of its usage windows is full. */
|
|
172
|
+
creditsAfterLimit?: boolean;
|
|
168
173
|
quota: (StoredAccountQuota | (Omit<StoredAccountQuota, "updatedAt"> & { updatedAt: number })) | null;
|
|
169
174
|
credits?: CodexCredits;
|
|
170
175
|
needsReauth?: boolean;
|
|
@@ -183,7 +188,7 @@ export interface CodexAuthAccountDto {
|
|
|
183
188
|
healthAction?: string;
|
|
184
189
|
quotaProbeSkipped?: true;
|
|
185
190
|
quotaRefresh?: CodexQuotaRefreshOutcome;
|
|
186
|
-
mainAccountHardLock?: MainAccountHardLockStatus;
|
|
191
|
+
mainAccountHardLock?: MainAccountHardLockStatus & { externalUsage?: MainAccountExternalUsageWarning };
|
|
187
192
|
}
|
|
188
193
|
|
|
189
194
|
export interface FreshPoolPlanUpdate {
|
|
@@ -371,9 +376,11 @@ export async function listCodexAuthAccountsSnapshot(
|
|
|
371
376
|
logLabel: "main",
|
|
372
377
|
isMain: true,
|
|
373
378
|
paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
|
|
374
|
-
mainAccountHardLock: getMainAccountHardLockStatus(runtimeConfig),
|
|
379
|
+
mainAccountHardLock: { ...getMainAccountHardLockStatus(runtimeConfig),
|
|
380
|
+
externalUsage: getMainAccountExternalUsageWarning(getObservedMainQuotaIdentityKey()) },
|
|
375
381
|
priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
|
|
376
382
|
autoSwitchThresholdOverride: getCodexAccountAutoSwitchThresholdOverride(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
|
|
383
|
+
creditsAfterLimit: codexAccountUsesCreditsAfterLimit(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
|
|
377
384
|
hasCredential: hasMainCredential,
|
|
378
385
|
needsReauth: mainNeedsReauth,
|
|
379
386
|
...(mainReauthReason !== undefined ? { reauthReason: mainReauthReason } : {}),
|
|
@@ -6,6 +6,7 @@ import { readUsageSnapshotForManagement } from "../../usage/log";
|
|
|
6
6
|
import { getAccountQuotaHistory, listAccountQuotas } from "../quota";
|
|
7
7
|
import { deleteCodexAccount } from "../account-lifecycle";
|
|
8
8
|
import { isCodexAccountPaused, setCodexAccountPaused } from "../account-pause";
|
|
9
|
+
import { setAllCodexAccountsCreditsAfterLimit, setCodexAccountCreditsAfterLimit } from "../account-credit-use";
|
|
9
10
|
import { clearCodexAccountPin, isCodexAccountPriorityKey, pinnedCodexAccountId, setCodexAccountPin, setCodexAccountPriority } from "../account-priority";
|
|
10
11
|
import { codexAccountPinDrainReason, codexQuotaScopeForModel, clearCodexAccountCooldown, clearThreadAccountMapForAccount, getEffectiveActiveCodexAccountId, isEffectiveCodexAccountPinned, resetCodexRoutingForManualSelection } from "../routing";
|
|
11
12
|
import { DEFAULT_ACCOUNT_PRIORITY, MAX_ACCOUNT_PRIORITY, MIN_ACCOUNT_PRIORITY, normalizeAccountPoolStickyLimit, normalizeCodexAccountPoolStrategy, parseAccountPoolStickyLimit, parseCodexAccountPoolStrategy, parseAccountPriority } from "../pool-rotation";
|
|
@@ -114,6 +115,39 @@ export async function handleCodexAuthAPI(
|
|
|
114
115
|
});
|
|
115
116
|
}
|
|
116
117
|
|
|
118
|
+
// Pool accounts and `__main__`, like pause. Spending is opt-in: an account not on the list is
|
|
119
|
+
// skipped by the next selection while a usage window is full, and a bound thread is released
|
|
120
|
+
// through the usual block reason, so nothing is cleared here. `{ all }` is the dashboard's
|
|
121
|
+
// global switch: on lists every current account, off clears the list.
|
|
122
|
+
if (url.pathname === "/api/codex-auth/accounts/credits" && req.method === "PUT") {
|
|
123
|
+
const parsed: unknown = await req.json().catch(() => null);
|
|
124
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
125
|
+
return jsonResponse({ error: "body must be an object" }, 400);
|
|
126
|
+
}
|
|
127
|
+
const body = parsed as { id?: unknown; creditsAfterLimit?: unknown; all?: unknown };
|
|
128
|
+
const runtimeConfig = getRuntimeConfig(config);
|
|
129
|
+
if (body.all !== undefined) {
|
|
130
|
+
if (typeof body.all !== "boolean") return jsonResponse({ error: "all must be a boolean" }, 400);
|
|
131
|
+
const ids = [MAIN_CODEX_ACCOUNT_ID, ...(runtimeConfig.codexAccounts ?? [])
|
|
132
|
+
.filter(isSelectableCodexPoolAccount)
|
|
133
|
+
.map(account => account.id)];
|
|
134
|
+
setAllCodexAccountsCreditsAfterLimit(runtimeConfig, ids, body.all);
|
|
135
|
+
saveRuntimeConfig(config, runtimeConfig);
|
|
136
|
+
return jsonResponse({ ok: true, all: body.all, ids: runtimeConfig.creditCodexAccountIds ?? [] });
|
|
137
|
+
}
|
|
138
|
+
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
139
|
+
if (id !== MAIN_CODEX_ACCOUNT_ID && !isValidCodexAccountId(id)) return jsonResponse({ error: "Invalid account id format" }, 400);
|
|
140
|
+
if (typeof body.creditsAfterLimit !== "boolean") return jsonResponse({ error: "creditsAfterLimit must be a boolean" }, 400);
|
|
141
|
+
|
|
142
|
+
const exists = id === MAIN_CODEX_ACCOUNT_ID
|
|
143
|
+
|| (runtimeConfig.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === id);
|
|
144
|
+
if (!exists) return jsonResponse({ error: "Account not found" }, 404);
|
|
145
|
+
|
|
146
|
+
setCodexAccountCreditsAfterLimit(runtimeConfig, id, body.creditsAfterLimit);
|
|
147
|
+
saveRuntimeConfig(config, runtimeConfig);
|
|
148
|
+
return jsonResponse({ ok: true, id, creditsAfterLimit: body.creditsAfterLimit });
|
|
149
|
+
}
|
|
150
|
+
|
|
117
151
|
// Deliberately a route of its own rather than a field on the alias PATCH: aliases
|
|
118
152
|
// are display-only and reject __main__, while selection order is routing metadata
|
|
119
153
|
// that the Desktop account must be able to carry. Re-ordering never kicks a live
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { noteMainAccountActivity } from "./main-account-external-usage";
|
|
1
2
|
import { codexAccountPriorityFailbackEnabled } from "./account-priority";
|
|
2
3
|
import type { PoolQuotaWriter } from "./quota-types";
|
|
3
4
|
import { createHash, timingSafeEqual } from "node:crypto";
|
|
@@ -63,13 +64,14 @@ import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, NATIVE_RESERVE_MODEL } from "./cata
|
|
|
63
64
|
import type { CodexCooldownSource, CodexQuotaScope } from "./routing";
|
|
64
65
|
import { maskAccountId } from "../lib/privacy";
|
|
65
66
|
import { formatErrorResponse } from "../bridge";
|
|
66
|
-
import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota, parseUsageQuota, parseMainPolicyUsageQuota, setAccountQuotaFromParsed } from "./quota";
|
|
67
|
+
import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota, getMainPolicyQuota, parseUsageQuota, parseMainPolicyUsageQuota, setAccountQuotaFromParsed } from "./quota";
|
|
68
|
+
import { codexAccountUsesCreditsAfterLimit, codexUsageLimitResetAt } from "./account-credit-use";
|
|
67
69
|
import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types";
|
|
68
70
|
import { FORWARD_HEADERS } from "../adapters/openai-responses";
|
|
69
71
|
import { captureConfigGeneration } from "../lib/state-store-sweeper";
|
|
70
72
|
import { extractAccountId, extractEmail } from "../oauth/chatgpt";
|
|
71
73
|
import {
|
|
72
|
-
|
|
74
|
+
resolveMainAccountHardLockThresholds,
|
|
73
75
|
getMainAccountHardLockStatus,
|
|
74
76
|
isMainAccountHardLockEnabled,
|
|
75
77
|
isMainAccountHardLocked,
|
|
@@ -117,7 +119,7 @@ function requestOwnedMainPinHasQuotaHeadroom(config: OcxConfig): boolean {
|
|
|
117
119
|
*
|
|
118
120
|
* Read-free by construction, which is what makes it usable on the fenced side. Every input is
|
|
119
121
|
* config, policy, or in-memory runtime state: the pin fields, the paused list, the cached quota
|
|
120
|
-
* score, and `callerMatchesObservedMain`, which compares HMAC digests against the observed
|
|
122
|
+
* score, the main policy quota the hard lock and the credits switch read, and `callerMatchesObservedMain`, which compares HMAC digests against the observed
|
|
121
123
|
* credential record in `main-account-cache.ts`. Nothing here opens a file.
|
|
122
124
|
*
|
|
123
125
|
* `candidate` is the pin before the hard-lock question, because the caller still owes the
|
|
@@ -141,6 +143,7 @@ export function requestOwnedMainPinState(
|
|
|
141
143
|
candidate,
|
|
142
144
|
preserve: candidate && !(callerMatchesObservedMain(headers)
|
|
143
145
|
&& (isMainAccountHardLocked(policy)
|
|
146
|
+
|| mainCreditsHoldResetAt(policy) !== undefined
|
|
144
147
|
|| getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope)?.cooldownUntil)),
|
|
145
148
|
};
|
|
146
149
|
}
|
|
@@ -510,15 +513,28 @@ export class CodexAccountCooldownError extends Error {
|
|
|
510
513
|
export class CodexMainAccountHardLockError extends CodexAccountCooldownError {
|
|
511
514
|
readonly resetAt?: number;
|
|
512
515
|
|
|
513
|
-
constructor(resetAt?: number) {
|
|
516
|
+
constructor(resetAt?: number, thresholds = resolveMainAccountHardLockThresholds(undefined)) {
|
|
514
517
|
super(MAIN_CODEX_ACCOUNT_ID, resetAt ?? 0);
|
|
515
518
|
this.name = "CodexMainAccountHardLockError";
|
|
516
519
|
this.resetAt = resetAt;
|
|
517
|
-
this.message = `Codex main account is blocked by the
|
|
520
|
+
this.message = `Codex main account is blocked by the main-account quota policy (5h ≥ ${thresholds.short}%, weekly ≥ ${thresholds.long}%).`
|
|
518
521
|
+ " Choose another account, wait for quota to reset, or disable codexMainAccountHardLock in Settings.";
|
|
519
522
|
}
|
|
520
523
|
}
|
|
521
524
|
|
|
525
|
+
/** The main login may not spend credits and one of its usage windows is full (#6334). */
|
|
526
|
+
export class CodexMainAccountCreditsOffError extends CodexAccountCooldownError {
|
|
527
|
+
readonly resetAt?: number;
|
|
528
|
+
|
|
529
|
+
constructor(resetAt?: number) {
|
|
530
|
+
super(MAIN_CODEX_ACCOUNT_ID, resetAt ?? 0);
|
|
531
|
+
this.name = "CodexMainAccountCreditsOffError";
|
|
532
|
+
this.resetAt = resetAt;
|
|
533
|
+
this.message = "Codex main account reached its usage limit, and spending ChatGPT credits is off for it."
|
|
534
|
+
+ " Choose another account, wait for the limit to reset, or allow the main account under \"Use credits\" in Codex Auth.";
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
522
538
|
export class CodexReserveUnavailableError extends CodexAccountCooldownError {
|
|
523
539
|
constructor() {
|
|
524
540
|
super(MAIN_CODEX_ACCOUNT_ID, 0);
|
|
@@ -577,7 +593,8 @@ export class CodexRecoveryWithheldError extends CodexAccountCooldownError {
|
|
|
577
593
|
}
|
|
578
594
|
|
|
579
595
|
export type CodexAuthPolicyConfig = Readonly<Pick<OcxConfig,
|
|
580
|
-
"codexMainAccountHardLock" | "codexDesktopAuthless" | "runtimeRole" | "pausedCodexAccountIds"
|
|
596
|
+
"codexMainAccountHardLock" | "codexMainAccountHardLockThresholds" | "codexDesktopAuthless" | "runtimeRole" | "pausedCodexAccountIds"
|
|
597
|
+
| "creditCodexAccountIds"
|
|
581
598
|
>>;
|
|
582
599
|
|
|
583
600
|
interface CodexAuthMaterializationOptions {
|
|
@@ -700,10 +717,28 @@ export function unwrapUpstreamRetryEvidenceError(error: unknown): unknown {
|
|
|
700
717
|
return error;
|
|
701
718
|
}
|
|
702
719
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
720
|
+
/**
|
|
721
|
+
* When the main login's full window ends, if credits are off for it and a window is full (#6334).
|
|
722
|
+
* Same evidence the hard lock reads, and no plan lookup: the plan can live in the physical auth
|
|
723
|
+
* file, which several callers are forbidden to open, so every long window counts instead.
|
|
724
|
+
*/
|
|
725
|
+
function mainCreditsHoldResetAt(config: Pick<OcxConfig, "creditCodexAccountIds">): number | undefined {
|
|
726
|
+
if (codexAccountUsesCreditsAfterLimit(config, MAIN_CODEX_ACCOUNT_ID)) return undefined;
|
|
727
|
+
return codexUsageLimitResetAt(getMainPolicyQuota(), undefined, Date.now());
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function assertMainAccountPolicy(
|
|
731
|
+
config: Pick<OcxConfig, "codexMainAccountHardLock" | "codexMainAccountHardLockThresholds" | "creditCodexAccountIds"> | undefined,
|
|
732
|
+
): void {
|
|
733
|
+
if (config) {
|
|
734
|
+
const status = getMainAccountHardLockStatus(config);
|
|
735
|
+
if (status.state === "blocked") throw new CodexMainAccountHardLockError(status.resetAt, status.thresholds);
|
|
736
|
+
const creditsResetAt = mainCreditsHoldResetAt(config);
|
|
737
|
+
if (creditsResetAt !== undefined) throw new CodexMainAccountCreditsOffError(creditsResetAt);
|
|
738
|
+
}
|
|
739
|
+
// Only an admitted request is opencodex's own use of the main account. Counting a refused one
|
|
740
|
+
// would hide outside usage from the warning exactly while the lock is holding.
|
|
741
|
+
noteMainAccountActivity();
|
|
707
742
|
}
|
|
708
743
|
|
|
709
744
|
/** No auth-file I/O: an unsigned claim alone never identifies a caller as stored main. */
|
|
@@ -826,6 +861,9 @@ export function cooldownAccountLabel(accountId: string): string {
|
|
|
826
861
|
*/
|
|
827
862
|
export function cooldownErrorMessage(err: CodexAccountCooldownError, accountSelector?: string): string {
|
|
828
863
|
if (err instanceof CodexMainAccountHardLockError
|
|
864
|
+
// A credits-off refusal is a configuration policy, not a cooldown: clearing a cooldown
|
|
865
|
+
// cannot lift it, so its own wording (wait for the reset or allow credits) is the remedy.
|
|
866
|
+
|| err instanceof CodexMainAccountCreditsOffError
|
|
829
867
|
|| err instanceof CodexReserveUnavailableError
|
|
830
868
|
// A transient-hold refusal is not a quota cooldown. Its own wording is the only accurate
|
|
831
869
|
// one, and the quota recovery advice below would send the operator after a cooldown that
|
|
@@ -874,6 +912,7 @@ export class CodexThreadAffinityExpiredError extends Error {
|
|
|
874
912
|
|
|
875
913
|
export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown): boolean {
|
|
876
914
|
return !(cause instanceof CodexMainAccountHardLockError)
|
|
915
|
+
&& !(cause instanceof CodexMainAccountCreditsOffError)
|
|
877
916
|
&& !(cause instanceof CodexAccountValidationPendingError)
|
|
878
917
|
&& !(cause instanceof CodexReserveUnavailableError)
|
|
879
918
|
&& !(cause instanceof CodexCredentialGenerationConflictError)
|
|
@@ -1428,7 +1467,9 @@ export async function resolveCodexAuthContext(
|
|
|
1428
1467
|
releaseTransientProbeGrant();
|
|
1429
1468
|
if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
|
|
1430
1469
|
else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
|
|
1431
|
-
|
|
1470
|
+
// Policy refusals, including one that lands while the token refresh was in flight, are not
|
|
1471
|
+
// authentication failures: they must stay reset-bound 429s and never mark a valid login.
|
|
1472
|
+
if (cause instanceof CodexMainAccountHardLockError || cause instanceof CodexMainAccountCreditsOffError) throw cause;
|
|
1432
1473
|
if (!options.signal?.aborted && shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) {
|
|
1433
1474
|
markAccountNeedsReauth(accountId, writerGeneration);
|
|
1434
1475
|
}
|
|
@@ -21,6 +21,7 @@ export const DISCOVERED_NATIVE_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
|
|
|
21
21
|
*/
|
|
22
22
|
export const DISCOVERED_NATIVE_RENEW_INTERVAL_MS = 60 * 60 * 1000;
|
|
23
23
|
const FILE_NAME = "discovered-native-models.json";
|
|
24
|
+
const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
24
25
|
|
|
25
26
|
export interface DiscoveredNativeModel {
|
|
26
27
|
slug: string;
|
|
@@ -35,6 +36,13 @@ let models: DiscoveredNativeModel[] = [];
|
|
|
35
36
|
let fingerprint = "[]";
|
|
36
37
|
let generation = 0;
|
|
37
38
|
|
|
39
|
+
function containsLoneSurrogate(value: unknown): boolean {
|
|
40
|
+
if (typeof value === "string") return LONE_SURROGATE.test(value);
|
|
41
|
+
if (Array.isArray(value)) return value.some(containsLoneSurrogate);
|
|
42
|
+
if (!value || typeof value !== "object") return false;
|
|
43
|
+
return Object.entries(value).some(([key, child]) => LONE_SURROGATE.test(key) || containsLoneSurrogate(child));
|
|
44
|
+
}
|
|
45
|
+
|
|
38
46
|
/** Validate untrusted roster rows without filesystem effects; keep upstream capability metadata. */
|
|
39
47
|
export function validateDiscoveredNativeRows(rows: unknown): Record<string, unknown>[] {
|
|
40
48
|
if (!Array.isArray(rows)) return [];
|
|
@@ -56,8 +64,10 @@ export function validateDiscoveredNativeRows(rows: unknown): Record<string, unkn
|
|
|
56
64
|
try {
|
|
57
65
|
const serialized = JSON.stringify(row);
|
|
58
66
|
if (Buffer.byteLength(serialized, "utf8") > DISCOVERED_NATIVE_MAX_ROW_BYTES) continue;
|
|
67
|
+
const cloned = JSON.parse(serialized) as Record<string, unknown>;
|
|
68
|
+
if (containsLoneSurrogate(cloned)) continue;
|
|
59
69
|
if (accepted.size >= DISCOVERED_NATIVE_MAX_ROWS && !accepted.has(row.slug)) continue;
|
|
60
|
-
accepted.set(row.slug,
|
|
70
|
+
accepted.set(row.slug, cloned);
|
|
61
71
|
} catch { /* Cyclic/non-JSON rows cannot be persisted or sent to Codex. */ }
|
|
62
72
|
}
|
|
63
73
|
return [...accepted.values()];
|
|
@@ -121,6 +121,8 @@ export interface ModelsAuthResolution {
|
|
|
121
121
|
readonly apiKey: string | undefined;
|
|
122
122
|
readonly observed: boolean;
|
|
123
123
|
readonly oauthAccountId?: string;
|
|
124
|
+
/** Upstream user id carried by the OAuth snapshot (Zed); distinct from the store slot id. */
|
|
125
|
+
readonly oauthProviderUserId?: string;
|
|
124
126
|
readonly oauthGeneration?: string;
|
|
125
127
|
readonly oauthApiBaseUrl?: string;
|
|
126
128
|
readonly oauthProjectId?: string;
|
|
@@ -417,6 +419,7 @@ export function captureProviderGather(
|
|
|
417
419
|
finalMethod: request.method,
|
|
418
420
|
finalUrl: request.url,
|
|
419
421
|
filter: capturedField(discovery.spec, "filter"),
|
|
422
|
+
preferFirst: capturedField(discovery.spec, "preferFirst"),
|
|
420
423
|
maxResponseBytes: discovery.maxResponseBytes,
|
|
421
424
|
maxModels: discovery.maxModels,
|
|
422
425
|
trustedOpenAiApi,
|
|
@@ -446,7 +449,9 @@ export function captureGatherFlight(
|
|
|
446
449
|
const authResolver = createAuthResolver(providerAuthOutcomes);
|
|
447
450
|
const comboTargetsByProvider = configuredComboTargetModelsByProvider(config);
|
|
448
451
|
const providers = Object.entries(config.providers)
|
|
449
|
-
|
|
452
|
+
// A decision service (canonical TypeSafe or a self-hosted `jev-decision` row) owns no model
|
|
453
|
+
// transport: never probe its endpoint for /models and never publish its model id as routable.
|
|
454
|
+
.filter(([, provider]) => provider.disabled !== true && provider.adapter !== "jev-decision")
|
|
450
455
|
.map(([name, provider]) => captureProviderGather(
|
|
451
456
|
name,
|
|
452
457
|
provider,
|
|
@@ -604,7 +604,12 @@ export function modelInputModalities(
|
|
|
604
604
|
))) {
|
|
605
605
|
return ["text", "image"];
|
|
606
606
|
}
|
|
607
|
-
|
|
607
|
+
// OpenGateway publishes modalities as { input: [...], output: [...] }, not an array.
|
|
608
|
+
// Append this fallback after all recognized signals so existing providers keep their
|
|
609
|
+
// resolution, and retain Codex's closed enum rather than advertising output modalities.
|
|
610
|
+
const nestedInput = normalizedStringList(plainRecord(item.modalities)?.input, 8, 24)
|
|
611
|
+
?.filter(value => value === "text" || value === "image" || value === "audio");
|
|
612
|
+
return nestedInput && nestedInput.length > 0 ? nestedInput : undefined;
|
|
608
613
|
}
|
|
609
614
|
|
|
610
615
|
/**
|
|
@@ -688,6 +693,9 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid
|
|
|
688
693
|
// real values. Appended after the recognized fields for the same reason as the
|
|
689
694
|
// llama.cpp entries above: no provider that already resolves changes behavior.
|
|
690
695
|
capabilityRecord?.context_length,
|
|
696
|
+
// OpenGateway publishes a top-level context_window. Keep this last so catalogs
|
|
697
|
+
// already resolving through any recognized field retain their existing window.
|
|
698
|
+
item.context_window,
|
|
691
699
|
);
|
|
692
700
|
const maxInputTokens = positiveSafeInteger(limits?.max_input_tokens, item.max_input_tokens);
|
|
693
701
|
const maxOutputTokens = positiveSafeInteger(
|
|
@@ -62,6 +62,7 @@ import { fetchCodeBuddyModels } from "../../adapters/codebuddy/live-models";
|
|
|
62
62
|
import { resolveProfileByBaseUrl } from "../../adapters/coding-agent/profile";
|
|
63
63
|
import { fetchDevinUsableModels } from "../../adapters/devin/live-models";
|
|
64
64
|
import { resolveDevinApiBaseUrl } from "../../oauth/devin/api-base";
|
|
65
|
+
import { resolveZedModels } from "../../providers/zed";
|
|
65
66
|
import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
|
|
66
67
|
import {
|
|
67
68
|
COMBO_NAMESPACE,
|
|
@@ -152,6 +153,7 @@ export function observedModelsAuthResolver(
|
|
|
152
153
|
observed: true,
|
|
153
154
|
oauthAccountId: observation.snapshot.accountId,
|
|
154
155
|
oauthGeneration: observation.snapshot.generation,
|
|
156
|
+
...(observation.snapshot.providerUserId ? { oauthProviderUserId: observation.snapshot.providerUserId } : {}),
|
|
155
157
|
...(observation.snapshot.apiBaseUrl ? { oauthApiBaseUrl: observation.snapshot.apiBaseUrl } : {}),
|
|
156
158
|
...(observation.snapshot.projectId ? { oauthProjectId: observation.snapshot.projectId } : {}),
|
|
157
159
|
};
|
|
@@ -263,6 +265,7 @@ export async function fetchProviderModelsWithAuth(
|
|
|
263
265
|
observed: false,
|
|
264
266
|
oauthAccountId: snapshot.accountId,
|
|
265
267
|
oauthGeneration: snapshot.generation,
|
|
268
|
+
...(snapshot.providerUserId ? { oauthProviderUserId: snapshot.providerUserId } : {}),
|
|
266
269
|
...(snapshot.apiBaseUrl ? { oauthApiBaseUrl: snapshot.apiBaseUrl } : {}),
|
|
267
270
|
...(snapshot.projectId ? { oauthProjectId: snapshot.projectId } : {}),
|
|
268
271
|
}))
|
|
@@ -342,6 +345,63 @@ export async function fetchProviderModelsWithAuth(
|
|
|
342
345
|
stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias) : configured,
|
|
343
346
|
), "degraded");
|
|
344
347
|
}
|
|
348
|
+
if (name === "zed" && prov.adapter === "zed") {
|
|
349
|
+
if (!apiKey || !auth.oauthAccountId || !auth.oauthProviderUserId) return observed(configured, "degraded");
|
|
350
|
+
// Zed's roster and short-lived inference token are both account-scoped. Keep the
|
|
351
|
+
// catalog cache bound to the same pair so a multi-account switch cannot reuse a stale
|
|
352
|
+
// roster even when the provider destination is unchanged.
|
|
353
|
+
const authorityIdentity = createHash("sha256")
|
|
354
|
+
.update(JSON.stringify([auth.oauthAccountId, apiKey])).digest("hex");
|
|
355
|
+
const cached = getFreshCached(name, ttlMs, Date.now(), authorityIdentity);
|
|
356
|
+
if (cached) {
|
|
357
|
+
return observed(
|
|
358
|
+
withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cached, contextCap, metadataModelIdCaseFold, captured.effectiveAlias)),
|
|
359
|
+
"authoritative",
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
const stale = getStaleCached(name, authorityIdentity);
|
|
363
|
+
if (isModelsFetchCoolingDown(name, undefined, undefined, authorityIdentity) && stale) {
|
|
364
|
+
return observed(
|
|
365
|
+
withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias)),
|
|
366
|
+
"degraded",
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
const zedFetch = (prov as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch;
|
|
370
|
+
try {
|
|
371
|
+
const live = await resolveZedModels(
|
|
372
|
+
// Zed signs with its own user id; the slot id only keys the catalog cache above.
|
|
373
|
+
{ userId: auth.oauthProviderUserId, accessToken: apiKey },
|
|
374
|
+
{ signal: AbortSignal.timeout(8_000), ...(zedFetch ? { fetchFn: zedFetch } : {}) },
|
|
375
|
+
);
|
|
376
|
+
const discovered = live.models.map(model => {
|
|
377
|
+
const reasoningEfforts = sanitizeCodexReasoningEfforts(model.supportedEffortLevels);
|
|
378
|
+
return {
|
|
379
|
+
id: model.id,
|
|
380
|
+
provider: name,
|
|
381
|
+
...(model.contextLength ? { contextWindow: model.contextLength } : {}),
|
|
382
|
+
...(model.maxOutputTokens ? { maxOutputTokens: model.maxOutputTokens } : {}),
|
|
383
|
+
...(model.supportsImages ? { inputModalities: ["text", "image"] } : {}),
|
|
384
|
+
...(reasoningEfforts?.length ? { reasoningEfforts } : {}),
|
|
385
|
+
...catalogHintsFromProviderConfig(name, prov, model.id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias),
|
|
386
|
+
} as CatalogModel;
|
|
387
|
+
});
|
|
388
|
+
const forCache = withConfiguredRetention(discovered, { retainComboTargets: false });
|
|
389
|
+
if (!setCached(name, forCache, Date.now(), cacheGeneration, authorityIdentity)) {
|
|
390
|
+
return observed(withConfiguredRetention(configured), "degraded");
|
|
391
|
+
}
|
|
392
|
+
markProviderDiscoveryOk(name, live.models.length);
|
|
393
|
+
return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative");
|
|
394
|
+
} catch {
|
|
395
|
+
if (isCurrentCacheGeneration()) {
|
|
396
|
+
markModelsFetchFailure(name, undefined, authorityIdentity);
|
|
397
|
+
markProviderDiscoveryFailed(name, { reason: "provider" });
|
|
398
|
+
}
|
|
399
|
+
return observed(
|
|
400
|
+
withConfiguredRetention(stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias) : configured),
|
|
401
|
+
"degraded",
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
345
405
|
if (prov.adapter === "qoder") {
|
|
346
406
|
if (!apiKey) return observed(configured, "degraded");
|
|
347
407
|
const profile = resolveQoderProfile(prov.baseUrl);
|
|
@@ -528,7 +528,16 @@ async function gatherRoutedModelsUncached(
|
|
|
528
528
|
else warnUncataloguedComboOnce(id, combo, members, localOmissions);
|
|
529
529
|
}
|
|
530
530
|
replaceLastComboCatalogOmissions(localOmissions);
|
|
531
|
-
|
|
531
|
+
// Preserve declaratively ordered discovery (and cold-start seed order); other presets
|
|
532
|
+
// retain their historical alphabetical order. Renamed/custom transport scoping still applies.
|
|
533
|
+
// Read the captured discovery policy, never the live registry: a flight must keep the
|
|
534
|
+
// authority it was captured under even if the registry changes mid-gather.
|
|
535
|
+
const orderedProviders = new Set(activeProviders
|
|
536
|
+
.filter(({ discovery }) => (discovery.spec?.preferFirst?.length ?? 0) > 0)
|
|
537
|
+
.map(({ name }) => name));
|
|
538
|
+
all.sort((a, b) => (a.provider === b.provider
|
|
539
|
+
? orderedProviders.has(a.provider) ? 0 : a.id.localeCompare(b.id)
|
|
540
|
+
: a.provider.localeCompare(b.provider)));
|
|
532
541
|
// Provider-derived rows keyed by their Codex-facing slug: a custom override replaces the row
|
|
533
542
|
// with the same slug below, so that row's provider capability metadata is the inheritance source.
|
|
534
543
|
const replacedByRoutedSlug = new Map(all.map(model => [routedSlug(model.provider, model.id), model]));
|
|
@@ -737,10 +746,26 @@ async function gatherRoutedModelsUncached(
|
|
|
737
746
|
}
|
|
738
747
|
return mergedWithAutoCompact;
|
|
739
748
|
});
|
|
740
|
-
// Custom rows override discovered rows that encode to the same Codex-facing slug.
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
const
|
|
749
|
+
// Custom rows override discovered rows that encode to the same Codex-facing slug. A provider whose
|
|
750
|
+
// captured discovery declares an order (preferFirst) keeps each replacement in the discovered row's
|
|
751
|
+
// slot; every other provider keeps the historical order of discovered rows followed by custom rows.
|
|
752
|
+
const customBySlug = new Map<string, CatalogModel[]>();
|
|
753
|
+
for (const custom of customModels) {
|
|
754
|
+
const key = routedSlug(custom.provider, custom.id);
|
|
755
|
+
const bucket = customBySlug.get(key);
|
|
756
|
+
if (bucket) bucket.push(custom);
|
|
757
|
+
else customBySlug.set(key, [custom]);
|
|
758
|
+
}
|
|
759
|
+
const placedInSlot = new Set<string>();
|
|
760
|
+
const models: CatalogModel[] = all.flatMap(model => {
|
|
761
|
+
const key = routedSlug(model.provider, model.id);
|
|
762
|
+
const replacements = customBySlug.get(key);
|
|
763
|
+
if (!replacements) return [model];
|
|
764
|
+
if (!orderedProviders.has(model.provider) || placedInSlot.has(key)) return [];
|
|
765
|
+
placedInSlot.add(key);
|
|
766
|
+
return replacements;
|
|
767
|
+
});
|
|
768
|
+
models.push(...customModels.filter(custom => !placedInSlot.has(routedSlug(custom.provider, custom.id))));
|
|
744
769
|
// ponytail: catalog-scale scan; index ids by provider if catalog growth makes this measurable.
|
|
745
770
|
const aliasDisplayNames = new Map(activeProviders.flatMap(({ name, provider }) => {
|
|
746
771
|
const providerModels = models.filter(model => model.provider === name);
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* behavior at its own commit, which a runtime placeholder here would break.
|
|
15
15
|
*/
|
|
16
16
|
import type { OcxConfig } from "../types";
|
|
17
|
-
import type { ProviderModelDiscoveryFilter } from "../providers/registry";
|
|
17
|
+
import type { ProviderModelDiscoveryFilter, ProviderModelDiscoveryPredicate } from "../providers/registry";
|
|
18
18
|
|
|
19
19
|
/**
|
|
20
20
|
* The non-CAS JSON record for the Codex integration.
|
|
@@ -448,6 +448,7 @@ export interface CatalogProviderDiscoveryPolicySnapshot {
|
|
|
448
448
|
readonly finalMethod: "GET" | "POST";
|
|
449
449
|
readonly finalUrl: string;
|
|
450
450
|
readonly filter: CatalogDiscoveryPolicyField<ProviderModelDiscoveryFilter | undefined>;
|
|
451
|
+
readonly preferFirst: CatalogDiscoveryPolicyField<readonly ProviderModelDiscoveryPredicate[] | undefined>;
|
|
451
452
|
readonly maxResponseBytes: number;
|
|
452
453
|
readonly maxModels: number;
|
|
453
454
|
readonly trustedOpenAiApi: CatalogTrustedOpenAiApiPolicySnapshot;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Advisory fresh-observation detector. No persistence, timers, or merged quota reads. */
|
|
2
|
+
export const EXTERNAL_USAGE_QUIET_MS = 30 * 60_000;
|
|
3
|
+
export const EXTERNAL_USAGE_TTL_MS = 6 * 60 * 60_000;
|
|
4
|
+
export type FreshWindow = { kind: "short" | "long"; percent: number; resetAtMs: number };
|
|
5
|
+
export type MainAccountExternalUsageWarning = {
|
|
6
|
+
window: "short" | "long";
|
|
7
|
+
fromPercent: number;
|
|
8
|
+
toPercent: number;
|
|
9
|
+
observedAt: number;
|
|
10
|
+
};
|
|
11
|
+
type Reading = { percent: number; resetAtMs: number; observedAt: number;
|
|
12
|
+
warning?: MainAccountExternalUsageWarning };
|
|
13
|
+
let baseline: { identityKey: string; perWindow: Partial<Record<FreshWindow["kind"], Reading>> } | undefined;
|
|
14
|
+
let lastActivity = -Infinity;
|
|
15
|
+
|
|
16
|
+
export function noteMainAccountActivity(now = Date.now()): void {
|
|
17
|
+
lastActivity = now;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Only validated usage with a known reset from this observation reaches the baseline. */
|
|
21
|
+
export function observeMainAccountUsage(identityKey: string, windows: FreshWindow[], now = Date.now()): void {
|
|
22
|
+
if (baseline?.identityKey !== identityKey) baseline = { identityKey, perWindow: {} };
|
|
23
|
+
for (const window of windows) {
|
|
24
|
+
if (!Number.isFinite(window.percent) || window.percent < 0 || window.percent > 100
|
|
25
|
+
|| !Number.isFinite(window.resetAtMs) || window.resetAtMs <= 0) continue;
|
|
26
|
+
const previous = baseline.perWindow[window.kind];
|
|
27
|
+
const sameEpisode = previous?.resetAtMs === window.resetAtMs;
|
|
28
|
+
let warning = sameEpisode ? previous?.warning : undefined;
|
|
29
|
+
if (sameEpisode && previous && window.percent - previous.percent >= 1
|
|
30
|
+
&& lastActivity < previous.observedAt - EXTERNAL_USAGE_QUIET_MS) {
|
|
31
|
+
warning = { window: window.kind, fromPercent: previous.percent,
|
|
32
|
+
toPercent: window.percent, observedAt: now };
|
|
33
|
+
}
|
|
34
|
+
baseline.perWindow[window.kind] = { percent: window.percent, resetAtMs: window.resetAtMs,
|
|
35
|
+
observedAt: now, ...(warning ? { warning } : {}) };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function forgetMainAccountUsage(): void {
|
|
40
|
+
baseline = undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getMainAccountExternalUsageWarning(
|
|
44
|
+
identityKey: string | undefined, now = Date.now(),
|
|
45
|
+
): MainAccountExternalUsageWarning | undefined {
|
|
46
|
+
if (!identityKey || baseline?.identityKey !== identityKey) return undefined;
|
|
47
|
+
for (const kind of ["short", "long"] as const) {
|
|
48
|
+
const reading = baseline.perWindow[kind];
|
|
49
|
+
if (!reading?.warning) continue;
|
|
50
|
+
if (now >= reading.resetAtMs || now - reading.warning.observedAt >= EXTERNAL_USAGE_TTL_MS) {
|
|
51
|
+
delete reading.warning;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
return { ...reading.warning };
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function resetMainAccountExternalUsageForTests(): void {
|
|
60
|
+
forgetMainAccountUsage();
|
|
61
|
+
lastActivity = -Infinity;
|
|
62
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { OcxConfig } from "../types";
|
|
2
2
|
import { getMainPolicyQuota } from "./quota";
|
|
3
|
-
import { MAIN_ACCOUNT_HARD_LOCK_PERCENT } from "./quota-types";
|
|
3
|
+
import { MAIN_ACCOUNT_HARD_LOCK_PERCENT, MAIN_ACCOUNT_HARD_LOCK_SHORT_PERCENT, MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT } from "./quota-types";
|
|
4
4
|
|
|
5
5
|
export { MAIN_ACCOUNT_HARD_LOCK_PERCENT };
|
|
6
6
|
|
|
@@ -9,9 +9,19 @@ export interface MainAccountHardLockStatus {
|
|
|
9
9
|
state: "off" | "unknown" | "ready" | "blocked";
|
|
10
10
|
/** Unix milliseconds; absent when a blocking observation has no future reset. */
|
|
11
11
|
resetAt?: number;
|
|
12
|
+
thresholds: { short: number; long: number };
|
|
13
|
+
window?: "short" | "long";
|
|
12
14
|
}
|
|
13
15
|
|
|
14
|
-
type PolicyConfig = Pick<OcxConfig, "codexMainAccountHardLock">;
|
|
16
|
+
type PolicyConfig = Pick<OcxConfig, "codexMainAccountHardLock" | "codexMainAccountHardLockThresholds">;
|
|
17
|
+
|
|
18
|
+
export function resolveMainAccountHardLockThresholds(config: PolicyConfig | undefined): { short: number; long: number } {
|
|
19
|
+
const valid = (value: unknown, fallback: number): number => typeof value === "number"
|
|
20
|
+
&& Number.isInteger(value) && value >= MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT && value <= 100 ? value : fallback;
|
|
21
|
+
const long = valid(config?.codexMainAccountHardLockThresholds?.long, MAIN_ACCOUNT_HARD_LOCK_PERCENT);
|
|
22
|
+
const short = valid(config?.codexMainAccountHardLockThresholds?.short, MAIN_ACCOUNT_HARD_LOCK_SHORT_PERCENT);
|
|
23
|
+
return { short: Math.min(short, long), long };
|
|
24
|
+
}
|
|
15
25
|
|
|
16
26
|
/**
|
|
17
27
|
* Whether the main-account hard lock applies to this config (#5694).
|
|
@@ -31,11 +41,11 @@ function resetTimestamp(value: number | undefined): number | undefined {
|
|
|
31
41
|
return value < 10_000_000_000 ? value * 1000 : value;
|
|
32
42
|
}
|
|
33
43
|
|
|
34
|
-
type WindowReading = { percent: number | undefined; resetAt: number | undefined };
|
|
44
|
+
type WindowReading = { kind: "short" | "long"; percent: number | undefined; resetAt: number | undefined };
|
|
35
45
|
|
|
36
46
|
/**
|
|
37
47
|
* The windows that govern the lock. The 5h and weekly windows each govern on their own, so either
|
|
38
|
-
* one at
|
|
48
|
+
* one at its configured threshold blocks even while the other has headroom. Monthly governs only a monthly-only account:
|
|
39
49
|
* supplementary monthly data on a 5h/weekly account never becomes a lock. The list is never empty,
|
|
40
50
|
* so a record with no readings at all classifies as unknown rather than vacuously ready.
|
|
41
51
|
*/
|
|
@@ -44,9 +54,9 @@ function governingWindows(quota: NonNullable<ReturnType<typeof getMainPolicyQuot
|
|
|
44
54
|
|| quota.shortWindowSeconds !== undefined;
|
|
45
55
|
const hasWeekly = quota.weeklyPercent !== undefined || quota.weeklyResetAt !== undefined;
|
|
46
56
|
const windows: WindowReading[] = [];
|
|
47
|
-
if (hasShort) windows.push({ percent: quota.shortPercent, resetAt: quota.shortResetAt });
|
|
48
|
-
if (hasWeekly) windows.push({ percent: quota.weeklyPercent, resetAt: quota.weeklyResetAt });
|
|
49
|
-
if (windows.length === 0) windows.push({ percent: quota.monthlyPercent, resetAt: quota.monthlyResetAt });
|
|
57
|
+
if (hasShort) windows.push({ kind: "short", percent: quota.shortPercent, resetAt: quota.shortResetAt });
|
|
58
|
+
if (hasWeekly) windows.push({ kind: "long", percent: quota.weeklyPercent, resetAt: quota.weeklyResetAt });
|
|
59
|
+
if (windows.length === 0) windows.push({ kind: "long", percent: quota.monthlyPercent, resetAt: quota.monthlyResetAt });
|
|
50
60
|
return windows;
|
|
51
61
|
}
|
|
52
62
|
|
|
@@ -60,22 +70,23 @@ export function getMainAccountHardLockStatus(
|
|
|
60
70
|
config: PolicyConfig,
|
|
61
71
|
now = Date.now(),
|
|
62
72
|
): MainAccountHardLockStatus {
|
|
63
|
-
|
|
73
|
+
const thresholds = resolveMainAccountHardLockThresholds(config);
|
|
74
|
+
if (!isMainAccountHardLockEnabled(config)) return { enabled: false, state: "off", thresholds };
|
|
64
75
|
const quota = getMainPolicyQuota();
|
|
65
|
-
if (!quota) return { enabled: true, state: "unknown" };
|
|
76
|
+
if (!quota) return { enabled: true, state: "unknown", thresholds };
|
|
66
77
|
const windows = governingWindows(quota);
|
|
67
|
-
const blocking = windows.filter(w => validPercent(w.percent) && w.percent >=
|
|
78
|
+
const blocking = windows.filter(w => validPercent(w.percent) && w.percent >= thresholds[w.kind]);
|
|
68
79
|
if (blocking.length > 0) {
|
|
69
80
|
// The lock holds until every blocking window reads lower or is authoritatively absent, so the earliest possible unlock is
|
|
70
81
|
// the latest blocking reset. One blocking window without a future reset makes it unknowable.
|
|
71
82
|
// A predicted reset is not evidence of recovery; fresh lower usage or validated WHAM absence releases.
|
|
72
83
|
const resets = blocking.map(w => resetTimestamp(w.resetAt));
|
|
73
84
|
const resetAt = resets.every(r => r !== undefined && r > now) ? Math.max(...(resets as number[])) : undefined;
|
|
74
|
-
return { enabled: true, state: "blocked", ...(resetAt !== undefined ? { resetAt } : {}) };
|
|
85
|
+
return { enabled: true, state: "blocked", thresholds, window: blocking[0]!.kind, ...(resetAt !== undefined ? { resetAt } : {}) };
|
|
75
86
|
}
|
|
76
87
|
// Unknown admits, so an unreadable window never hides a blocking one and never blocks alone.
|
|
77
|
-
if (windows.some(w => !validPercent(w.percent))) return { enabled: true, state: "unknown" };
|
|
78
|
-
return { enabled: true, state: "ready" };
|
|
88
|
+
if (windows.some(w => !validPercent(w.percent))) return { enabled: true, state: "unknown", thresholds };
|
|
89
|
+
return { enabled: true, state: "ready", thresholds };
|
|
79
90
|
}
|
|
80
91
|
|
|
81
92
|
export function isMainAccountHardLocked(config: PolicyConfig, now = Date.now()): boolean {
|