@bitkyc08/opencodex 2.11.1 → 2.12.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 +1 -1
- package/bin/ocx.mjs +4 -0
- package/gui/dist/assets/index-BNVYzdn0.css +1 -0
- package/gui/dist/assets/index-Cw1_mxO8.js +70 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/command-code.ts +94 -5
- package/src/adapters/cursor/live-transport.ts +14 -1
- package/src/adapters/google-tool-schema.ts +80 -15
- package/src/adapters/google.ts +24 -7
- package/src/adapters/openai-chat.ts +233 -305
- package/src/adapters/openai-responses.ts +62 -0
- package/src/adapters/tool-catalog-nudge.ts +2 -11
- package/src/claude/outbound.ts +42 -14
- package/src/cli/claude.ts +61 -9
- package/src/cli/codex-shim-autorestore.ts +2 -0
- package/src/cli/codex-shim-readiness.ts +8 -1
- package/src/cli/config-command.ts +4 -1
- package/src/cli/doctor.ts +10 -0
- package/src/cli/help.ts +15 -0
- package/src/cli/index.ts +27 -11
- package/src/cli/lab.ts +367 -0
- package/src/cli/provider.ts +9 -1
- package/src/clients/config-export.ts +100 -3
- package/src/codex/app-server-processes.ts +11 -8
- package/src/codex/auth-api.ts +92 -28
- package/src/codex/auth-collision.ts +5 -3
- package/src/codex/catalog/metadata.ts +7 -3
- package/src/codex/catalog/provider-fetch.ts +191 -47
- package/src/codex/catalog.ts +2 -2
- package/src/codex/history-job.ts +83 -18
- package/src/codex/history-migration-guardian.ts +9 -16
- package/src/codex/history-provider.ts +178 -1
- package/src/codex/history-transition.ts +5 -5
- package/src/codex/history-worker.ts +22 -2
- package/src/codex/native-profile-store.ts +1 -1
- package/src/codex/plan.ts +15 -0
- package/src/codex/quota.ts +6 -6
- package/src/codex/routing.ts +3 -3
- package/src/codex/subagent-model-fallback.ts +246 -22
- package/src/codex/user-identity.ts +63 -12
- package/src/config.ts +265 -32
- package/src/images/loop.ts +3 -1
- package/src/images/plan.ts +23 -13
- package/src/integrations/omp-yaml-source.ts +225 -0
- package/src/integrations/ownership.ts +5 -5
- package/src/integrations/state.ts +45 -6
- package/src/integrations/writer.ts +38 -3
- package/src/lab/artifacts/sanitize.ts +586 -0
- package/src/lab/artifacts/secure-fs.ts +475 -0
- package/src/lab/artifacts/store.ts +310 -0
- package/src/lab/conformance/assertion.ts +325 -0
- package/src/lab/conformance/digest.ts +22 -0
- package/src/lab/conformance/executor.ts +741 -0
- package/src/lab/conformance/fixture-provider.ts +27 -0
- package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
- package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
- package/src/lab/conformance/harness-budget.ts +47 -0
- package/src/lab/conformance/index.ts +5 -0
- package/src/lab/conformance/jcs.ts +24 -0
- package/src/lab/conformance/json-pointer.ts +39 -0
- package/src/lab/conformance/manifest.ts +180 -0
- package/src/lab/conformance/mcp-stub.ts +179 -0
- package/src/lab/conformance/negative-controls.ts +160 -0
- package/src/lab/conformance/observation.ts +355 -0
- package/src/lab/conformance/runner.ts +57 -0
- package/src/lab/conformance/sse-normalize.ts +59 -0
- package/src/lab/conformance/suite-manifest.ts +78 -0
- package/src/lab/conformance/types.ts +191 -0
- package/src/lab/constants.ts +126 -0
- package/src/lab/digest.ts +64 -0
- package/src/lab/events/errors.ts +9 -0
- package/src/lab/events/limits.ts +117 -0
- package/src/lab/events/types.ts +228 -0
- package/src/lab/events/validate.ts +780 -0
- package/src/lab/index.ts +36 -0
- package/src/lab/ledger/artifact-refs.ts +127 -0
- package/src/lab/ledger/invalidation.ts +136 -0
- package/src/lab/ledger/purge.ts +241 -0
- package/src/lab/ledger/store.ts +263 -0
- package/src/lab/live/credential-lease.ts +53 -0
- package/src/lab/live/destination.ts +155 -0
- package/src/lab/live/executor.ts +311 -0
- package/src/lab/live/inert-tools.ts +56 -0
- package/src/lab/live/manifest.ts +85 -0
- package/src/lab/live/mcp-loopback.ts +57 -0
- package/src/lab/live/runner.ts +19 -0
- package/src/lab/live/sandbox.ts +61 -0
- package/src/lab/live/suite-manifest.ts +41 -0
- package/src/lab/live/transport.ts +116 -0
- package/src/lab/live/types.ts +197 -0
- package/src/lab/observe/from-conformance.ts +344 -0
- package/src/lab/observe/from-live.ts +103 -0
- package/src/lab/paths.ts +65 -0
- package/src/lab/projection/rebuild.ts +477 -0
- package/src/lab/projection/schema.ts +122 -0
- package/src/lab/projection/verdicts.ts +438 -0
- package/src/lab/projection/verification.ts +332 -0
- package/src/lab/query/catalog.ts +72 -0
- package/src/lab/query/connection.ts +107 -0
- package/src/lab/query/constants.ts +4 -0
- package/src/lab/query/cursor.ts +132 -0
- package/src/lab/query/dto-map.ts +277 -0
- package/src/lab/query/errors.ts +22 -0
- package/src/lab/query/index.ts +34 -0
- package/src/lab/query/queries.ts +444 -0
- package/src/lab/query/types.ts +266 -0
- package/src/lab/subject/behavior-fingerprint.ts +77 -0
- package/src/lab/subject/installation-salt.ts +81 -0
- package/src/lab/subject/route-subject.ts +73 -0
- package/src/lib/bounded-body.ts +104 -0
- package/src/lib/lab-live-execution-authority.ts +13 -0
- package/src/lib/lab-live-host.ts +30 -0
- package/src/lib/lab-live-pinned-sender.ts +45 -0
- package/src/lib/pinned-http.ts +36 -7
- package/src/oauth/index.ts +20 -1
- package/src/oauth/local-token-detect.ts +11 -2
- package/src/oauth/login-cli.ts +38 -4
- package/src/providers/alibaba-region-migration.ts +1 -1
- package/src/providers/codex-capacity.ts +7 -4
- package/src/providers/command-code-efforts.ts +5 -2
- package/src/providers/derive.ts +105 -0
- package/src/providers/label.ts +1 -1
- package/src/providers/openai-tiers.ts +59 -3
- package/src/providers/opencode-zen-rate-limit.ts +102 -0
- package/src/providers/quota.ts +67 -21
- package/src/providers/registry.ts +118 -5
- package/src/router.ts +15 -1
- package/src/routing/analytics.ts +8 -7
- package/src/routing/evaluator.ts +51 -5
- package/src/routing/profile.ts +10 -1
- package/src/routing/trace.ts +42 -6
- package/src/server/auth-cors.ts +15 -0
- package/src/server/background-lifecycle.ts +169 -0
- package/src/server/chat-completions.ts +18 -3
- package/src/server/claude-messages.ts +1 -0
- package/src/server/images.ts +78 -11
- package/src/server/index.ts +72 -18
- package/src/server/lifecycle.ts +6 -0
- package/src/server/live.ts +5 -1
- package/src/server/management/agent-settings-routes.ts +41 -24
- package/src/server/management/body.ts +6 -0
- package/src/server/management/config-routes.ts +51 -8
- package/src/server/management/lab-routes.ts +388 -0
- package/src/server/management/logs-usage-routes.ts +19 -1
- package/src/server/management/model-rows.ts +25 -11
- package/src/server/management/native-integration-routes.ts +3 -3
- package/src/server/management/provider-routes.ts +5 -0
- package/src/server/management/shared.ts +8 -1
- package/src/server/management/usage-summary-cache.ts +2 -0
- package/src/server/management/vision-sidecar-options.ts +125 -0
- package/src/server/management-api.ts +2 -0
- package/src/server/request-decompress.ts +106 -5
- package/src/server/responses/collaboration.ts +4 -1
- package/src/server/responses/core.ts +117 -31
- package/src/server/responses-json-events.ts +50 -12
- package/src/server/search.ts +23 -7
- package/src/server/ws-bridge.ts +3 -1
- package/src/service.ts +40 -20
- package/src/types.ts +74 -0
- package/src/update/job.ts +10 -2
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +9 -0
- package/src/usage/log.ts +9 -9
- package/src/usage/user-cost-overlay-reconciler.ts +313 -0
- package/src/usage/user-cost-overlays.ts +314 -0
- package/src/vision/describe.ts +2 -2
- package/src/vision/eligibility.ts +229 -0
- package/src/vision/index.ts +20 -2
- package/src/web-search/executor.ts +2 -2
- package/src/web-search/index.ts +4 -2
- package/src/web-search/loop.ts +3 -1
- package/src/web-search/parse.ts +72 -3
- package/gui/dist/assets/index-BA1dgo4q.css +0 -1
- package/gui/dist/assets/index-DDZpgzKk.js +0 -70
package/src/routing/profile.ts
CHANGED
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
OcxConfig,
|
|
10
10
|
OcxRoutingProfileConfig,
|
|
11
11
|
OcxRoutingUnknownEvidenceMode,
|
|
12
|
+
OcxRoutingUnknownCostCapMode,
|
|
12
13
|
} from "../types";
|
|
13
14
|
import { codexAccountNamespaceEntries } from "../codex/account-namespaces";
|
|
14
15
|
import { listComboIds, resolveComboId } from "../combos";
|
|
@@ -60,7 +61,7 @@ export interface NormalizedRoutingProfile {
|
|
|
60
61
|
candidates: Array<{ provider: string; model: string }>;
|
|
61
62
|
require: NormalizedRoutingProfileRequirements;
|
|
62
63
|
optimize: { latency: number; health: number; cost: number; quota: number };
|
|
63
|
-
limits: { maxEstimatedCostUsd?: number };
|
|
64
|
+
limits: { maxEstimatedCostUsd?: number; onUnknownCost?: OcxRoutingUnknownCostCapMode };
|
|
64
65
|
unknownEvidence: Record<"capability" | "health" | "quota" | "cost", OcxRoutingUnknownEvidenceMode>;
|
|
65
66
|
revision: string;
|
|
66
67
|
}
|
|
@@ -317,6 +318,11 @@ export function routingProfileIssues(
|
|
|
317
318
|
|| limits.maxEstimatedCostUsd < 0)) {
|
|
318
319
|
issues.push({ path: ["limits", "maxEstimatedCostUsd"], message: "maxEstimatedCostUsd must be a non-negative number" });
|
|
319
320
|
}
|
|
321
|
+
if (limits.onUnknownCost !== undefined
|
|
322
|
+
&& limits.onUnknownCost !== "allow"
|
|
323
|
+
&& limits.onUnknownCost !== "exclude") {
|
|
324
|
+
issues.push({ path: ["limits", "onUnknownCost"], message: 'onUnknownCost must be "allow" or "exclude"' });
|
|
325
|
+
}
|
|
320
326
|
}
|
|
321
327
|
}
|
|
322
328
|
|
|
@@ -402,6 +408,9 @@ export function normalizeRoutingProfile(id: string, raw: OcxRoutingProfileConfig
|
|
|
402
408
|
...(raw.limits?.maxEstimatedCostUsd !== undefined
|
|
403
409
|
? { maxEstimatedCostUsd: raw.limits.maxEstimatedCostUsd }
|
|
404
410
|
: {}),
|
|
411
|
+
...(raw.limits?.onUnknownCost !== undefined
|
|
412
|
+
? { onUnknownCost: raw.limits.onUnknownCost }
|
|
413
|
+
: {}),
|
|
405
414
|
},
|
|
406
415
|
unknownEvidence: normalizedUnknownEvidence(raw),
|
|
407
416
|
};
|
package/src/routing/trace.ts
CHANGED
|
@@ -77,12 +77,29 @@ export interface RouteQuotaEvidence {
|
|
|
77
77
|
source?: string;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Stable wire outcome for `limits.maxEstimatedCostUsd` against this candidate's
|
|
82
|
+
* cost evidence. Present only when a cap is configured. Non-excluding outcomes
|
|
83
|
+
* (`satisfied`, `unknown-allowed`) must not be mirrored into `exclusions`.
|
|
84
|
+
*/
|
|
85
|
+
export type RouteCostCapOutcome =
|
|
86
|
+
| "satisfied"
|
|
87
|
+
| "exceeded"
|
|
88
|
+
| "unknown-allowed"
|
|
89
|
+
| "unknown-excluded";
|
|
90
|
+
|
|
80
91
|
export interface RouteCostEvidence {
|
|
81
92
|
estimatedUsd?: number;
|
|
82
93
|
/** Stable wire code for the price source (e.g. "registry" | "expected"). */
|
|
83
94
|
priceSource?: string;
|
|
84
95
|
incomplete?: boolean;
|
|
85
96
|
limitUsd?: number;
|
|
97
|
+
/**
|
|
98
|
+
* Cap evaluation outcome when `limitUsd` / profile `maxEstimatedCostUsd` is set.
|
|
99
|
+
* Distinguishes "known under the cap" from "unknown cost allowed by policy"
|
|
100
|
+
* without treating the latter as an exclusion.
|
|
101
|
+
*/
|
|
102
|
+
capOutcome?: RouteCostCapOutcome;
|
|
86
103
|
}
|
|
87
104
|
|
|
88
105
|
export interface RouteScoreEvidence {
|
|
@@ -465,13 +482,20 @@ function parseCapability(raw: unknown, caps: ParseCaps): RouteCapabilityEvidence
|
|
|
465
482
|
if (image !== undefined) out.image = image;
|
|
466
483
|
const structuredOutput = unknownable(raw.structuredOutput);
|
|
467
484
|
if (structuredOutput !== undefined) out.structuredOutput = structuredOutput;
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
485
|
+
const rawReasoningEfforts = Array.isArray(raw.reasoningEfforts)
|
|
486
|
+
? raw.reasoningEfforts
|
|
487
|
+
: undefined;
|
|
488
|
+
const reasoningEfforts = rawReasoningEfforts
|
|
489
|
+
? Array.from(
|
|
490
|
+
{ length: Math.min(rawReasoningEfforts.length, 8) },
|
|
491
|
+
(_, index) => rawReasoningEfforts[index],
|
|
492
|
+
)
|
|
493
|
+
: undefined;
|
|
494
|
+
if (reasoningEfforts
|
|
495
|
+
&& reasoningEfforts.every((value): value is string => typeof value === "string")) {
|
|
496
|
+
if (reasoningEfforts.some((value: unknown) => typeof value === "string"
|
|
471
497
|
&& value.length > MAX_TRACE_STRING)) caps.strings = true;
|
|
472
|
-
out.reasoningEfforts =
|
|
473
|
-
.slice(0, 8)
|
|
474
|
-
.map(value => value.slice(0, MAX_TRACE_STRING));
|
|
498
|
+
out.reasoningEfforts = reasoningEfforts.map(value => value.slice(0, MAX_TRACE_STRING));
|
|
475
499
|
}
|
|
476
500
|
if (raw.serviceTier === "unknown") {
|
|
477
501
|
out.serviceTier = "unknown";
|
|
@@ -521,6 +545,13 @@ function parseQuota(raw: unknown, caps: ParseCaps): RouteQuotaEvidence | undefin
|
|
|
521
545
|
return out;
|
|
522
546
|
}
|
|
523
547
|
|
|
548
|
+
const COST_CAP_OUTCOMES = new Set<RouteCostCapOutcome>([
|
|
549
|
+
"satisfied",
|
|
550
|
+
"exceeded",
|
|
551
|
+
"unknown-allowed",
|
|
552
|
+
"unknown-excluded",
|
|
553
|
+
]);
|
|
554
|
+
|
|
524
555
|
/** Whitelisted cost-evidence parse. */
|
|
525
556
|
function parseCost(raw: unknown, caps: ParseCaps): RouteCostEvidence | undefined {
|
|
526
557
|
if (!isPlainRecord(raw)) return undefined;
|
|
@@ -532,6 +563,11 @@ function parseCost(raw: unknown, caps: ParseCaps): RouteCostEvidence | undefined
|
|
|
532
563
|
}
|
|
533
564
|
if (typeof raw.incomplete === "boolean") out.incomplete = raw.incomplete;
|
|
534
565
|
if (finiteNumber(raw.limitUsd)) out.limitUsd = raw.limitUsd;
|
|
566
|
+
if (out.limitUsd !== undefined
|
|
567
|
+
&& typeof raw.capOutcome === "string"
|
|
568
|
+
&& COST_CAP_OUTCOMES.has(raw.capOutcome as RouteCostCapOutcome)) {
|
|
569
|
+
out.capOutcome = raw.capOutcome as RouteCostCapOutcome;
|
|
570
|
+
}
|
|
535
571
|
return Object.keys(out).length > 0 ? out : undefined;
|
|
536
572
|
}
|
|
537
573
|
|
package/src/server/auth-cors.ts
CHANGED
|
@@ -10,8 +10,10 @@ import {
|
|
|
10
10
|
positiveIntegerRecordConfigError,
|
|
11
11
|
providerBaseUrlConfigError,
|
|
12
12
|
providerHeadersConfigError,
|
|
13
|
+
providerModelCostsConfigError,
|
|
13
14
|
reasoningSummaryDeliveryRecordConfigError,
|
|
14
15
|
retryOn429PolicyConfigError,
|
|
16
|
+
sanitizeModelCostsForDisplay,
|
|
15
17
|
} from "../config";
|
|
16
18
|
import { providerDestinationConfigError } from "../lib/destination-policy";
|
|
17
19
|
import { redactSecretString } from "../lib/redact";
|
|
@@ -447,6 +449,9 @@ export function providerManagementConfigError(name: unknown, provider: unknown):
|
|
|
447
449
|
if (seed) seed.codexAccountMode = raw.codexAccountMode;
|
|
448
450
|
const canonicalCandidate = { ...raw };
|
|
449
451
|
delete canonicalCandidate.responsesSnapshotRepair;
|
|
452
|
+
// modelCosts is a user-owned display overlay, not part of the canonical
|
|
453
|
+
// forward seed; it is validated separately below (providerModelCostsConfigError).
|
|
454
|
+
delete canonicalCandidate.modelCosts;
|
|
450
455
|
const canonical = seed && sameCanonicalProviderSeed(canonicalCandidate, seed);
|
|
451
456
|
if (!canonical) {
|
|
452
457
|
return `provider ${name} must equal the canonical built-in provider seed`;
|
|
@@ -471,6 +476,12 @@ export function providerManagementConfigError(name: unknown, provider: unknown):
|
|
|
471
476
|
// it before it reaches the management API response.
|
|
472
477
|
return `provider ${JSON.stringify(redactSecretString(name))} ${retryOn429Error}`;
|
|
473
478
|
}
|
|
479
|
+
const modelCostsError = providerModelCostsConfigError(raw.modelCosts);
|
|
480
|
+
if (modelCostsError) {
|
|
481
|
+
// The provider name is caller-controlled and can be token-shaped; redact and JSON-escape
|
|
482
|
+
// it before it reaches the management API response (same rule as retryOn429 above).
|
|
483
|
+
return `provider ${JSON.stringify(redactSecretString(name))} ${modelCostsError}`;
|
|
484
|
+
}
|
|
474
485
|
const apiKeyTransportError = apiKeyTransportConfigError(typed);
|
|
475
486
|
if (apiKeyTransportError) return `provider ${name} ${apiKeyTransportError}`;
|
|
476
487
|
const maxInputError = positiveIntegerRecordConfigError(raw.modelMaxInputTokens, "modelMaxInputTokens");
|
|
@@ -544,6 +555,7 @@ export function copyIfDefined<K extends keyof OcxProviderConfig>(
|
|
|
544
555
|
if (value !== undefined) out[key as string] = value as unknown;
|
|
545
556
|
}
|
|
546
557
|
|
|
558
|
+
/** Public dashboard DTO for config.json: provider entries with secrets stripped and documented fields exposed (including `modelCosts`). */
|
|
547
559
|
export function safeConfigDTO(config: OcxConfig): unknown {
|
|
548
560
|
const providers: Record<string, Record<string, unknown>> = {};
|
|
549
561
|
for (const [name, provider] of Object.entries(config.providers)) {
|
|
@@ -579,10 +591,13 @@ export function safeConfigDTO(config: OcxConfig): unknown {
|
|
|
579
591
|
"noPenaltyModels",
|
|
580
592
|
"autoToolChoiceOnlyModels",
|
|
581
593
|
"preserveReasoningContentModels",
|
|
594
|
+
"requiresReasoningPlaceholderModels",
|
|
582
595
|
"escapeBuiltinToolNames",
|
|
583
596
|
] as const) {
|
|
584
597
|
copyIfDefined(dto, provider, key);
|
|
585
598
|
}
|
|
599
|
+
const modelCosts = sanitizeModelCostsForDisplay(provider.modelCosts);
|
|
600
|
+
if (modelCosts) dto.modelCosts = modelCosts;
|
|
586
601
|
// Resolve the note by DESTINATION, not by name. A preset saved under a custom name is
|
|
587
602
|
// still pointed at the same vendor route, and a usage restriction the user needs to see
|
|
588
603
|
// must not disappear because the row was renamed. Prefer the same-name entry so an
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { StorageCleanupPolicy } from "../types";
|
|
2
|
+
import { startStateStoreSweeper } from "../lib/state-store-sweeper";
|
|
3
|
+
import {
|
|
4
|
+
abortStorageCleanupPolicyJobAsync,
|
|
5
|
+
setStorageCleanupPolicyJobLiveApply,
|
|
6
|
+
} from "../storage/policy-job";
|
|
7
|
+
import { setStorageCleanupPolicyLiveSink } from "../storage/policy";
|
|
8
|
+
import {
|
|
9
|
+
scheduleStorageCleanupStartupRun,
|
|
10
|
+
startStorageCleanupScheduler,
|
|
11
|
+
stopStorageCleanupScheduler,
|
|
12
|
+
} from "../storage/policy-scheduler";
|
|
13
|
+
import {
|
|
14
|
+
cancelQueuedStorageWorkerSpawns,
|
|
15
|
+
drainStorageWorkers,
|
|
16
|
+
} from "../storage/worker-lifecycle";
|
|
17
|
+
import {
|
|
18
|
+
startMemoryWatchdog,
|
|
19
|
+
type MemoryWatchdog,
|
|
20
|
+
} from "./memory-watchdog";
|
|
21
|
+
|
|
22
|
+
type PolicyApply = (policy: StorageCleanupPolicy) => void;
|
|
23
|
+
|
|
24
|
+
type ProcessLoops = {
|
|
25
|
+
memoryWatchdog: MemoryWatchdog;
|
|
26
|
+
stateStoreSweeper: ReturnType<typeof startStateStoreSweeper>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type LeaseOwner = {
|
|
30
|
+
token: symbol;
|
|
31
|
+
applyPolicy: PolicyApply;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type ServerBackgroundLifecycleLease = {
|
|
35
|
+
scheduleStartupRun(): void;
|
|
36
|
+
release(): Promise<void>;
|
|
37
|
+
releaseAfterFailedStart(): void;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const owners: LeaseOwner[] = [];
|
|
41
|
+
let processLoops: ProcessLoops | null = null;
|
|
42
|
+
let cleanupInProgress = false;
|
|
43
|
+
|
|
44
|
+
function setLivePolicyOwner(applyPolicy: PolicyApply | null): void {
|
|
45
|
+
setStorageCleanupPolicyLiveSink(applyPolicy);
|
|
46
|
+
setStorageCleanupPolicyJobLiveApply(applyPolicy);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function startProcessLoops(applyPolicy: PolicyApply): ProcessLoops {
|
|
50
|
+
let memoryWatchdog: MemoryWatchdog | null = null;
|
|
51
|
+
let stateStoreSweeper: ReturnType<typeof startStateStoreSweeper> | null = null;
|
|
52
|
+
try {
|
|
53
|
+
memoryWatchdog = startMemoryWatchdog();
|
|
54
|
+
stateStoreSweeper = startStateStoreSweeper();
|
|
55
|
+
setLivePolicyOwner(applyPolicy);
|
|
56
|
+
startStorageCleanupScheduler();
|
|
57
|
+
return { memoryWatchdog, stateStoreSweeper };
|
|
58
|
+
} catch (error) {
|
|
59
|
+
memoryWatchdog?.stop();
|
|
60
|
+
stateStoreSweeper?.stop();
|
|
61
|
+
stopStorageCleanupScheduler();
|
|
62
|
+
setLivePolicyOwner(null);
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function stopProcessLoops(): void {
|
|
68
|
+
const loops = processLoops;
|
|
69
|
+
processLoops = null;
|
|
70
|
+
loops?.memoryWatchdog.stop();
|
|
71
|
+
loops?.stateStoreSweeper.stop();
|
|
72
|
+
stopStorageCleanupScheduler();
|
|
73
|
+
setLivePolicyOwner(null);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function stopStoragePolicyWorker(): Promise<void> {
|
|
77
|
+
cancelQueuedStorageWorkerSpawns();
|
|
78
|
+
const abortResult = await Promise.allSettled([abortStorageCleanupPolicyJobAsync()]);
|
|
79
|
+
if (abortResult[0]?.status === "rejected") {
|
|
80
|
+
console.warn(
|
|
81
|
+
"[storage] policy worker abort during server stop failed:",
|
|
82
|
+
abortResult[0].reason instanceof Error
|
|
83
|
+
? abortResult[0].reason.message
|
|
84
|
+
: abortResult[0].reason,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
await drainStorageWorkers();
|
|
89
|
+
} catch (error) {
|
|
90
|
+
console.warn(
|
|
91
|
+
"[storage] worker drain during server stop failed:",
|
|
92
|
+
error instanceof Error ? error.message : error,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function removeOwner(owner: LeaseOwner): boolean {
|
|
98
|
+
const index = owners.findIndex(candidate => candidate.token === owner.token);
|
|
99
|
+
if (index === -1) return false;
|
|
100
|
+
owners.splice(index, 1);
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function releaseOwnerSynchronously(owner: LeaseOwner): "inactive" | "shared" | "last" {
|
|
105
|
+
if (!removeOwner(owner)) return "inactive";
|
|
106
|
+
const nextOwner = owners.at(-1);
|
|
107
|
+
if (nextOwner) {
|
|
108
|
+
setLivePolicyOwner(nextOwner.applyPolicy);
|
|
109
|
+
return "shared";
|
|
110
|
+
}
|
|
111
|
+
stopProcessLoops();
|
|
112
|
+
return "last";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Acquire one server's lease on process-wide singleton loops.
|
|
117
|
+
*
|
|
118
|
+
* The first live server starts the loops. Later servers only add a reference and
|
|
119
|
+
* become the current policy sink; out-of-order release restores the newest
|
|
120
|
+
* remaining sink. The final release owns timer and Worker teardown.
|
|
121
|
+
*/
|
|
122
|
+
export function acquireServerBackgroundLifecycle(
|
|
123
|
+
applyPolicy: PolicyApply,
|
|
124
|
+
): ServerBackgroundLifecycleLease {
|
|
125
|
+
if (cleanupInProgress) {
|
|
126
|
+
throw new Error("server background lifecycle cleanup is still in progress");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const owner: LeaseOwner = {
|
|
130
|
+
token: Symbol("server-background-lifecycle"),
|
|
131
|
+
applyPolicy,
|
|
132
|
+
};
|
|
133
|
+
if (!processLoops) {
|
|
134
|
+
processLoops = startProcessLoops(applyPolicy);
|
|
135
|
+
} else {
|
|
136
|
+
setLivePolicyOwner(applyPolicy);
|
|
137
|
+
}
|
|
138
|
+
owners.push(owner);
|
|
139
|
+
|
|
140
|
+
let releaseFlight: Promise<void> | null = null;
|
|
141
|
+
return {
|
|
142
|
+
scheduleStartupRun() {
|
|
143
|
+
if (owners.some(candidate => candidate.token === owner.token)) {
|
|
144
|
+
scheduleStorageCleanupStartupRun();
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
release() {
|
|
148
|
+
if (releaseFlight) return releaseFlight;
|
|
149
|
+
const outcome = releaseOwnerSynchronously(owner);
|
|
150
|
+
if (outcome !== "last") {
|
|
151
|
+
releaseFlight = Promise.resolve();
|
|
152
|
+
return releaseFlight;
|
|
153
|
+
}
|
|
154
|
+
cleanupInProgress = true;
|
|
155
|
+
releaseFlight = stopStoragePolicyWorker().finally(() => {
|
|
156
|
+
cleanupInProgress = false;
|
|
157
|
+
});
|
|
158
|
+
return releaseFlight;
|
|
159
|
+
},
|
|
160
|
+
releaseAfterFailedStart() {
|
|
161
|
+
if (releaseFlight) return;
|
|
162
|
+
// Startup evaluation is scheduled only after both listeners bind, so a
|
|
163
|
+
// failed start cannot have spawned a Worker for this lease. Keep rollback
|
|
164
|
+
// synchronous so a caller may immediately retry a different port.
|
|
165
|
+
releaseOwnerSynchronously(owner);
|
|
166
|
+
releaseFlight = Promise.resolve();
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -85,7 +85,6 @@ async function handleChatCompletionsWithBudget(
|
|
|
85
85
|
try {
|
|
86
86
|
chatBody = await readChatBody(req, translatorBudget);
|
|
87
87
|
internalBody = chatCompletionsToResponsesBody(chatBody);
|
|
88
|
-
translatorBudget.chargeRetained(new TextEncoder().encode(JSON.stringify(internalBody)).byteLength, { kind: "request_copies" });
|
|
89
88
|
} catch (err) {
|
|
90
89
|
const overflow = isTranslatorBudgetExceededError(err);
|
|
91
90
|
const status = overflow ? 413 : err instanceof ChatCompletionsRequestError ? 400 : 500;
|
|
@@ -180,8 +179,24 @@ async function handleChatCompletionsWithBudget(
|
|
|
180
179
|
}
|
|
181
180
|
}
|
|
182
181
|
|
|
183
|
-
|
|
184
|
-
|
|
182
|
+
let internalBodyJson: string;
|
|
183
|
+
try {
|
|
184
|
+
internalBodyJson = JSON.stringify(internalBody);
|
|
185
|
+
translatorBudget.chargeRetained(
|
|
186
|
+
new TextEncoder().encode(internalBodyJson).byteLength,
|
|
187
|
+
{ kind: "request_copies" },
|
|
188
|
+
);
|
|
189
|
+
} catch (err) {
|
|
190
|
+
const overflow = isTranslatorBudgetExceededError(err);
|
|
191
|
+
const status = overflow ? 413 : 500;
|
|
192
|
+
if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, { closeReason: "non_stream" });
|
|
193
|
+
return chatCompletionsErrorResponse(
|
|
194
|
+
status,
|
|
195
|
+
overflow ? "request translation buffer exceeded the safe limit" : err instanceof Error ? err.message : String(err),
|
|
196
|
+
overflow ? "request_too_large" : undefined,
|
|
197
|
+
overflow ? "translation_buffer_limit" : undefined,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
185
200
|
const internalReq = new Request("http://localhost/v1/responses", {
|
|
186
201
|
method: "POST",
|
|
187
202
|
headers,
|
|
@@ -732,6 +732,7 @@ async function handleClaudeMessagesWithBudget(
|
|
|
732
732
|
// Without this the replay would look native and a Responses-scoped wire default
|
|
733
733
|
// would fire, disagreeing with the pre-flight decision above.
|
|
734
734
|
inboundWire: "anthropic",
|
|
735
|
+
stripClaudeMainAuthForNoncanonicalForward: true,
|
|
735
736
|
translatorBudget,
|
|
736
737
|
...(logIds ? { onFirstOutput: () => recordFirstOutput(logCtx, logIds.start) } : {}),
|
|
737
738
|
onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForTerminalStatus(status), { terminalStatus: status, closeReason: "terminal" }),
|
package/src/server/images.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from "../codex/auth-context";
|
|
25
25
|
import { formatCodexProviderForLog } from "../codex/routing";
|
|
26
26
|
import { signalWithTimeout } from "../lib/abort";
|
|
27
|
+
import { readBoundedResponseBytes, type BoundedBytesResult } from "../lib/bounded-body";
|
|
27
28
|
import { sidecarEnter } from "../lib/sidecar-tracker";
|
|
28
29
|
import type { OcxConfig } from "../types";
|
|
29
30
|
import { resolveFirstUsableOpenAiSidecar, selectImagesProvider } from "../providers/openai-sidecar";
|
|
@@ -50,7 +51,56 @@ const IMAGES_UPSTREAM_TIMEOUT_MS = 300_000;
|
|
|
50
51
|
* containing base64-encoded images — typically a few MB. This prevents an oversized or malicious
|
|
51
52
|
* response from exhausting process memory.
|
|
52
53
|
*/
|
|
53
|
-
const IMAGES_RESPONSE_MAX_BYTES = 100 * 1024 * 1024;
|
|
54
|
+
export const IMAGES_RESPONSE_MAX_BYTES = 100 * 1024 * 1024;
|
|
55
|
+
|
|
56
|
+
interface ImageResponseBytesOptions {
|
|
57
|
+
signal?: AbortSignal;
|
|
58
|
+
/** Smaller values are used only by focused reader tests. */
|
|
59
|
+
maxBytes?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function cancelUnlockedResponseBody(response: Response | undefined, reason?: unknown): void {
|
|
63
|
+
const body = response?.body;
|
|
64
|
+
if (!body || body.locked) return;
|
|
65
|
+
try {
|
|
66
|
+
void body.cancel(reason).catch(() => undefined);
|
|
67
|
+
} catch {
|
|
68
|
+
// A broken stream can throw synchronously from cancel().
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Read one image relay response as exact raw bytes under the same cap used by
|
|
74
|
+
* the public handler. A trustworthy Content-Length can reject obvious excess
|
|
75
|
+
* before attaching a reader; the streaming reader remains authoritative when
|
|
76
|
+
* the header is absent or understated.
|
|
77
|
+
*/
|
|
78
|
+
export async function readImageResponseBytes(
|
|
79
|
+
response: Response,
|
|
80
|
+
options: ImageResponseBytesOptions = {},
|
|
81
|
+
): Promise<BoundedBytesResult> {
|
|
82
|
+
const signal = options.signal;
|
|
83
|
+
if (signal?.aborted) {
|
|
84
|
+
cancelUnlockedResponseBody(response, signal.reason);
|
|
85
|
+
throw signal.reason;
|
|
86
|
+
}
|
|
87
|
+
if (response.body === null) {
|
|
88
|
+
return { bytes: new Uint8Array(0), oversized: false };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const maxBytes = options.maxBytes ?? IMAGES_RESPONSE_MAX_BYTES;
|
|
92
|
+
const contentLength = response.headers.get("content-length");
|
|
93
|
+
const declaredBytes = contentLength === null ? Number.NaN : Number(contentLength);
|
|
94
|
+
if (Number.isSafeInteger(declaredBytes) && declaredBytes > maxBytes) {
|
|
95
|
+
cancelUnlockedResponseBody(
|
|
96
|
+
response,
|
|
97
|
+
new DOMException("Response body size limit reached", "QuotaExceededError"),
|
|
98
|
+
);
|
|
99
|
+
return { bytes: new Uint8Array(0), oversized: true };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return readBoundedResponseBytes(response, { maxBytes, signal });
|
|
103
|
+
}
|
|
54
104
|
|
|
55
105
|
const CCA_IMAGE_MODEL = "gemini-3.1-flash-image";
|
|
56
106
|
|
|
@@ -440,34 +490,47 @@ export async function handleImages(
|
|
|
440
490
|
const timeoutMs = config.images?.timeoutMs ?? IMAGES_UPSTREAM_TIMEOUT_MS;
|
|
441
491
|
const linkedSignal = signalWithTimeout(timeoutMs, req.signal);
|
|
442
492
|
const sidecarExit = sidecarEnter("images");
|
|
493
|
+
let upstreamResponse: Response | undefined;
|
|
443
494
|
try {
|
|
444
495
|
// Images POSTs create paid, non-idempotent work. One fetch only: no reset retry without a
|
|
445
496
|
// source-proven idempotency contract.
|
|
446
|
-
|
|
497
|
+
upstreamResponse = await fetch(url, {
|
|
447
498
|
method: "POST",
|
|
448
499
|
headers,
|
|
449
500
|
body: JSON.stringify(body),
|
|
450
501
|
signal: linkedSignal.signal,
|
|
451
502
|
});
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
if (
|
|
457
|
-
|
|
503
|
+
const observed = await readImageResponseBytes(upstreamResponse, {
|
|
504
|
+
maxBytes: IMAGES_RESPONSE_MAX_BYTES,
|
|
505
|
+
signal: linkedSignal.signal,
|
|
506
|
+
});
|
|
507
|
+
if (observed.oversized) {
|
|
508
|
+
forward?.recordOutcome?.(upstreamResponse.status);
|
|
509
|
+
return formatErrorResponse(
|
|
510
|
+
502,
|
|
511
|
+
"upstream_error",
|
|
512
|
+
`image ${endpoint} response too large (exceeded ${IMAGES_RESPONSE_MAX_BYTES} bytes)`,
|
|
513
|
+
);
|
|
458
514
|
}
|
|
459
|
-
forward?.recordOutcome?.(upstreamResponse.status);
|
|
460
515
|
const relayHeaders: Record<string, string> = {};
|
|
461
516
|
const contentType = upstreamResponse.headers.get("content-type");
|
|
462
517
|
if (contentType) relayHeaders["content-type"] = contentType;
|
|
463
|
-
|
|
518
|
+
// Fetch represents 204/205/304 responses with a null body. Preserve that
|
|
519
|
+
// invariant: constructing those statuses with even an empty Uint8Array throws.
|
|
520
|
+
const relayBody = upstreamResponse.body === null ? null : observed.bytes;
|
|
521
|
+
const relayResponse = new Response(relayBody, {
|
|
522
|
+
status: upstreamResponse.status,
|
|
523
|
+
headers: relayHeaders,
|
|
524
|
+
});
|
|
525
|
+
forward?.recordOutcome?.(upstreamResponse.status);
|
|
526
|
+
return relayResponse;
|
|
464
527
|
} catch (err) {
|
|
465
528
|
// Client cancel first: it aborts the linked signal too, and must not be logged as an
|
|
466
529
|
// upstream failure (499 maps to client_closed_request in the request log).
|
|
467
530
|
if (req.signal.aborted) {
|
|
468
531
|
return formatErrorResponse(499, "client_closed_request", `image ${endpoint} request canceled by client`);
|
|
469
532
|
}
|
|
470
|
-
if (err instanceof Error && err.name === "TimeoutError") {
|
|
533
|
+
if (linkedSignal.signal.aborted || (err instanceof Error && err.name === "TimeoutError")) {
|
|
471
534
|
forward?.recordOutcome?.("timeout");
|
|
472
535
|
// codex retries 5xx up to 4 more times; a retried 504 is acceptable for a transient hang.
|
|
473
536
|
return formatErrorResponse(504, "upstream_error", `image ${endpoint} upstream timed out`);
|
|
@@ -481,5 +544,9 @@ export async function handleImages(
|
|
|
481
544
|
} finally {
|
|
482
545
|
sidecarExit();
|
|
483
546
|
linkedSignal.cleanup();
|
|
547
|
+
// If cancellation won before readImageResponseBytes attached its reader, the
|
|
548
|
+
// response still owns an upstream body/socket. Consumed or locked bodies are
|
|
549
|
+
// already owned by the reader and need no second cancellation.
|
|
550
|
+
cancelUnlockedResponseBody(upstreamResponse);
|
|
484
551
|
}
|
|
485
552
|
}
|