@bitkyc08/opencodex 2.81.0 → 2.82.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/bin/ocx.mjs +166 -73
- package/gui/dist/assets/App-Ds_qL7o8.js +54 -0
- package/gui/dist/assets/{Tray-EwAOsP0S.js → Tray-BqHF5Trd.js} +1 -1
- package/gui/dist/assets/{index-Brk6F3fn.css → index-BAbcj51J.css} +1 -1
- package/gui/dist/assets/{index-RsoaAxXY.js → index-D3FBCNiP.js} +13 -13
- package/gui/dist/assets/{quota-summary-D2lN8tDm.js → quota-summary-DTBC8n7-.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +1 -1
- package/src/adapters/openai-responses/compaction-search-history.ts +109 -0
- package/src/adapters/openai-responses/passthrough.ts +9 -4
- package/src/adapters/openai-responses/tool-output-recovery.ts +20 -0
- package/src/claude/agents-inject.ts +14 -3
- package/src/claude/context-windows.ts +19 -17
- package/src/claude/desktop-3p.ts +88 -12
- package/src/claude/desktop-profile.ts +2 -1
- package/src/claude/gateway-cache.ts +18 -1
- package/src/claude/inbound.ts +77 -3
- package/src/claude/intercept/cli-catalog.ts +3 -0
- package/src/claude/intercept/picker-ca-persistence.ts +7 -6
- package/src/claude/intercept/picker-models.ts +14 -6
- package/src/claude/intercept/settings.ts +3 -0
- package/src/claude/long-context.ts +66 -0
- package/src/claude/model-info.ts +19 -16
- package/src/claude/outbound.ts +94 -9
- package/src/claude/subagent-model.ts +40 -11
- package/src/cli/agent.ts +9 -5
- package/src/cli/aside-profile-recovery.ts +33 -0
- package/src/cli/capabilities-base.ts +5 -5
- package/src/cli/capabilities-integrations.ts +2 -2
- package/src/cli/cli-path-diagnostics.ts +132 -0
- package/src/cli/desktop-runtime-guidance.ts +65 -0
- package/src/cli/dispatch.ts +5 -0
- package/src/cli/doctor.ts +16 -3
- package/src/cli/index.ts +14 -9
- package/src/cli/integration-aside-sync.ts +2 -1
- package/src/cli/integrations.ts +16 -3
- package/src/cli/resolve.ts +25 -2
- package/src/cli/root.ts +4 -2
- package/src/cli/status.ts +64 -12
- package/src/cli/update-restart-child.ts +4 -2
- package/src/cli/update-restart-home.ts +28 -10
- package/src/cli/update-restart-service-record.ts +116 -0
- package/src/cli/update-restart-supervision.ts +96 -0
- package/src/cli/update-restart.ts +59 -13
- package/src/cli/v2.ts +27 -20
- package/src/cli/version-skew-notice.ts +77 -0
- package/src/clients/lazycodex.ts +1 -1
- package/src/clients/omo-role-models.ts +32 -7
- package/src/codex/auth-api/account-selection.ts +107 -0
- package/src/codex/auth-api/routes.ts +10 -49
- package/src/codex/auth-context.ts +20 -1
- package/src/codex/autostart-health.ts +12 -3
- package/src/codex/catalog/build-entries.ts +18 -0
- package/src/codex/catalog/bundled.ts +155 -26
- package/src/codex/catalog/combo-member.ts +3 -2
- package/src/codex/catalog/effort.ts +26 -5
- package/src/codex/catalog/gather-capture.ts +5 -0
- package/src/codex/catalog/model-hints.ts +15 -8
- package/src/codex/catalog/model-visibility.ts +3 -1
- package/src/codex/catalog/operator-tiers.ts +38 -0
- package/src/codex/catalog/provider-models.ts +43 -35
- package/src/codex/catalog/retained-sync.ts +7 -5
- package/src/codex/catalog/routed-gather.ts +15 -1
- package/src/codex/catalog-auto-refresh-sources.ts +3 -4
- package/src/codex/catalog-auto-refresh.ts +19 -0
- package/src/codex/catalog.ts +1 -1
- package/src/codex/codex-home-owner.ts +3 -0
- package/src/codex/config-write-lock.ts +333 -0
- package/src/codex/convergence.ts +2 -1
- package/src/codex/features.ts +257 -200
- package/src/codex/inject/config-write-section.ts +34 -0
- package/src/codex/inject/multi-agent-v2.ts +14 -6
- package/src/codex/inject/remove.ts +36 -11
- package/src/codex/inject/restore.ts +85 -20
- package/src/codex/inject-coordination.ts +15 -6
- package/src/codex/inject.ts +62 -20
- package/src/codex/journal.ts +62 -17
- package/src/codex/main-account-cache.ts +43 -0
- package/src/codex/paths.ts +2 -0
- package/src/codex/prepared-home.ts +18 -0
- package/src/codex/prompt-journal.ts +25 -12
- package/src/codex/prompt-layers.ts +49 -26
- package/src/codex/prompt-lock-claim.ts +134 -0
- package/src/codex/prompt-lock-evidence.ts +26 -0
- package/src/codex/prompt-lock-io.ts +16 -0
- package/src/codex/prompt-lock-owner.ts +130 -0
- package/src/codex/prompt-lock.ts +84 -53
- package/src/codex/routing/failure-window.ts +146 -0
- package/src/codex/routing/selection.ts +4 -1
- package/src/codex/routing.ts +13 -17
- package/src/codex/runtime.ts +106 -18
- package/src/codex/shim.ts +45 -19
- package/src/combos/jev-service-exchange.ts +281 -0
- package/src/combos/jev.ts +34 -220
- package/src/config/atomic-write.ts +1 -1
- package/src/config/codex-ws-reuse-setting.ts +10 -0
- package/src/config/live-reconcile.ts +17 -0
- package/src/config/load-degrade.ts +8 -0
- package/src/config/rebase-provenance.ts +11 -0
- package/src/config/schema/config-schema.ts +4 -0
- package/src/generated/compatibility-version.json +303 -171
- package/src/generated/model-metadata.ts +2 -2
- package/src/grok/inject.ts +50 -4
- package/src/grok/sync.ts +2 -1
- package/src/images/loop.ts +29 -11
- package/src/integrations/aside-profiles.ts +10 -3
- package/src/integrations/catalog-refresh.ts +18 -2
- package/src/integrations/writer.ts +9 -3
- package/src/lib/bun-path-runtime.d.mts +32 -0
- package/src/lib/bun-path-runtime.mjs +95 -0
- package/src/lib/bun-runtime-preflight.ts +140 -0
- package/src/lib/desktop-cli-handoff.d.mts +20 -0
- package/src/lib/desktop-cli-handoff.mjs +92 -0
- package/src/lib/desktop-cli-record.d.mts +29 -0
- package/src/lib/desktop-cli-record.mjs +102 -0
- package/src/lib/request-execution-budget.ts +8 -0
- package/src/lib/synced-state-location.ts +46 -6
- package/src/lib/test-home-guard.ts +87 -1
- package/src/lib/translator-budget.ts +17 -4
- package/src/lib/windows-atomic-replace.ts +38 -16
- package/src/lib/windows-elevation.ts +6 -0
- package/src/lib/windows-secret-acl.ts +92 -25
- package/src/lib/winsw.ts +2 -3
- package/src/oauth/anthropic-account-refusal.ts +39 -1
- package/src/oauth/anthropic-routing.ts +1 -1
- package/src/oauth/anthropic-send-ownership.ts +3 -3
- package/src/oauth/chatgpt.ts +113 -41
- package/src/oauth/index.ts +6 -2
- package/src/oauth/store.ts +1 -1
- package/src/providers/azure-model-metadata.ts +115 -0
- package/src/providers/derive.ts +21 -0
- package/src/providers/quota/vendor-probes-key.ts +127 -17
- package/src/providers/quota.ts +1 -1
- package/src/responses/reasoning-envelope.ts +29 -1
- package/src/responses/reasoning-replay-cache.ts +17 -1
- package/src/responses/spill-store.ts +15 -2
- package/src/responses/state/metrics.ts +8 -0
- package/src/responses/state/spill-failure.ts +12 -0
- package/src/responses/state/spill-queue.ts +12 -5
- package/src/responses/state.ts +18 -10
- package/src/responses/task-input.ts +17 -0
- package/src/server/background-lifecycle.ts +27 -0
- package/src/server/chat-completions.ts +1 -0
- package/src/server/chat-native.ts +28 -12
- package/src/server/claude-messages.ts +43 -6
- package/src/server/inbound-body-admission.ts +1 -0
- package/src/server/index/claude-intercept-lifecycle.ts +3 -1
- package/src/server/index/serve-options.ts +10 -2
- package/src/server/index/spend-ledger-lifecycle.ts +2 -1
- package/src/server/index/startup-warnings.ts +19 -0
- package/src/server/index.ts +1 -0
- package/src/server/management/agent-settings-routes.ts +93 -32
- package/src/server/management/codex-agent-role-routes.ts +4 -2
- package/src/server/management/codex-prompt-routes.ts +1 -0
- package/src/server/management/codex-role-auto-assign.ts +13 -2
- package/src/server/management/config-routes.ts +59 -8
- package/src/server/management/context.ts +2 -2
- package/src/server/management/shared.ts +3 -3
- package/src/server/messages-native-oauth.ts +9 -8
- package/src/server/messages-native.ts +49 -13
- package/src/server/messages-response-headers.ts +14 -0
- package/src/server/request-log-conversation.ts +48 -5
- package/src/server/request-log.ts +31 -2
- package/src/server/responses/adapter-continuation.ts +2 -2
- package/src/server/responses/adapter-dispatch.ts +34 -13
- package/src/server/responses/codex-ws-exchange.ts +17 -2
- package/src/server/responses/codex-ws-pool.ts +100 -13
- package/src/server/responses/codex-ws-wire.ts +9 -0
- package/src/server/responses/combo-requested-effort.ts +3 -1
- package/src/server/responses/core-codex-account.ts +22 -1
- package/src/server/responses/core-combo-native.ts +20 -3
- package/src/server/responses/core-combo.ts +22 -14
- package/src/server/responses/core-lifetime.ts +3 -36
- package/src/server/responses/core-options.ts +5 -0
- package/src/server/responses/core-replay.ts +73 -0
- package/src/server/responses/core.ts +3 -3
- package/src/server/responses/fetch-helpers.ts +26 -3
- package/src/server/responses/passthrough-delivery.ts +24 -2
- package/src/server/responses/passthrough-dispatch.ts +19 -2
- package/src/server/responses/request-prepare.ts +4 -0
- package/src/server/responses/request-send-budget.ts +112 -49
- package/src/server/responses/response-effects.ts +1 -0
- package/src/server/responses/run-turn-execution.ts +2 -0
- package/src/server/responses/sidecar-execution.ts +25 -16
- package/src/server/responses/sidecar-send-budget.ts +58 -0
- package/src/server/responses/ws-upstream.ts +19 -6
- package/src/server/startup-health-cache.ts +2 -1
- package/src/server/workflow-refusal.ts +3 -0
- package/src/service/cli.ts +20 -4
- package/src/service/desktop-command-guard.ts +32 -0
- package/src/service/desktop-startup.ts +66 -40
- package/src/service/desktop-supervision.d.mts +26 -0
- package/src/service/desktop-supervision.mjs +140 -0
- package/src/service/managing-cli.ts +1 -0
- package/src/service/orchestration.ts +46 -20
- package/src/service/repair.ts +25 -9
- package/src/service/state.ts +4 -3
- package/src/service/systemd.ts +17 -8
- package/src/service/windows-ops.ts +61 -22
- package/src/storage/policy-job.ts +5 -1
- package/src/storage/policy-worker.ts +3 -1
- package/src/storage/policy.ts +9 -0
- package/src/types/config.ts +21 -0
- package/src/types/request.ts +5 -0
- package/src/types/wire.ts +1 -0
- package/src/update/index.ts +57 -9
- package/src/update/restart-ownership.ts +10 -3
- package/src/update/runtime-ownership.d.mts +6 -0
- package/src/update/runtime-ownership.mjs +23 -7
- package/src/web-search/loop.ts +5 -2
- package/gui/dist/assets/App-7yhoys8a.js +0 -54
package/src/oauth/index.ts
CHANGED
|
@@ -38,7 +38,7 @@ import { anthropicOAuthDefinition, assertAnthropicInstanceLoginConfig, Anthropic
|
|
|
38
38
|
resumeAnthropicRefreshIntentCleanup, clearObservedAnthropicRefreshIntent } from "./anthropic-oauth-definitions";
|
|
39
39
|
import { loginKimi, refreshKimiToken } from "./kimi";
|
|
40
40
|
import { loginNous, NousTokenError, refreshNousToken, clearNousRefreshIntent, RefreshIntentIOError } from "./nous";
|
|
41
|
-
import { loginChatGPT, refreshChatGPTToken, type ChatGPTLoginFlow } from "./chatgpt";
|
|
41
|
+
import { ChatGptTokenRequestError, ChatGptTokenError, loginChatGPT, refreshChatGPTToken, type ChatGPTLoginFlow } from "./chatgpt";
|
|
42
42
|
import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity";
|
|
43
43
|
import { loginCursor, refreshCursorToken } from "./cursor";
|
|
44
44
|
import { loginZed, refreshZedToken } from "./zed";
|
|
@@ -350,7 +350,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
|
|
|
350
350
|
},
|
|
351
351
|
chatgpt: {
|
|
352
352
|
login: (ctrl, opts) => loginChatGPT(ctrl, { forceLogin: opts?.forceLogin, flow: opts?.flow }),
|
|
353
|
-
refresh: (rt) => refreshChatGPTToken(rt),
|
|
353
|
+
refresh: (rt, signal) => refreshChatGPTToken(rt, { signal }),
|
|
354
354
|
providerConfig: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const },
|
|
355
355
|
defaultModel: "gpt-5.6-luna",
|
|
356
356
|
},
|
|
@@ -712,10 +712,14 @@ function isTerminalRefreshError(err: unknown): boolean {
|
|
|
712
712
|
|| msg.includes("expired_token");
|
|
713
713
|
}
|
|
714
714
|
function terminal(error:unknown):boolean{
|
|
715
|
+
if (error instanceof ChatGptTokenRequestError) return false;
|
|
715
716
|
if(error instanceof XaiTokenRequestError)return ["invalid_grant","refresh_token_reused","revoked_token"].includes(error.oauthError??"");
|
|
716
717
|
if(error instanceof AnthropicTokenError)return (error.httpStatus===400||error.httpStatus===401)&&["invalid_grant","refresh_token_reused","revoked","revoked_token","refresh_token_revoked"].includes(error.oauthError??"");
|
|
717
718
|
if(error instanceof KiroTokenRefreshError)return (error.httpStatus===400||error.httpStatus===401)&&error.oauthError!==undefined;
|
|
718
719
|
if(error instanceof NousTokenError)return error.terminal===true||["invalid_grant","refresh_token_reused","revoked","revoked_token","expired_token"].includes(error.oauthError??"");
|
|
720
|
+
// ChatGPT carries the pool classifier's verdict on the error itself: revoked and
|
|
721
|
+
// expired grants are terminal; unknown/transient failures stay retryable.
|
|
722
|
+
if(error instanceof ChatGptTokenError)return error.terminal;
|
|
719
723
|
// Local durable-write/read/cleanup failures are operational, not credential
|
|
720
724
|
// death: the provider credential was never rejected or consumed. Never mark
|
|
721
725
|
// the account needsReauth for broken local persistence infrastructure.
|
package/src/oauth/store.ts
CHANGED
|
@@ -1508,7 +1508,7 @@ export async function markAccountNeedsReauth(
|
|
|
1508
1508
|
export async function mergeAccountCredential(provider:string,accountId:string,credential:OAuthCredentials,opts:{expectedGeneration?:string;afterPrePersistRead?:()=>void|Promise<void>;assertOwnership?: (store: AuthStore) => void}={}):Promise<{superseded:false}|{superseded:true;stored:OAuthCredentials}>{const safe=normalizeCredential(credential);if(!safe)throw new Error("Refusing to persist invalid OAuth credential");if(isAnthropicInstanceId(provider))assertAnthropicCredentialSource(provider,safe);return await mutateStore(async store=>{await opts.afterPrePersistRead?.();const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account)throw new Error(`OAuth account disappeared before persist: ${provider}`);if(opts.expectedGeneration!==undefined&&credentialGeneration(account.credential)!==opts.expectedGeneration)return{superseded:true,stored:account.credential};opts.assertOwnership?.(store);account.credential=safe;delete account.refreshAttentionGeneration;if(account.needsReauthReason!=="verify_account"){delete account.needsReauth;delete account.needsReauthReason;}return{superseded:false};},[provider,accountId,safe,opts.expectedGeneration]);}
|
|
1509
1509
|
// A late refresh failure must not change an operator-paused account's health. Check under
|
|
1510
1510
|
// the mutation lock, not before awaiting it, so a concurrent pause cannot be overwritten.
|
|
1511
|
-
export async function markAccountNeedsReauthIfGeneration(provider:string,accountId:string,generation:string,writerGeneration=captureConfigGeneration(),reason?:"verify_account"):Promise<boolean>{const key=oauthAccountKey(provider,accountId);if(writerGeneration<lastReconciledGeneration&&!liveOAuthAccountKeys.has(key))return false;return await mutateStore(store=>{const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account?.credential||account.paused||credentialGeneration(account.credential)!==generation)return false;if(writerGeneration<lastReconciledGeneration&&!liveOAuthAccountKeys.has(key))return false;account.needsReauth=true;if(reason==="verify_account")account.needsReauthReason=reason;return true;},[provider,accountId,generation]);}
|
|
1511
|
+
export async function markAccountNeedsReauthIfGeneration(provider:string,accountId:string,generation:string,writerGeneration=captureConfigGeneration(),reason?:"verify_account",isCurrent?:(store:AuthStore)=>boolean):Promise<boolean>{const key=oauthAccountKey(provider,accountId);if(writerGeneration<lastReconciledGeneration&&!liveOAuthAccountKeys.has(key))return false;return await mutateStore(store=>{const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account?.credential||account.paused||credentialGeneration(account.credential)!==generation)return false;if(writerGeneration<lastReconciledGeneration&&!liveOAuthAccountKeys.has(key))return false;if(isCurrent&&!isCurrent(store))return false;account.needsReauth=true;if(reason==="verify_account")account.needsReauthReason=reason;return true;},[provider,accountId,generation]);}
|
|
1512
1512
|
|
|
1513
1513
|
/** Attention is projected only; refresh selection continues to use the stored terminal flag. */
|
|
1514
1514
|
export function accountNeedsReauthForStatus(provider: string, account: ProviderAccount, now = Date.now()): boolean {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { atomicWriteFile } from "../config/atomic-write";
|
|
4
|
+
import { getConfigDir } from "../config/paths";
|
|
5
|
+
import { readBoundedResponseBytes } from "../lib/bounded-body";
|
|
6
|
+
|
|
7
|
+
const SOURCE_URL = "https://models.dev/api.json";
|
|
8
|
+
const FILENAME = "azure-model-metadata-cache.json";
|
|
9
|
+
const TTL_MS = 24 * 60 * 60 * 1000;
|
|
10
|
+
|
|
11
|
+
export type AzureModelMetadata = { input?: string[]; contextWindow?: number; maxTokens?: number };
|
|
12
|
+
type Snapshot = { version: 1; fetchedAt: number; models: Record<string, AzureModelMetadata> };
|
|
13
|
+
type CacheState = { snapshot?: Snapshot | null; inFlight?: Promise<void>; retryAfter: number };
|
|
14
|
+
const cacheByConfigDir = new Map<string, CacheState>();
|
|
15
|
+
|
|
16
|
+
function cacheState(configDir: string): CacheState {
|
|
17
|
+
let state = cacheByConfigDir.get(configDir);
|
|
18
|
+
if (!state) {
|
|
19
|
+
state = { retryAfter: 0 };
|
|
20
|
+
cacheByConfigDir.set(configDir, state);
|
|
21
|
+
}
|
|
22
|
+
return state;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isAzureModelMetadataDestination(baseUrl: string | undefined): boolean {
|
|
26
|
+
try {
|
|
27
|
+
return !!baseUrl && new URL(baseUrl).hostname.endsWith(".openai.azure.com");
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function positiveInteger(value: unknown): number | undefined {
|
|
34
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
38
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
39
|
+
? value as Record<string, unknown> : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseModels(models: unknown, published: boolean): Record<string, AzureModelMetadata> {
|
|
43
|
+
const out: Record<string, AzureModelMetadata> = Object.create(null);
|
|
44
|
+
for (const [id, value] of Object.entries(record(models) ?? {}).slice(0, 2000)) {
|
|
45
|
+
const row = record(value);
|
|
46
|
+
if (!row || id.length > 256) continue;
|
|
47
|
+
const limit = published ? record(row.limit) : row;
|
|
48
|
+
const input = published ? record(row.modalities)?.input : row.input;
|
|
49
|
+
const modalities = Array.isArray(input)
|
|
50
|
+
? [...new Set(input.filter((item): item is string => item === "text" || item === "image" || item === "audio"))]
|
|
51
|
+
: [];
|
|
52
|
+
const contextWindow = positiveInteger(published ? limit?.context : limit?.contextWindow);
|
|
53
|
+
const maxTokens = positiveInteger(published ? limit?.output : limit?.maxTokens);
|
|
54
|
+
if (!modalities.length && contextWindow === undefined && maxTokens === undefined) continue;
|
|
55
|
+
out[id.toLowerCase()] = {
|
|
56
|
+
...(modalities.length ? { input: modalities } : {}),
|
|
57
|
+
...(contextWindow !== undefined ? { contextWindow } : {}),
|
|
58
|
+
...(maxTokens !== undefined ? { maxTokens } : {}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function loadSnapshot(configDir: string, state: CacheState): Snapshot | null {
|
|
65
|
+
if (state.snapshot !== undefined) return state.snapshot;
|
|
66
|
+
state.snapshot = null;
|
|
67
|
+
try {
|
|
68
|
+
const raw = record(JSON.parse(readFileSync(join(configDir, FILENAME), "utf8")));
|
|
69
|
+
if (raw?.version === 1 && positiveInteger(raw.fetchedAt) !== undefined && record(raw.models)) {
|
|
70
|
+
state.snapshot = { version: 1, fetchedAt: raw.fetchedAt as number, models: parseModels(raw.models, false) };
|
|
71
|
+
}
|
|
72
|
+
} catch { /* Missing or corrupt optional metadata leaves bundled hints available. */ }
|
|
73
|
+
return state.snapshot;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Synchronous catalog fallback. Generation requests never fetch this snapshot. */
|
|
77
|
+
export function publishedAzureModelMetadata(modelId: string, configDir = getConfigDir()): AzureModelMetadata | undefined {
|
|
78
|
+
return loadSnapshot(configDir, cacheState(configDir))?.models[modelId.toLowerCase()];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Refresh once per day during discovery, coalesced and bounded; retain stale data offline. */
|
|
82
|
+
export async function refreshAzureModelMetadata(baseUrl: string | undefined, configDir = getConfigDir()): Promise<void> {
|
|
83
|
+
if (!isAzureModelMetadataDestination(baseUrl)) return;
|
|
84
|
+
// Capture state and destination together: tests and embedders can change config roots.
|
|
85
|
+
const state = cacheState(configDir);
|
|
86
|
+
const current = loadSnapshot(configDir, state);
|
|
87
|
+
if ((current && Date.now() - current.fetchedAt < TTL_MS) || Date.now() < state.retryAfter) return;
|
|
88
|
+
if (state.inFlight) return state.inFlight;
|
|
89
|
+
const path = join(configDir, FILENAME);
|
|
90
|
+
state.inFlight = (async () => {
|
|
91
|
+
try {
|
|
92
|
+
const signal = AbortSignal.timeout(2000);
|
|
93
|
+
const response = await fetch(SOURCE_URL, {
|
|
94
|
+
headers: { accept: "application/json" },
|
|
95
|
+
redirect: "error",
|
|
96
|
+
signal,
|
|
97
|
+
});
|
|
98
|
+
if (!response.ok) return;
|
|
99
|
+
const body = await readBoundedResponseBytes(response, { maxBytes: 16 * 1024 * 1024, signal });
|
|
100
|
+
if (body.oversized) return;
|
|
101
|
+
const raw = record(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body.bytes)));
|
|
102
|
+
// Provider-specific rows matter: Azure may expose text only where the direct vendor has vision.
|
|
103
|
+
const models = parseModels(record(raw?.azure)?.models, true);
|
|
104
|
+
if (!Object.keys(models).length) return;
|
|
105
|
+
state.snapshot = { version: 1, fetchedAt: Date.now(), models };
|
|
106
|
+
try { atomicWriteFile(path, JSON.stringify(state.snapshot) + "\n"); } catch { /* Memory still serves discovery. */ }
|
|
107
|
+
} catch { /* A public metadata outage must not fail provider discovery. */ }
|
|
108
|
+
finally { state.retryAfter = Date.now() + 60_000; }
|
|
109
|
+
})().finally(() => { state.inFlight = undefined; });
|
|
110
|
+
return state.inFlight;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function resetAzureModelMetadataForTests(): void {
|
|
114
|
+
cacheByConfigDir.clear();
|
|
115
|
+
}
|
package/src/providers/derive.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { CodexAccountMode, OcxProviderConfig } from "../types";
|
|
2
|
+
import { COST_VENDOR_PRIORITY, getModelMetadata, getModelMetadataCaseInsensitive } from "../generated/model-metadata";
|
|
2
3
|
import { cloneFastWire } from "./fastwire";
|
|
4
|
+
import { isAzureModelMetadataDestination, publishedAzureModelMetadata, type AzureModelMetadata } from "./azure-model-metadata";
|
|
3
5
|
import { resolveModelPolicy } from "./resolved-model-policy";
|
|
4
6
|
import {
|
|
5
7
|
PROVIDER_REGISTRY,
|
|
@@ -651,6 +653,25 @@ export function shouldCaseFoldMetadataModelId(providerId: string): boolean {
|
|
|
651
653
|
return entry?.metadataModelIdNormalize === "case-insensitive";
|
|
652
654
|
}
|
|
653
655
|
|
|
656
|
+
const AZURE_VENDOR_METADATA_PRIORITY = [
|
|
657
|
+
"azure-openai",
|
|
658
|
+
...COST_VENDOR_PRIORITY.filter(provider => provider !== "azure-openai"),
|
|
659
|
+
];
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Fill missing Azure model metadata by destination and known model id. Custom provider
|
|
663
|
+
* names miss PROVIDER_ALIASES; arbitrary deployment aliases still need explicit metadata.
|
|
664
|
+
*/
|
|
665
|
+
export function azureVendorModelMetadata(baseUrl: string | undefined, modelId: string, metadataConfigDir?: string): AzureModelMetadata | undefined {
|
|
666
|
+
if (!isAzureModelMetadataDestination(baseUrl)) return undefined;
|
|
667
|
+
const published = publishedAzureModelMetadata(modelId, metadataConfigDir);
|
|
668
|
+
for (const provider of AZURE_VENDOR_METADATA_PRIORITY) {
|
|
669
|
+
const meta = getModelMetadata(provider, modelId) ?? getModelMetadataCaseInsensitive(provider, modelId);
|
|
670
|
+
if (meta) return { ...meta, ...published };
|
|
671
|
+
}
|
|
672
|
+
return published;
|
|
673
|
+
}
|
|
674
|
+
|
|
654
675
|
function entryToPreset(entry: ProviderRegistryEntry): DerivedProviderPreset {
|
|
655
676
|
return {
|
|
656
677
|
id: entry.id,
|
|
@@ -38,6 +38,7 @@ const DEEPSEEK_BASE_URL = "https://api.deepseek.com";
|
|
|
38
38
|
const CLINE_BASE_URL = "https://api.cline.bot";
|
|
39
39
|
const OLLAMA_CLOUD_BASE_URL = "https://ollama.com";
|
|
40
40
|
const OLLAMA_CLOUD_USAGE_URL = `${OLLAMA_CLOUD_BASE_URL}/api/usage`;
|
|
41
|
+
const OLLAMA_CLOUD_BALANCE_URL = `${OLLAMA_CLOUD_BASE_URL}/api/balance`;
|
|
41
42
|
const ZAI_BASE_URL = "https://api.z.ai";
|
|
42
43
|
const ZAI_CN_BASE_URL = "https://open.bigmodel.cn";
|
|
43
44
|
const MINIMAX_REMAINS_PATH = "/v1/api/openplatform/coding_plan/remains";
|
|
@@ -439,11 +440,80 @@ async function fetchClineQuota(provider: string, config: OcxProviderConfig): Pro
|
|
|
439
440
|
}
|
|
440
441
|
|
|
441
442
|
/**
|
|
442
|
-
* Ollama Cloud `GET https://ollama.com/api/
|
|
443
|
-
*
|
|
444
|
-
* `
|
|
445
|
-
* `
|
|
443
|
+
* Ollama Cloud quota. `GET https://ollama.com/api/balance` is the live source: it
|
|
444
|
+
* returns the included (plan) balance plus purchased credits. Legacy plans report
|
|
445
|
+
* rolling 5-hour `included.session.remaining_percent` and 7-day
|
|
446
|
+
* `included.weekly.remaining_percent` (remaining shares, so used = 100 - remaining);
|
|
447
|
+
* credit plans report `included.balance_usd`/`included.allowance_usd` over a
|
|
448
|
+
* `period`.
|
|
449
|
+
*
|
|
450
|
+
* `GET https://ollama.com/api/usage` used to carry `limits.{session,weekly,monthly}.usage`
|
|
451
|
+
* as normalized fractions but now returns only a request-count timeseries, so it is
|
|
452
|
+
* kept as a fallback for older deployments.
|
|
446
453
|
*/
|
|
454
|
+
function parseOllamaUsedPercent(row: Record<string, unknown>): number | undefined {
|
|
455
|
+
if (row.remaining_percent !== undefined) {
|
|
456
|
+
const remaining = toFiniteNumber(row.remaining_percent);
|
|
457
|
+
if (remaining === undefined) return undefined;
|
|
458
|
+
return normalizePercent(Math.round((100 - remaining) * 100) / 100);
|
|
459
|
+
}
|
|
460
|
+
return parseOllamaPercent(row.usage);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export function parseOllamaCloudBalance(body: Record<string, unknown> | null): ProviderQuota | null {
|
|
464
|
+
if (!body) return null;
|
|
465
|
+
const included = asRecord(body.included);
|
|
466
|
+
if (!included) return null;
|
|
467
|
+
|
|
468
|
+
const quota: ProviderQuota = { updatedAt: Date.now() };
|
|
469
|
+
let windows = 0;
|
|
470
|
+
|
|
471
|
+
const rows: Array<[string, "fiveHour" | "weekly" | "monthly"]> = [
|
|
472
|
+
["session", "fiveHour"],
|
|
473
|
+
["weekly", "weekly"],
|
|
474
|
+
["monthly", "monthly"],
|
|
475
|
+
];
|
|
476
|
+
for (const [key, window] of rows) {
|
|
477
|
+
const row = asRecord(included[key]);
|
|
478
|
+
if (!row) continue;
|
|
479
|
+
const percent = parseOllamaUsedPercent(row);
|
|
480
|
+
if (percent === undefined) continue;
|
|
481
|
+
const resetAt = normalizeResetAt(row.resets_at);
|
|
482
|
+
if (window === "fiveHour") {
|
|
483
|
+
quota.fiveHourPercent = percent;
|
|
484
|
+
if (resetAt !== undefined) quota.fiveHourResetAt = resetAt;
|
|
485
|
+
} else if (window === "weekly") {
|
|
486
|
+
quota.weeklyPercent = percent;
|
|
487
|
+
if (resetAt !== undefined) quota.weeklyResetAt = resetAt;
|
|
488
|
+
} else {
|
|
489
|
+
quota.monthlyPercent = percent;
|
|
490
|
+
if (resetAt !== undefined) quota.monthlyResetAt = resetAt;
|
|
491
|
+
}
|
|
492
|
+
windows += 1;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const allowance = toFiniteNumber(included.allowance_usd);
|
|
496
|
+
const balance = toFiniteNumber(included.balance_usd);
|
|
497
|
+
if (allowance !== undefined && allowance > 0 && balance !== undefined) {
|
|
498
|
+
const used = Math.max(0, allowance - balance);
|
|
499
|
+
const percent = normalizePercent(Math.round((used / allowance) * 10000) / 100);
|
|
500
|
+
if (percent !== undefined) {
|
|
501
|
+
const expiresAt = normalizeResetAt(asRecord(included.period)?.until);
|
|
502
|
+
// creditsUsd is the included allowance only; purchased credits are ignored.
|
|
503
|
+
quota.creditsUsd = {
|
|
504
|
+
used,
|
|
505
|
+
limit: allowance,
|
|
506
|
+
remaining: balance,
|
|
507
|
+
percent,
|
|
508
|
+
...(expiresAt !== undefined ? { expiresAt } : {}),
|
|
509
|
+
};
|
|
510
|
+
windows += 1;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return windows > 0 ? quota : null;
|
|
515
|
+
}
|
|
516
|
+
|
|
447
517
|
function parseOllamaPercent(usageValue: unknown): number | undefined {
|
|
448
518
|
const usage = toFiniteNumber(usageValue);
|
|
449
519
|
if (usage === undefined || usage < 0) return undefined;
|
|
@@ -489,25 +559,65 @@ export function parseOllamaCloudQuota(body: Record<string, unknown> | null): Pro
|
|
|
489
559
|
return windows > 0 ? quota : null;
|
|
490
560
|
}
|
|
491
561
|
|
|
562
|
+
/**
|
|
563
|
+
* Routing projection for an `/api/balance` report. `creditsUsd` covers the included
|
|
564
|
+
* allowance only, so it may veto routing only when purchased credit is known to be empty;
|
|
565
|
+
* otherwise an exhausted allowance would block an account that still has paid balance.
|
|
566
|
+
*/
|
|
567
|
+
function ollamaBalanceInferenceQuota(quota: ProviderQuota, body: Record<string, unknown> | null): ProviderQuota {
|
|
568
|
+
if (!quota.creditsUsd) return quota;
|
|
569
|
+
const purchased = toFiniteNumber(asRecord(body?.purchased)?.balance_usd);
|
|
570
|
+
if (purchased !== undefined && purchased <= 0) return quota;
|
|
571
|
+
const { creditsUsd: _includedOnly, ...inference } = quota;
|
|
572
|
+
return inference;
|
|
573
|
+
}
|
|
574
|
+
|
|
492
575
|
async function fetchOllamaCloudQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
|
|
493
576
|
const effectiveBaseUrl = config.baseUrl ?? getProviderRegistryEntry(provider)?.baseUrl ?? "";
|
|
494
577
|
if (!isCanonicalOllamaCloudBaseUrl(effectiveBaseUrl)) return null;
|
|
495
578
|
const apiKey = resolveProviderApiKey(config.apiKey)?.trim();
|
|
496
579
|
if (!apiKey) return null;
|
|
497
|
-
const
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
580
|
+
const attempts: Array<{
|
|
581
|
+
url: string;
|
|
582
|
+
source: string;
|
|
583
|
+
parse: (body: Record<string, unknown> | null) => ProviderQuota | null;
|
|
584
|
+
}> = [
|
|
585
|
+
{ url: OLLAMA_CLOUD_BALANCE_URL, source: "ollama-cloud:balance", parse: parseOllamaCloudBalance },
|
|
586
|
+
{ url: OLLAMA_CLOUD_USAGE_URL, source: "ollama-cloud:usage", parse: parseOllamaCloudQuota },
|
|
587
|
+
];
|
|
588
|
+
let terminalFailure = false;
|
|
589
|
+
for (const attempt of attempts) {
|
|
590
|
+
let response: Response;
|
|
591
|
+
try {
|
|
592
|
+
response = await quotaFetch(provider, config, attempt.url, {
|
|
593
|
+
headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
|
|
594
|
+
redirect: "error",
|
|
595
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
596
|
+
});
|
|
597
|
+
} catch {
|
|
598
|
+
continue;
|
|
599
|
+
}
|
|
600
|
+
if (!response.ok) {
|
|
601
|
+
if (response.status !== 404 && response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) {
|
|
602
|
+
terminalFailure = true;
|
|
603
|
+
}
|
|
604
|
+
try {
|
|
605
|
+
void response.body?.cancel().catch(() => undefined);
|
|
606
|
+
} catch {
|
|
607
|
+
// Best-effort cancellation only.
|
|
608
|
+
}
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
const raw = await readQuotaJson(response);
|
|
612
|
+
if (raw === QUOTA_JSON_READ_FAILURE) return terminalFailure ? TERMINAL_QUOTA_FAILURE : null;
|
|
613
|
+
const body = asRecord(raw);
|
|
614
|
+
const quota = attempt.parse(body);
|
|
615
|
+
if (quota) {
|
|
616
|
+
const inference = attempt.url === OLLAMA_CLOUD_BALANCE_URL ? ollamaBalanceInferenceQuota(quota, body) : quota;
|
|
617
|
+
return keyReport(provider, attempt.source, quota, config, apiKey, inference);
|
|
618
|
+
}
|
|
507
619
|
}
|
|
508
|
-
|
|
509
|
-
const quota = parseOllamaCloudQuota(body);
|
|
510
|
-
return quota ? keyReport(provider, "ollama-cloud:usage", quota, config, apiKey, quota) : null;
|
|
620
|
+
return terminalFailure ? TERMINAL_QUOTA_FAILURE : null;
|
|
511
621
|
}
|
|
512
622
|
|
|
513
623
|
/**
|
package/src/providers/quota.ts
CHANGED
|
@@ -97,7 +97,7 @@ export {
|
|
|
97
97
|
type ProviderAccountQuota,
|
|
98
98
|
} from "./quota/account-cache";
|
|
99
99
|
export { fetchAntigravityUsageQuota, isCanonicalAntigravityQuotaUrl, setAntigravityAccountQuotaTransportForTests } from "./quota/antigravity";
|
|
100
|
-
export { parseOllamaCloudQuota, parseZaiQuotaLimits, providerApiKeyQuotaMode } from "./quota/vendor-probes-key";
|
|
100
|
+
export { parseOllamaCloudBalance, parseOllamaCloudQuota, parseZaiQuotaLimits, providerApiKeyQuotaMode } from "./quota/vendor-probes-key";
|
|
101
101
|
export { parseXaiCreditsResponse } from "./quota/vendor-probes-oauth";
|
|
102
102
|
export async function fetchProviderApiKeyQuotas(config: OcxConfig, name: string, forceRefresh = false): Promise<ProviderApiKeyQuota[]> {
|
|
103
103
|
const provider = config.providers[name];
|
|
@@ -36,6 +36,32 @@ export interface ReasoningEnvelope {
|
|
|
36
36
|
* same way a signature does.
|
|
37
37
|
*/
|
|
38
38
|
krc?: string;
|
|
39
|
+
/**
|
|
40
|
+
* A Responses provider's own `encrypted_content` for one reasoning item, minted while serving
|
|
41
|
+
* `model` (the client-facing model the Claude route was asked for). The Claude route has no
|
|
42
|
+
* other channel to hand a provider its reasoning back: the body is `store: false` and Claude
|
|
43
|
+
* Code replays only the thinking block. `src/claude/inbound.ts` restores it only for a request
|
|
44
|
+
* naming the same model, so a model switch cannot carry it to a provider that cannot decrypt it.
|
|
45
|
+
*/
|
|
46
|
+
nat?: NativeReasoning;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface NativeReasoning {
|
|
50
|
+
/** The provider's opaque `encrypted_content`, verbatim. */
|
|
51
|
+
enc: string;
|
|
52
|
+
/** Client-facing model the item was produced for. */
|
|
53
|
+
model: string;
|
|
54
|
+
/** Route-bound HMAC for this exact ciphertext. */
|
|
55
|
+
tag: string;
|
|
56
|
+
/** The provider's reasoning item id, when it sent one. */
|
|
57
|
+
id?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function decodeNativeReasoning(value: unknown): NativeReasoning | undefined {
|
|
61
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
62
|
+
const { enc, model, id, tag } = value as { enc?: unknown; model?: unknown; id?: unknown; tag?: unknown };
|
|
63
|
+
if (typeof enc !== "string" || enc.length === 0 || typeof model !== "string" || model.length === 0 || typeof tag !== "string" || tag.length === 0) return undefined;
|
|
64
|
+
return typeof id === "string" && id.length > 0 ? { enc, model, tag, id } : { enc, model, tag };
|
|
39
65
|
}
|
|
40
66
|
|
|
41
67
|
export function encodeReasoningEnvelope(envelope: ReasoningEnvelope, budget?: TranslatorBudget): string {
|
|
@@ -116,7 +142,9 @@ export function decodeReasoningEnvelope(encryptedContent: string, budget?: Trans
|
|
|
116
142
|
if (hasTxt) envelope.txt = txt;
|
|
117
143
|
const krc = (parsed as { krc?: unknown }).krc;
|
|
118
144
|
if (typeof krc === "string" && krc.length > 0) envelope.krc = krc;
|
|
119
|
-
|
|
145
|
+
const nat = decodeNativeReasoning((parsed as { nat?: unknown }).nat);
|
|
146
|
+
if (nat) envelope.nat = nat;
|
|
147
|
+
return envelope.sig || envelope.red || hasTxt || envelope.krc || envelope.nat ? envelope : null;
|
|
120
148
|
} catch {
|
|
121
149
|
return null;
|
|
122
150
|
} finally {
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
* long-lived proxy cannot grow without limit.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { createHash, createHmac, randomBytes } from "node:crypto";
|
|
22
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
23
|
+
import { thoughtSignatureReplaySalt } from "./thought-signature-replay";
|
|
23
24
|
import type {
|
|
24
25
|
OcxProviderConfig,
|
|
25
26
|
OcxReasoningReplayIdentity,
|
|
@@ -27,6 +28,21 @@ import type {
|
|
|
27
28
|
} from "../types";
|
|
28
29
|
|
|
29
30
|
const MAX_ENTRIES = 64;
|
|
31
|
+
export interface NativeReasoningOwner { destination: string; credential: string }
|
|
32
|
+
|
|
33
|
+
/** Bind exact ciphertext to the durable route that accepted it. */
|
|
34
|
+
export function nativeReasoningTag(owner: NativeReasoningOwner | undefined, blob: string): string | undefined {
|
|
35
|
+
const salt = thoughtSignatureReplaySalt();
|
|
36
|
+
if (!owner || !salt || salt.length < 16 || blob.length === 0) return undefined;
|
|
37
|
+
return createHmac("sha256", salt).update("native-reasoning\0")
|
|
38
|
+
.update(JSON.stringify([owner.destination, owner.credential, createHash("sha256").update(blob).digest("hex")]))
|
|
39
|
+
.digest("hex");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function nativeReasoningTagMatches(expected: string | undefined, presented: string | undefined): boolean {
|
|
43
|
+
if (!expected || !presented || !/^[a-f0-9]{64}$/.test(expected) || !/^[a-f0-9]{64}$/.test(presented)) return false;
|
|
44
|
+
return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(presented, "hex"));
|
|
45
|
+
}
|
|
30
46
|
const MAX_TOTAL_BYTES = 256 * 1024;
|
|
31
47
|
const TTL_MS = 60 * 60 * 1000;
|
|
32
48
|
const OPAQUE_BLOB_REJECTION_TTL_MS = 5 * 60 * 1000;
|
|
@@ -765,10 +765,23 @@ export function readResponseSpill(responseId: string, ref: ResponseSpillRef): Re
|
|
|
765
765
|
export function deleteResponseSpill(ref: ResponseSpillRef): void {
|
|
766
766
|
if (!validSpillRef(ref)) return;
|
|
767
767
|
const dir = responseSpillDirectory();
|
|
768
|
+
const path = join(dir, ref.fileName);
|
|
768
769
|
try {
|
|
769
|
-
unlink(
|
|
770
|
+
unlink(path);
|
|
770
771
|
fsyncDirectoryBestEffort(dir);
|
|
771
|
-
} catch {
|
|
772
|
+
} catch (error) {
|
|
773
|
+
// Still best effort, but no longer invisible: a file a failed unlink left behind occupies
|
|
774
|
+
// the volume, so the accounting owner keeps pricing it until the path is gone (#6747).
|
|
775
|
+
if (!isErrno(error, "ENOENT")) spillUnlinkFailureObserver?.(path, ref.payloadBytes);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
type SpillUnlinkFailureObserver = (path: string, bytes: number) => void;
|
|
780
|
+
let spillUnlinkFailureObserver: SpillUnlinkFailureObserver | null = null;
|
|
781
|
+
|
|
782
|
+
/** The owner of spill accounting registers here so a failed unlink cannot under-report disk use. */
|
|
783
|
+
export function setResponseSpillUnlinkFailureObserver(next: SpillUnlinkFailureObserver | null): void {
|
|
784
|
+
spillUnlinkFailureObserver = next;
|
|
772
785
|
}
|
|
773
786
|
|
|
774
787
|
type SpillDirNameKind = "spill" | "temp";
|
|
@@ -18,6 +18,10 @@ export interface ResponseStateMetrics {
|
|
|
18
18
|
spillLastWriteFailureOrigin: ResponseSpillWriteFailureOrigin | null;
|
|
19
19
|
spillAclRetryReturnedTimeouts: number;
|
|
20
20
|
spillAclTimeoutMemoRefusals: number;
|
|
21
|
+
/** Publications refused because the durable spill cap could not make room (cumulative). */
|
|
22
|
+
spillCapacityRefusals: number;
|
|
23
|
+
/** Spills evicted by admission to make room for a newer publication (cumulative). */
|
|
24
|
+
spillHeadroomEvictions: number;
|
|
21
25
|
spillLastWriteFailureAt: number | null;
|
|
22
26
|
spillLastWriteSuccessAt: number | null;
|
|
23
27
|
spillReadFailures: number;
|
|
@@ -37,6 +41,8 @@ export function computeResponseStateMetrics(params: {
|
|
|
37
41
|
spillLastSuccessAt: number | null;
|
|
38
42
|
spillAclRetryReturnedTimeouts: number;
|
|
39
43
|
spillAclTimeoutMemoRefusals: number;
|
|
44
|
+
spillCapacityRefusals: number;
|
|
45
|
+
spillHeadroomEvictions: number;
|
|
40
46
|
spillLastFailureAt: number | null;
|
|
41
47
|
spillReadFailures: number;
|
|
42
48
|
replayScopeMismatchDrops: number;
|
|
@@ -79,6 +85,8 @@ export function computeResponseStateMetrics(params: {
|
|
|
79
85
|
spillLastWriteFailureOrigin: params.spillLastFailureOrigin,
|
|
80
86
|
spillAclRetryReturnedTimeouts: params.spillAclRetryReturnedTimeouts,
|
|
81
87
|
spillAclTimeoutMemoRefusals: params.spillAclTimeoutMemoRefusals,
|
|
88
|
+
spillCapacityRefusals: params.spillCapacityRefusals,
|
|
89
|
+
spillHeadroomEvictions: params.spillHeadroomEvictions,
|
|
82
90
|
spillLastWriteFailureAt: params.spillLastFailureAt,
|
|
83
91
|
spillLastWriteSuccessAt: params.spillLastSuccessAt,
|
|
84
92
|
spillReadFailures: params.spillReadFailures,
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
export const spillCounters = {
|
|
2
2
|
writes: 0, writeFailures: 0, readFailures: 0,
|
|
3
3
|
aclRetryReturnedTimeouts: 0, aclTimeoutMemoRefusals: 0,
|
|
4
|
+
capacityRefusals: 0, headroomEvictions: 0,
|
|
4
5
|
};
|
|
5
6
|
|
|
7
|
+
/** Test-only: zero every spill counter, including ones added later. */
|
|
8
|
+
export function resetSpillCountersForTests(): void {
|
|
9
|
+
for (const key of Object.keys(spillCounters) as Array<keyof typeof spillCounters>) spillCounters[key] = 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
6
12
|
export type ResponseSpillWriteFailureCode =
|
|
7
13
|
| "EACLRETRYEXHAUSTED"
|
|
8
14
|
| "ETIMEDOUT"
|
|
@@ -102,6 +108,12 @@ export function noteSpillWriteFailure(
|
|
|
102
108
|
if (origin === "retry_returned_timeout") spillCounters.aclRetryReturnedTimeouts += 1;
|
|
103
109
|
else if (origin === "timeout_memo_refusal") spillCounters.aclTimeoutMemoRefusals += 1;
|
|
104
110
|
}
|
|
111
|
+
|
|
112
|
+
/** A publication refused because the durable spill cap could not make room for it (#6747). */
|
|
113
|
+
export function noteSpillCapacityRefusal(): void {
|
|
114
|
+
spillCounters.capacityRefusals += 1;
|
|
115
|
+
noteSpillWriteFailure(null, "ECAPACITY");
|
|
116
|
+
}
|
|
105
117
|
/**
|
|
106
118
|
* Admission-boundary observability (test-visible). directSpills: oversized
|
|
107
119
|
* candidates routed straight to durable spill without a resident stay or
|
|
@@ -11,12 +11,14 @@ import {
|
|
|
11
11
|
responseSpillNow,
|
|
12
12
|
type ResponseSpillPublicationControl,
|
|
13
13
|
type ResponseSpillRef,
|
|
14
|
+
setResponseSpillUnlinkFailureObserver,
|
|
14
15
|
writeResponseSpillDurably,
|
|
15
16
|
writeResponseSpillDurablyAsync,
|
|
16
17
|
} from "../spill-store";
|
|
17
18
|
import { enforceAppOwnedMemoryBudget } from "../../lib/app-owned-memory";
|
|
18
19
|
import {
|
|
19
20
|
admissionCounters,
|
|
21
|
+
noteSpillCapacityRefusal,
|
|
20
22
|
noteSpillWriteFailure,
|
|
21
23
|
noteSpillWriteSuccess,
|
|
22
24
|
spillAclMemoRefusalOrigin,
|
|
@@ -42,7 +44,8 @@ export interface SpillQueueStore {
|
|
|
42
44
|
pruneResponses(): void;
|
|
43
45
|
accountedResponseSpillBytes(): number;
|
|
44
46
|
spillByteCap(): number;
|
|
45
|
-
|
|
47
|
+
/** No argument: plain cap pruning. A number (0 included): admission reclaim of that headroom. */
|
|
48
|
+
enforceSpilledResponseBudget(requiredHeadroomBytes?: number): number;
|
|
46
49
|
terminalizationMaxPasses(): number;
|
|
47
50
|
}
|
|
48
51
|
|
|
@@ -122,6 +125,8 @@ function chargeUnreclaimableSpillPath(path: string | null | undefined, bytes: nu
|
|
|
122
125
|
if (!path || bytes <= 0) return;
|
|
123
126
|
unreclaimableSpillPaths.set(path, bytes);
|
|
124
127
|
}
|
|
128
|
+
// A failed unlink of an installed or deferred spill leaves a real file too; price it the same way.
|
|
129
|
+
setResponseSpillUnlinkFailureObserver(chargeUnreclaimableSpillPath);
|
|
125
130
|
|
|
126
131
|
/** Drop charges for paths that have since disappeared; returns the surviving total. */
|
|
127
132
|
function reconcileUnreclaimableSpillPaths(): number {
|
|
@@ -286,9 +291,10 @@ export function queuePendingResponseSpill(
|
|
|
286
291
|
// against a total that is short by a whole envelope.
|
|
287
292
|
const inheritedBytes = inheritedSpill?.payloadBytes ?? 0;
|
|
288
293
|
if (requireStore().accountedResponseSpillBytes() + footprint + inheritedBytes > requireStore().spillByteCap()) {
|
|
289
|
-
|
|
294
|
+
// An envelope over the replay ceiling ends in EFBIG anyway; never evict a continuation for it.
|
|
295
|
+
if (footprint / 2 <= responseSpillPayloadCap()) requireStore().enforceSpilledResponseBudget(footprint + inheritedBytes);
|
|
290
296
|
if (requireStore().accountedResponseSpillBytes() + footprint + inheritedBytes > requireStore().spillByteCap()) {
|
|
291
|
-
|
|
297
|
+
noteSpillCapacityRefusal();
|
|
292
298
|
requireStore().replaceWithSpillFailure(id, candidate);
|
|
293
299
|
requireStore().deferSupersededSpill(inheritedSpill);
|
|
294
300
|
return;
|
|
@@ -421,10 +427,11 @@ function installShutdownFallbackSpill(
|
|
|
421
427
|
// over budget. `replaceWithSpillFailure` is the same fail-closed ending the budget
|
|
422
428
|
// exhaustion path uses, so replay reports `spill_failed` and the client resends.
|
|
423
429
|
if (requireStore().accountedResponseSpillBytes() + supersededBytes > requireStore().spillByteCap()) {
|
|
424
|
-
|
|
430
|
+
// The footprint is already reserved above, so only the superseded generation is extra room.
|
|
431
|
+
if (footprint / 2 <= responseSpillPayloadCap()) requireStore().enforceSpilledResponseBudget(supersededBytes);
|
|
425
432
|
if (requireStore().accountedResponseSpillBytes() + supersededBytes > requireStore().spillByteCap()) {
|
|
426
433
|
if (requireStore().currentEntry(job.id) === candidate) {
|
|
427
|
-
|
|
434
|
+
noteSpillCapacityRefusal();
|
|
428
435
|
requireStore().replaceWithSpillFailure(job.id, candidate);
|
|
429
436
|
requireStore().deferSupersededSpill(job.supersededSpill);
|
|
430
437
|
}
|