@bitkyc08/opencodex 2.60.0 → 2.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS_INSTALL.md +64 -0
- package/README.md +28 -1
- package/bin/ocx.mjs +382 -209
- package/gui/dist/assets/App-CH6C5H7x.js +50 -0
- package/gui/dist/assets/Tray-CLZh48fM.js +1 -0
- package/gui/dist/assets/index-_bpvxJu0.css +1 -0
- package/gui/dist/assets/index-wpTOyepx.js +86 -0
- package/gui/dist/assets/usage-companion-chart-a0N58rRI.js +1 -0
- package/gui/dist/favicon.png +0 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/stepfun-color.svg +1 -0
- package/package.json +5 -1
- package/src/adapters/anthropic.ts +16 -0
- package/src/adapters/coding-agent/protocol.ts +36 -6
- package/src/adapters/coding-agent/turn.ts +10 -2
- package/src/adapters/command-code.ts +2 -1
- package/src/adapters/cursor/catalog.ts +51 -7
- package/src/adapters/cursor/protobuf-request.ts +6 -3
- package/src/adapters/cursor/request-builder.ts +13 -3
- package/src/adapters/cursor.ts +11 -2
- package/src/adapters/declaration-carrier.ts +45 -0
- package/src/adapters/devin.ts +75 -23
- package/src/adapters/google-antigravity-wire.ts +5 -2
- package/src/adapters/google-errors.ts +7 -1
- package/src/adapters/google.ts +29 -5
- package/src/adapters/image.ts +4 -1
- package/src/adapters/input-media-guard.ts +21 -9
- package/src/adapters/kiro/usage.ts +3 -2
- package/src/adapters/kiro-tool-fallback.ts +1 -1
- package/src/adapters/ollama-native.ts +6 -0
- package/src/adapters/openai-chat/developer-role.ts +61 -0
- package/src/adapters/openai-chat/messages.ts +46 -27
- package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
- package/src/adapters/openai-chat/passthrough.ts +33 -9
- package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
- package/src/adapters/openai-chat.ts +18 -57
- package/src/adapters/openai-responses/passthrough.ts +2 -0
- package/src/adapters/registry.ts +3 -2
- package/src/adapters/run-turn-queue.ts +178 -29
- package/src/adapters/xai-web-search.ts +16 -1
- package/src/bridge/errors.ts +8 -2
- package/src/bridge/response-json.ts +9 -1
- package/src/bridge/sse.ts +10 -0
- package/src/chat/inbound.ts +141 -5
- package/src/claude/desktop-3p.ts +7 -1
- package/src/claude/desktop-first-party.ts +183 -0
- package/src/claude/desktop-gateway-state.ts +41 -0
- package/src/claude/inbound-content-options.ts +6 -0
- package/src/claude/inbound.ts +32 -6
- package/src/claude/intercept/connect-proxy.ts +179 -0
- package/src/claude/intercept/listener.ts +122 -0
- package/src/claude/intercept/local-ca.ts +298 -0
- package/src/claude/intercept/runtime.ts +98 -0
- package/src/claude/intercept/settings.ts +189 -0
- package/src/cli/access.ts +87 -0
- package/src/cli/account-auth.ts +19 -0
- package/src/cli/capabilities.ts +31 -0
- package/src/cli/claude-desktop.ts +206 -16
- package/src/cli/codex-shim-autorestore.ts +3 -0
- package/src/cli/companion.ts +56 -0
- package/src/cli/dispatch.ts +43 -4
- package/src/cli/ensure-desired-integrations.ts +43 -5
- package/src/cli/help.ts +7 -9
- package/src/cli/index.ts +200 -61
- package/src/cli/init.ts +8 -0
- package/src/cli/integrations.ts +7 -1
- package/src/cli/registry.ts +41 -2
- package/src/cli/resolve.ts +230 -0
- package/src/cli/root.ts +24 -1
- package/src/cli/start-ownership-publication.ts +56 -0
- package/src/cli/status-probes.ts +2 -18
- package/src/cli/status.ts +62 -0
- package/src/cli/stop-report.ts +143 -0
- package/src/cli/uninstall-plan.ts +9 -0
- package/src/client/machine-listener.ts +2 -5
- package/src/clients/aside-profiles.ts +4 -0
- package/src/clients/config-export/zcode-store.ts +157 -0
- package/src/clients/config-export.ts +36 -0
- package/src/codex/app-server-processes.ts +72 -40
- package/src/codex/auth-api/login-flow.ts +6 -1
- package/src/codex/autostart-health.ts +28 -0
- package/src/codex/catalog/build-entries.ts +2 -2
- package/src/codex/catalog/effort.ts +3 -3
- package/src/codex/catalog/provider-models.ts +24 -15
- package/src/codex/catalog/retained-sync.ts +2 -2
- package/src/codex/convergence.ts +2 -2
- package/src/codex/history-provider.ts +12 -1
- package/src/codex/inject/config-toml.ts +41 -6
- package/src/codex/inject/paginated-openai-compat.ts +90 -0
- package/src/codex/inject.ts +18 -15
- package/src/codex/injected-marker.ts +18 -0
- package/src/codex/main-account.ts +6 -0
- package/src/codex/model-cache.ts +52 -6
- package/src/codex/model-entitlement-admission.ts +59 -0
- package/src/codex/model-entitlements.ts +87 -44
- package/src/codex/native-main-admission.ts +83 -0
- package/src/codex/routing/health-store.ts +39 -0
- package/src/codex/routing/selection.ts +37 -1
- package/src/codex/routing.ts +5 -41
- package/src/codex/shim-templates.ts +29 -3
- package/src/companion/settings.ts +132 -0
- package/src/config/atomic-write.ts +117 -5
- package/src/config/load-degrade.ts +34 -7
- package/src/config/process-state.ts +1 -1
- package/src/config/schema/config-schema.ts +27 -1
- package/src/config/schema/leaf-validators.ts +47 -0
- package/src/config.ts +1 -1
- package/src/generated/compatibility-version.json +418 -174
- package/src/integrations/config-io.ts +44 -10
- package/src/integrations/merge.ts +120 -13
- package/src/integrations/mutation-plan.ts +124 -18
- package/src/integrations/registry.ts +38 -0
- package/src/integrations/state.ts +78 -45
- package/src/integrations/target.ts +208 -0
- package/src/integrations/writer.ts +49 -11
- package/src/lab/conformance/fixture-provider.ts +5 -0
- package/src/lib/browser-launch-notice.ts +59 -0
- package/src/lib/bun-runtime.ts +6 -2
- package/src/lib/debug.ts +40 -0
- package/src/lib/open-url.ts +51 -7
- package/src/lib/package-tree-integrity.ts +2 -1
- package/src/lib/package-version.ts +8 -0
- package/src/lib/provider-egress.ts +310 -0
- package/src/lib/provider-outbound.ts +59 -14
- package/src/lib/proxy-env.ts +82 -7
- package/src/lib/request-execution-budget.ts +72 -0
- package/src/lib/request-failure-attribution.ts +183 -0
- package/src/lib/request-failure-model.ts +236 -0
- package/src/lib/request-resend-gate.ts +138 -0
- package/src/lib/standalone.ts +16 -0
- package/src/lib/upstream-retry.ts +167 -16
- package/src/lib/winsw.ts +2 -2
- package/src/oauth/index.ts +24 -1
- package/src/oauth/login-cli.ts +80 -29
- package/src/providers/api-key-resolve.ts +133 -0
- package/src/providers/api-key-selection.ts +5 -1
- package/src/providers/key-failover.ts +31 -1
- package/src/providers/key-store.ts +34 -110
- package/src/providers/model-rename-fields.ts +147 -0
- package/src/providers/model-rename-migration.ts +124 -37
- package/src/providers/quota/vendor-probes-key.ts +37 -22
- package/src/providers/reasoning-metadata.ts +43 -18
- package/src/providers/registry/entries-core.ts +9 -4
- package/src/providers/registry/entries-extended.ts +29 -4
- package/src/providers/registry/model-seeds.ts +47 -10
- package/src/providers/xai-transport.ts +12 -1
- package/src/reasoning-effort.ts +8 -0
- package/src/responses/function-call-compat.ts +38 -1
- package/src/responses/inline-document.ts +65 -0
- package/src/responses/input-media.ts +42 -8
- package/src/responses/muse-tool-name-alias.ts +19 -0
- package/src/responses/parser-content.ts +8 -2
- package/src/responses/parser-tools.ts +3 -0
- package/src/responses/parser.ts +3 -1
- package/src/responses/schema.ts +3 -0
- package/src/router.ts +17 -2
- package/src/server/admission-model-scope.ts +219 -0
- package/src/server/audio-live.ts +9 -3
- package/src/server/audio-upstream.ts +18 -0
- package/src/server/auth-cors.ts +26 -0
- package/src/server/chat-completions.ts +55 -2
- package/src/server/chat-native.ts +19 -4
- package/src/server/claude-messages.ts +55 -17
- package/src/server/grok-responses-snapshot-repair.ts +113 -11
- package/src/server/gui-freshness.ts +103 -0
- package/src/server/gui-static.ts +7 -9
- package/src/server/images.ts +59 -6
- package/src/server/index/claude-intercept-lifecycle.ts +49 -0
- package/src/server/index/serve-options.ts +56 -10
- package/src/server/index/spend-ledger-lifecycle.ts +34 -8
- package/src/server/index/startup-warnings.ts +24 -0
- package/src/server/index.ts +21 -28
- package/src/server/lifecycle.ts +4 -4
- package/src/server/live-call-bindings.ts +6 -0
- package/src/server/live.ts +88 -3
- package/src/server/management/agent-settings-routes.ts +121 -36
- package/src/server/management/companion-routes.ts +77 -0
- package/src/server/management/logs-usage-routes.ts +19 -0
- package/src/server/management/native-integration-routes.ts +103 -6
- package/src/server/management/oauth-account-routes.ts +45 -7
- package/src/server/management/route-registry.ts +6 -0
- package/src/server/management/shared.ts +18 -1
- package/src/server/management/usage-timeline-routes.ts +44 -0
- package/src/server/management-api.ts +8 -9
- package/src/server/proxy-liveness.ts +75 -0
- package/src/server/relay.ts +19 -2
- package/src/server/request-log-failure-attribution.ts +99 -0
- package/src/server/request-log.ts +114 -0
- package/src/server/request-metrics.ts +92 -30
- package/src/server/responses/codex-ws-wire.ts +34 -8
- package/src/server/responses/combo-stream-preflight.ts +168 -6
- package/src/server/responses/compact.ts +11 -0
- package/src/server/responses/core-opaque-recovery.ts +90 -0
- package/src/server/responses/fetch-helpers.ts +124 -8
- package/src/server/responses/input-admission.ts +10 -0
- package/src/server/responses/passthrough-delivery.ts +14 -1
- package/src/server/responses/passthrough-dispatch.ts +179 -35
- package/src/server/responses/passthrough-error.ts +27 -8
- package/src/server/responses/request-prepare.ts +42 -1
- package/src/server/responses/request-send-budget.ts +12 -0
- package/src/server/responses/request-transport.ts +24 -4
- package/src/server/responses/reset-replay.ts +108 -0
- package/src/server/responses-request-tool-scope.ts +214 -0
- package/src/server/responses-undeclared-tool-guard.ts +4 -1
- package/src/server/search.ts +25 -1
- package/src/server/usage-ledger-retention.ts +73 -0
- package/src/service/cli.ts +48 -2
- package/src/service/health.ts +3 -2
- package/src/service/install-state-contract.d.mts +27 -0
- package/src/service/install-state-contract.mjs +34 -0
- package/src/service/launchd.ts +1 -1
- package/src/service/orchestration.ts +2 -4
- package/src/service/ownership-compatibility.ts +164 -0
- package/src/service/ownership-mutation-lease.d.mts +32 -0
- package/src/service/ownership-mutation-lease.mjs +211 -0
- package/src/service/repair.ts +45 -1
- package/src/service/state-lock.ts +269 -0
- package/src/service/state-record.d.mts +36 -0
- package/src/service/state-record.mjs +138 -0
- package/src/service/state.ts +582 -68
- package/src/service/windows-taskxml.ts +11 -10
- package/src/service.ts +7 -3
- package/src/tray/windows-tray.ps1 +1 -1
- package/src/types/config.ts +37 -0
- package/src/types/provider.ts +73 -0
- package/src/types/request.ts +28 -2
- package/src/types/tools.ts +19 -0
- package/src/types.ts +3 -0
- package/src/update/index.ts +207 -63
- package/src/update/job.ts +9 -5
- package/src/update/ownership-transaction.ts +47 -0
- package/src/update/restart-ownership.ts +54 -0
- package/src/update/runtime-ownership.d.mts +40 -0
- package/src/update/runtime-ownership.mjs +122 -0
- package/src/usage/attempt-delivery.ts +198 -0
- package/src/usage/cache-diagnostic.ts +305 -0
- package/src/usage/failure-fingerprint.ts +118 -0
- package/src/usage/failure-projection-cache.ts +174 -0
- package/src/usage/failure-projection.ts +174 -0
- package/src/usage/ledger-retention.ts +165 -0
- package/src/usage/log.ts +126 -79
- package/src/usage/request-outcome.ts +150 -0
- package/src/usage/retention-contract.ts +28 -0
- package/src/usage/summary.ts +2 -2
- package/src/usage/telemetry-contract.ts +237 -0
- package/src/usage/timeline.ts +236 -0
- package/src/web-search/alpha-search.ts +21 -1
- package/gui/dist/assets/index-BTuCbqQd.css +0 -1
- package/gui/dist/assets/index-DoBVdPHP.js +0 -134
|
@@ -52,7 +52,7 @@ import { parseRequest } from "../../responses/parser";
|
|
|
52
52
|
import { anthropicSessionKeyFromParts } from "../../oauth/anthropic-routing";
|
|
53
53
|
import { isTranslatorBudgetExceededError } from "../../lib/translator-budget";
|
|
54
54
|
import { bindTurnTerminationScope, rememberDeliveredFinalAnswer } from "../../responses/turn-termination";
|
|
55
|
-
import { requestLogSpeedLabel, readConfiguredCodexServiceTier } from "../request-log";
|
|
55
|
+
import { observeCacheDiagnosticInbound, rebindCacheDiagnosticBody, requestLogSpeedLabel, readConfiguredCodexServiceTier } from "../request-log";
|
|
56
56
|
import type { RouteResult } from "../../router";
|
|
57
57
|
import {
|
|
58
58
|
captureRouteStaticPolicy,
|
|
@@ -110,6 +110,13 @@ import {
|
|
|
110
110
|
CODEX_RESERVE_OPT_IN_REQUIRED_MESSAGE,
|
|
111
111
|
} from "../../codex/loopback-target";
|
|
112
112
|
import { checkComboTargetInputAdmission, checkInputAdmission } from "./input-admission";
|
|
113
|
+
import {
|
|
114
|
+
admissionModelDeniedResponse,
|
|
115
|
+
AdmissionModelDeniedError,
|
|
116
|
+
assertRouteAllowedByScope,
|
|
117
|
+
resolveAdmissionModelScope,
|
|
118
|
+
routeAllowedByScope,
|
|
119
|
+
} from "../admission-model-scope";
|
|
113
120
|
import { nativeContextLimits } from "../../codex/catalog";
|
|
114
121
|
import { streamingContextOverflowResponse } from "./context-overflow";
|
|
115
122
|
import {
|
|
@@ -150,6 +157,14 @@ export async function prepareResponsesRequest(
|
|
|
150
157
|
}
|
|
151
158
|
return decodeRequestErrorResponse(err, "responses");
|
|
152
159
|
}
|
|
160
|
+
observeCacheDiagnosticInbound(
|
|
161
|
+
logCtx,
|
|
162
|
+
body,
|
|
163
|
+
req.headers,
|
|
164
|
+
options.promptCacheKeyIsSharedCohort === true
|
|
165
|
+
? "system-derived"
|
|
166
|
+
: options.promptCacheKeyIsSharedCohort === false ? "metadata-derived" : "caller",
|
|
167
|
+
);
|
|
153
168
|
if (!options.comboAttempt && !options.compactionRoutingOverride && inboundWire === "responses") {
|
|
154
169
|
options.compactionRoutingOverride = applyCompactionRoutingOverride(body, req.headers, config, {
|
|
155
170
|
endpoint: "responses",
|
|
@@ -293,6 +308,10 @@ export async function prepareResponsesRequest(
|
|
|
293
308
|
try {
|
|
294
309
|
parsed = parseRequest(body);
|
|
295
310
|
parsed._promptCacheKeyIsSharedCohort = options.promptCacheKeyIsSharedCohort;
|
|
311
|
+
// The body may have been rebuilt since the inbound observation (previous-response
|
|
312
|
+
// expansion); alias the parsed raw body to the same draft so the outbound
|
|
313
|
+
// observation at the adapter seam still finds it.
|
|
314
|
+
rebindCacheDiagnosticBody(parsed._rawBody, logCtx.cacheDiagnosticDraft);
|
|
296
315
|
// Captured before any parser mutates it, so both grammars see the client's id.
|
|
297
316
|
const { fastRow, effortRow } = parseSyntheticRowId(parsed.modelId, config);
|
|
298
317
|
if (fastRow) {
|
|
@@ -411,7 +430,18 @@ export async function prepareResponsesRequest(
|
|
|
411
430
|
|
|
412
431
|
let route: RouteResult;
|
|
413
432
|
let credentialDomainWasRewritten = false;
|
|
433
|
+
// The selector the caller actually sent, captured before shadow interception
|
|
434
|
+
// or a subagent fallback rewrites it, so a refusal names the client's own
|
|
435
|
+
// request rather than a destination it never asked for.
|
|
436
|
+
const inboundSelector = parsed.modelId;
|
|
437
|
+
const admissionScope = resolveAdmissionModelScope(config, options.admission);
|
|
414
438
|
const captureInboundRoutePolicy = (candidate: RouteResult): RouteResult => {
|
|
439
|
+
// Every route this request path produces passes through here: the direct
|
|
440
|
+
// name, an alias, a policy or combo selection, a compaction override, a
|
|
441
|
+
// shadow-intercept target and both subagent-fallback re-routes. Checking
|
|
442
|
+
// the key's scope at this one point is what stops a rewrite from reaching
|
|
443
|
+
// a destination the front door would have refused.
|
|
444
|
+
assertRouteAllowedByScope(admissionScope, inboundSelector, candidate);
|
|
415
445
|
candidate.staticPolicy = captureRouteStaticPolicy(
|
|
416
446
|
candidate.providerName,
|
|
417
447
|
candidate.modelId,
|
|
@@ -474,6 +504,7 @@ export async function prepareResponsesRequest(
|
|
|
474
504
|
}
|
|
475
505
|
logCtx.routeDecision = route.routeDecision;
|
|
476
506
|
} catch (err) {
|
|
507
|
+
if (err instanceof AdmissionModelDeniedError) return admissionModelDeniedResponse(err);
|
|
477
508
|
if (err instanceof NoAvailableComboTargetsError) {
|
|
478
509
|
return comboUnavailable(err.comboId);
|
|
479
510
|
}
|
|
@@ -673,6 +704,7 @@ export async function prepareResponsesRequest(
|
|
|
673
704
|
credentialDomainWasRewritten = true;
|
|
674
705
|
logCtx.routeDecision = route.routeDecision;
|
|
675
706
|
} catch (err) {
|
|
707
|
+
if (err instanceof AdmissionModelDeniedError) return admissionModelDeniedResponse(err);
|
|
676
708
|
if (err instanceof NoAvailableComboTargetsError) {
|
|
677
709
|
return comboUnavailable(err.comboId);
|
|
678
710
|
}
|
|
@@ -873,6 +905,7 @@ export async function prepareResponsesRequest(
|
|
|
873
905
|
credentialDomainWasRewritten = true;
|
|
874
906
|
logCtx.routeDecision = route.routeDecision;
|
|
875
907
|
} catch (err) {
|
|
908
|
+
if (err instanceof AdmissionModelDeniedError) return admissionModelDeniedResponse(err);
|
|
876
909
|
if (err instanceof NoAvailableComboTargetsError) {
|
|
877
910
|
return comboUnavailable(err.comboId);
|
|
878
911
|
}
|
|
@@ -1021,6 +1054,14 @@ export async function prepareResponsesRequest(
|
|
|
1021
1054
|
inboundTransport: options.inboundTransport,
|
|
1022
1055
|
claudeGoAffinity: options.claudeGoAffinity,
|
|
1023
1056
|
});
|
|
1057
|
+
// Normalization is the last thing that can move the destination: resolving an
|
|
1058
|
+
// OpenAI virtual model rewrites route.modelId to the wire id that will
|
|
1059
|
+
// actually be billed. A scope checked only before this would authorize the
|
|
1060
|
+
// public selector and send the wire model, so the settled route is checked
|
|
1061
|
+
// once more here.
|
|
1062
|
+
if (!routeAllowedByScope(admissionScope, route)) {
|
|
1063
|
+
return admissionModelDeniedResponse(new AdmissionModelDeniedError(inboundSelector, route));
|
|
1064
|
+
}
|
|
1024
1065
|
// Attribute local auth/cooldown failures to the public selector too; exact auth may fail before
|
|
1025
1066
|
// the normal post-resolution provider label is assigned.
|
|
1026
1067
|
if (route.codexAccountNamespace) {
|
|
@@ -139,6 +139,16 @@ export function createResponsesSendBudget(
|
|
|
139
139
|
*/
|
|
140
140
|
const sendBudgetExhausted = (cap: number = TRANSIENT_RETRY_MAX_ATTEMPTS): boolean =>
|
|
141
141
|
remainingTransientSendBudget(cap) === 0;
|
|
142
|
+
/**
|
|
143
|
+
* Spend one operator-granted replacement for an ambiguous failure of THIS logical request.
|
|
144
|
+
*
|
|
145
|
+
* The counter is the execution budget's, so a combo child that derives its own scope draws on
|
|
146
|
+
* the same grant. A budget that predates it -- a stub, or a caller that passed the narrow
|
|
147
|
+
* holder -- cannot grant anything, and refusing is the fail-closed answer for a send whose
|
|
148
|
+
* upstream state is unknown.
|
|
149
|
+
*/
|
|
150
|
+
const claimAmbiguousResend = (limit: number): boolean =>
|
|
151
|
+
isRequestExecutionBudget(sendBudget) && sendBudget.claimAmbiguousResend?.(limit) === true;
|
|
142
152
|
/**
|
|
143
153
|
* A credential hop reserves the send its own replay will make, and that replay is a recovery
|
|
144
154
|
* leg. The leg must SPEND the hop's reservation instead of taking a second one: the
|
|
@@ -263,6 +273,7 @@ export function createResponsesSendBudget(
|
|
|
263
273
|
noteAdapterPhysicalSend,
|
|
264
274
|
noteAdapterRecoveryWithheld,
|
|
265
275
|
sendBudgetExhausted,
|
|
276
|
+
claimAmbiguousResend,
|
|
266
277
|
get pendingHopPermit(): SingleUseDispatchPermit | undefined {
|
|
267
278
|
return pendingHopPermit;
|
|
268
279
|
},
|
|
@@ -301,6 +312,7 @@ function adapterDispatchBudgetView(
|
|
|
301
312
|
get targetTransitions(): number { return budget.targetTransitions; },
|
|
302
313
|
get lastTargetKey(): string | undefined { return budget.lastTargetKey; },
|
|
303
314
|
remainingBaseSends: (cap: number): number => budget.remainingBaseSends(cap),
|
|
315
|
+
claimAmbiguousResend: (limit: number): boolean => budget.claimAmbiguousResend?.(limit) === true,
|
|
304
316
|
reserveDispatch(intent: DispatchIntent): DispatchDecision {
|
|
305
317
|
// A dispatch whose upstream state is unknown is refused on its own merits. A hop that
|
|
306
318
|
// already paid does not make an unsafe replay safe, so that check stays with the budget.
|
|
@@ -64,6 +64,7 @@ import {
|
|
|
64
64
|
recordKeyAttemptUsage,
|
|
65
65
|
} from "../request-log";
|
|
66
66
|
import type { AttemptRecoveryKind } from "../../usage/log";
|
|
67
|
+
import { bindAttemptDeliveryRecorder } from "../../usage/attempt-delivery";
|
|
67
68
|
import { resolvePassiveRouteSubjectId } from "../passive-route-linker";
|
|
68
69
|
|
|
69
70
|
/** Owns live credential selection and adapter bindings for one request. */
|
|
@@ -298,15 +299,25 @@ export async function prepareResponsesTransport(
|
|
|
298
299
|
recordKeyAttemptUsage(logCtx, event.usage);
|
|
299
300
|
}
|
|
300
301
|
};
|
|
302
|
+
// Counted at the one seam every adapter parse passes, and counted for EVERY event rather
|
|
303
|
+
// than only usage-bearing ones: the number this pairs with is the frame count the client
|
|
304
|
+
// transport relayed, and a difference between the two is the loss signal (#3983). Reading
|
|
305
|
+
// the current attempt through logCtx rather than capturing one keeps the count with the
|
|
306
|
+
// attempt that is live when the event arrives, across a mid-request attempt rotation.
|
|
307
|
+
const delivery = bindAttemptDeliveryRecorder(translatorBudget, () => logCtx.activeAttempt);
|
|
308
|
+
const observeEvent = (event: AdapterEvent, response: object): void => {
|
|
309
|
+
delivery.noteAdapterEvent();
|
|
310
|
+
observeUsage(event, response);
|
|
311
|
+
};
|
|
301
312
|
const parseStream = resolved.parseStream.bind(resolved);
|
|
302
313
|
resolved.parseStream = async function* (...args) {
|
|
303
|
-
for await (const event of parseStream(...args)) {
|
|
314
|
+
for await (const event of parseStream(...args)) { observeEvent(event, args[0]); yield event; }
|
|
304
315
|
};
|
|
305
316
|
if (resolved.parseResponse) {
|
|
306
317
|
const parseResponse = resolved.parseResponse.bind(resolved);
|
|
307
318
|
resolved.parseResponse = async (...args) => {
|
|
308
319
|
const events = await parseResponse(...args);
|
|
309
|
-
events.forEach(event =>
|
|
320
|
+
events.forEach(event => observeEvent(event, args[0]));
|
|
310
321
|
return events;
|
|
311
322
|
};
|
|
312
323
|
}
|
|
@@ -321,7 +332,7 @@ export async function prepareResponsesTransport(
|
|
|
321
332
|
const runTurn = resolved.runTurn.bind(resolved);
|
|
322
333
|
rawRunTurns.set(resolved, (requestParsed, incoming, emit) => {
|
|
323
334
|
const response = {};
|
|
324
|
-
return runTurn(requestParsed, incoming, event => {
|
|
335
|
+
return runTurn(requestParsed, incoming, event => { observeEvent(event, response); emit(event); });
|
|
325
336
|
});
|
|
326
337
|
resolved.runTurn = (requestParsed, incoming, emit) => runSelectedTurn(resolved, requestParsed, incoming, emit);
|
|
327
338
|
}
|
|
@@ -409,7 +420,16 @@ export async function prepareResponsesTransport(
|
|
|
409
420
|
// Either way the send crosses the physical boundary, so the connection policy is
|
|
410
421
|
// applied around whichever implementation was just selected (#4992).
|
|
411
422
|
commitKeyAttemptSend();
|
|
412
|
-
|
|
423
|
+
// The binding travels with the send, so a rebuilt request resolves its provider route
|
|
424
|
+
// against the destination it is actually going to rather than the one this dispatch
|
|
425
|
+
// started with. Account reselection can move the upstream host, which would otherwise
|
|
426
|
+
// apply a host-scoped decision to a different host.
|
|
427
|
+
const response = await sendWithConnectionPolicy(
|
|
428
|
+
fetchImpl,
|
|
429
|
+
destination,
|
|
430
|
+
{ ...dispatchInit, redirect: "manual" },
|
|
431
|
+
{ providerName: route.providerName, provider: route.provider },
|
|
432
|
+
);
|
|
413
433
|
if (!response.ok) await recordKeyAttemptFailure(logCtx, response, dispatchInit.signal ?? options.abortSignal);
|
|
414
434
|
// Observe each physical response before retries replace it. The binding belongs to
|
|
415
435
|
// this dispatch, so a manual switch cannot file A's headers against B. Header
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The operator opt-in that overrides the stage table's refusal, and the one request-wide
|
|
3
|
+
* allowance both ambiguous stages claim from.
|
|
4
|
+
*
|
|
5
|
+
* `resendPermission` answers `refused-ambiguous` for a native Responses send that died with
|
|
6
|
+
* the caller having observed nothing -- before the response head, or after it while the SSE
|
|
7
|
+
* body carried only control events. This module is the one place that answer is overridden,
|
|
8
|
+
* and it is narrow on three axes at once: the provider has to opt in, the request has to be
|
|
9
|
+
* one whose second send cannot do more than run the same inference again, and the whole
|
|
10
|
+
* logical request gets a fixed number of replacements no matter how many legs ask.
|
|
11
|
+
*
|
|
12
|
+
* The judgment is made on the inbound body the client sent, which is already parsed. It is
|
|
13
|
+
* conservative for the outbound request: the proxy expands `previous_response_id` and lowers
|
|
14
|
+
* hosted tools into client execution, so every hazard that reaches the wire was visible here,
|
|
15
|
+
* and a hazard visible here may already have been removed. A cheap fail-closed answer beats
|
|
16
|
+
* re-parsing a multi-megabyte outbound body on every send.
|
|
17
|
+
*/
|
|
18
|
+
import type { OcxProviderConfig } from "../../types";
|
|
19
|
+
import { resetReplayPolicyFor } from "../../providers/key-failover";
|
|
20
|
+
import type { AmbiguousResendAllowance } from "../../lib/request-resend-gate";
|
|
21
|
+
|
|
22
|
+
/** Input items a client owns end to end: replaying them re-runs nothing but the model. */
|
|
23
|
+
const CLIENT_INPUT_ITEM_TYPES: ReadonlySet<string> = new Set([
|
|
24
|
+
"message", "reasoning", "compaction",
|
|
25
|
+
"function_call", "function_call_output",
|
|
26
|
+
"custom_tool_call", "custom_tool_call_output",
|
|
27
|
+
"tool_search_call",
|
|
28
|
+
]);
|
|
29
|
+
const MESSAGE_ROLES: ReadonlySet<string> = new Set(["user", "assistant", "system", "developer"]);
|
|
30
|
+
/** Bounded traversal: a catalog is operator data, not a reason to walk forever. */
|
|
31
|
+
const MAX_TOOL_ENTRIES = 4096;
|
|
32
|
+
const MAX_TOOL_DEPTH = 4;
|
|
33
|
+
|
|
34
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
35
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* True when every tool in the catalog is executed by the client. Hosted tools (`web_search`,
|
|
40
|
+
* `mcp`, `code_interpreter`, ...) run on the origin during the turn, so an unknown or hosted
|
|
41
|
+
* type fails the whole catalog rather than being skipped: a tool this proxy does not
|
|
42
|
+
* recognise is a tool it cannot vouch for.
|
|
43
|
+
*/
|
|
44
|
+
function clientExecutedTools(tools: unknown, budget: { remaining: number }, depth = 0): boolean {
|
|
45
|
+
if (!Array.isArray(tools) || depth > MAX_TOOL_DEPTH) return false;
|
|
46
|
+
return tools.every(tool => {
|
|
47
|
+
budget.remaining -= 1;
|
|
48
|
+
if (budget.remaining < 0 || !record(tool)) return false;
|
|
49
|
+
if (tool.type === "function" || tool.type === "custom") return true;
|
|
50
|
+
if (tool.type === "tool_search") return tool.execution === "client";
|
|
51
|
+
return tool.type === "namespace" && typeof tool.name === "string"
|
|
52
|
+
&& clientExecutedTools(tool.tools, budget, depth + 1);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A Responses body whose second send can only repeat the inference: nothing stored, no
|
|
58
|
+
* server-side continuation state, complete input, and only client-executed tools. Deferred
|
|
59
|
+
* tool declarations inside `input` are checked by the same rule as the root catalog, so a
|
|
60
|
+
* hosted tool cannot ride in through `additional_tools` or a `tool_search_output`.
|
|
61
|
+
*/
|
|
62
|
+
export function selfContainedResponsesBody(body: unknown): boolean {
|
|
63
|
+
if (!record(body)) return false;
|
|
64
|
+
if (body.store !== false || body.background === true) return false;
|
|
65
|
+
if (body.previous_response_id != null || body.conversation != null || Object.hasOwn(body, "stream_id")) return false;
|
|
66
|
+
const input = body.input;
|
|
67
|
+
if (typeof input !== "string" && !Array.isArray(input)) return false;
|
|
68
|
+
const budget = { remaining: MAX_TOOL_ENTRIES };
|
|
69
|
+
if (body.tools !== undefined && !clientExecutedTools(body.tools, budget)) return false;
|
|
70
|
+
if (typeof input === "string") return true;
|
|
71
|
+
return input.every(item => {
|
|
72
|
+
if (!record(item)) return false;
|
|
73
|
+
if (item.type === "additional_tools" || item.type === "tool_search_output") {
|
|
74
|
+
return clientExecutedTools(item.tools, budget);
|
|
75
|
+
}
|
|
76
|
+
if (item.type === undefined) return typeof item.role === "string" && MESSAGE_ROLES.has(item.role);
|
|
77
|
+
return typeof item.type === "string" && CLIENT_INPUT_ITEM_TYPES.has(item.type);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The allowance for ONE logical request, or nothing when the provider did not opt in.
|
|
83
|
+
*
|
|
84
|
+
* Built once per request and handed to every leg. `claim` spends the request's counter, which
|
|
85
|
+
* lives on the execution budget and is therefore shared with a combo child's derived scope --
|
|
86
|
+
* that sharing is the reason the pre-header helper takes a callback instead of a number.
|
|
87
|
+
*
|
|
88
|
+
* The body judgment is carried rather than applied here, because the gate has to be able to
|
|
89
|
+
* say WHY it refused: "the operator granted nothing" and "this request cannot be replayed" are
|
|
90
|
+
* different operator problems, and folding them together is what made the old refusal a single
|
|
91
|
+
* undifferentiated no.
|
|
92
|
+
*
|
|
93
|
+
* `selfContained` is a predicate rather than a body, and the getter below is why: a provider
|
|
94
|
+
* that never opted in must not pay to walk the input array, and the caller memoizes one answer
|
|
95
|
+
* across every leg of the request.
|
|
96
|
+
*/
|
|
97
|
+
export function ambiguousResendAllowanceFor(
|
|
98
|
+
provider: Pick<OcxProviderConfig, "retryOnReset">,
|
|
99
|
+
requestIsSelfContained: () => boolean,
|
|
100
|
+
claim: (limit: number) => boolean,
|
|
101
|
+
): AmbiguousResendAllowance | undefined {
|
|
102
|
+
const policy = resetReplayPolicyFor(provider);
|
|
103
|
+
if (policy === null) return undefined;
|
|
104
|
+
return {
|
|
105
|
+
get selfContained(): boolean { return requestIsSelfContained(); },
|
|
106
|
+
claim: () => claim(policy.replacements),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tool selection a Responses request actually authorized, read from the final outbound body.
|
|
3
|
+
*
|
|
4
|
+
* The undeclared-tool guard answers whether a NAME was declared. This answers a different
|
|
5
|
+
* question: whether this request still permits a client tool call at all, and which names it
|
|
6
|
+
* permits. `tool_choice: "none"`, a forced selector and an `allowed_tools` allow-list each narrow
|
|
7
|
+
* the catalog without removing a declaration, so a name can be declared and forbidden at the same
|
|
8
|
+
* time — and a repair that rebuilds a terminal from collected items would otherwise hand the
|
|
9
|
+
* client a call the caller ruled out.
|
|
10
|
+
*
|
|
11
|
+
* The scope is read from the OUTBOUND body, after every removal, rename and translation, because
|
|
12
|
+
* that is the request the destination answered. A catalog that ends up empty there authorizes no
|
|
13
|
+
* client call whatever the selector still says.
|
|
14
|
+
*/
|
|
15
|
+
import type { MuseToolNameAliases } from "../responses/muse-tool-name-alias";
|
|
16
|
+
import { museWireNameForOriginal } from "../responses/muse-tool-name-alias";
|
|
17
|
+
import type {
|
|
18
|
+
RoutedNamespaceToolAliases,
|
|
19
|
+
RoutedNamespaceToolIdentity,
|
|
20
|
+
} from "../responses/namespace-tool-compat";
|
|
21
|
+
import {
|
|
22
|
+
CLIENT_EXECUTED_CALL_TYPES,
|
|
23
|
+
collectDeclaredWireToolNames,
|
|
24
|
+
hasExplicitWireToolCatalog,
|
|
25
|
+
} from "./responses-undeclared-tool-guard";
|
|
26
|
+
import { isPlainObject } from "./responses-snapshot-codec";
|
|
27
|
+
|
|
28
|
+
type ToolKind = "function" | "custom";
|
|
29
|
+
const BUILTIN_FUNCTIONS_NAMESPACE = "functions";
|
|
30
|
+
|
|
31
|
+
type ToolIdentity = Readonly<{
|
|
32
|
+
kind: ToolKind;
|
|
33
|
+
name: string;
|
|
34
|
+
namespace?: string;
|
|
35
|
+
}>;
|
|
36
|
+
|
|
37
|
+
export type RequestToolScopeCorrespondence = Readonly<{
|
|
38
|
+
clientToolAuthorizationBody?: unknown;
|
|
39
|
+
routedNamespaceToolAliases?: RoutedNamespaceToolAliases;
|
|
40
|
+
routedMuseToolNameAliases?: MuseToolNameAliases;
|
|
41
|
+
convertedRoutedCustomToolNames?: ReadonlySet<string>;
|
|
42
|
+
}>;
|
|
43
|
+
|
|
44
|
+
function identityKey(identity: ToolIdentity): string {
|
|
45
|
+
return JSON.stringify([identity.kind, identity.namespace ?? null, identity.name]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function clientNamespace(value: unknown): string | undefined {
|
|
49
|
+
return typeof value === "string" && value.length > 0 && value !== BUILTIN_FUNCTIONS_NAMESPACE
|
|
50
|
+
? value
|
|
51
|
+
: undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function selectorIdentity(selector: unknown): ToolIdentity | undefined {
|
|
55
|
+
if (!isPlainObject(selector)) return undefined;
|
|
56
|
+
if (selector.type !== "function" && selector.type !== "custom") return undefined;
|
|
57
|
+
if (typeof selector.name !== "string" || selector.name.length === 0) return undefined;
|
|
58
|
+
if ("namespace" in selector && typeof selector.namespace !== "string") return undefined;
|
|
59
|
+
const namespace = clientNamespace(selector.namespace);
|
|
60
|
+
return { kind: selector.type, name: selector.name, ...(namespace ? { namespace } : {}) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function callIdentity(item: Record<string, unknown>): ToolIdentity | undefined {
|
|
64
|
+
const kind = item.type === "function_call"
|
|
65
|
+
? "function"
|
|
66
|
+
: item.type === "custom_tool_call"
|
|
67
|
+
? "custom"
|
|
68
|
+
: undefined;
|
|
69
|
+
if (!kind || typeof item.name !== "string" || item.name.length === 0) return undefined;
|
|
70
|
+
const namespace = clientNamespace(item.namespace);
|
|
71
|
+
return { kind, name: item.name, ...(namespace ? { namespace } : {}) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function sameRestoredIdentity(left: ToolIdentity, right: RoutedNamespaceToolIdentity): boolean {
|
|
75
|
+
return left.namespace === right.namespace
|
|
76
|
+
&& left.name === right.name
|
|
77
|
+
&& left.kind === right.kind;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Exact identities this restored call could have used on the final outbound wire.
|
|
82
|
+
*
|
|
83
|
+
* Namespace spellings come only from namespace-tool-compat's request-scoped, ambiguity-checked
|
|
84
|
+
* aliases. Muse aliases then compose over those wire names. A kind change is admitted only when
|
|
85
|
+
* the request recorded that exact custom identity as converted to a function.
|
|
86
|
+
*/
|
|
87
|
+
function outboundCallIdentities(
|
|
88
|
+
item: Record<string, unknown>,
|
|
89
|
+
correspondence: RequestToolScopeCorrespondence,
|
|
90
|
+
): ReadonlySet<string> {
|
|
91
|
+
const restored = callIdentity(item);
|
|
92
|
+
if (!restored) return new Set();
|
|
93
|
+
const keys = new Set<string>([identityKey(restored)]);
|
|
94
|
+
const museAliases = correspondence.routedMuseToolNameAliases ?? new Map();
|
|
95
|
+
const convertedCustom = correspondence.convertedRoutedCustomToolNames ?? new Set();
|
|
96
|
+
const originalSelection = isPlainObject(correspondence.clientToolAuthorizationBody)
|
|
97
|
+
? toolSelection(correspondence.clientToolAuthorizationBody)
|
|
98
|
+
: UNRESTRICTED;
|
|
99
|
+
const originalSelectionAllowsRestored = originalSelection.kind === "allow"
|
|
100
|
+
&& originalSelection.identities.has(identityKey(restored));
|
|
101
|
+
|
|
102
|
+
const addWireIdentity = (
|
|
103
|
+
preMuseName: string,
|
|
104
|
+
clientKind: ToolKind,
|
|
105
|
+
conversionIdentityVerified: boolean,
|
|
106
|
+
): void => {
|
|
107
|
+
const name = museWireNameForOriginal(preMuseName, museAliases);
|
|
108
|
+
if (name === undefined) return;
|
|
109
|
+
const kind = clientKind === "custom"
|
|
110
|
+
&& convertedCustom.has(preMuseName)
|
|
111
|
+
&& conversionIdentityVerified
|
|
112
|
+
? "function"
|
|
113
|
+
: clientKind;
|
|
114
|
+
keys.add(identityKey({ kind, name }));
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
if (restored.namespace === undefined) {
|
|
118
|
+
addWireIdentity(restored.name, restored.kind, originalSelectionAllowsRestored);
|
|
119
|
+
}
|
|
120
|
+
for (const [wireName, identity] of correspondence.routedNamespaceToolAliases ?? new Map()) {
|
|
121
|
+
// namespace-tool-compat emits only aliases authorized under the outbound selector's kind,
|
|
122
|
+
// then restores `custom` provenance from the request's conversion set. That exact alias edge is
|
|
123
|
+
// already the proof that this custom-to-function transition belongs to this identity.
|
|
124
|
+
if (sameRestoredIdentity(restored, identity)) addWireIdentity(wireName, identity.kind, true);
|
|
125
|
+
}
|
|
126
|
+
return keys;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
type ToolSelection =
|
|
130
|
+
| { readonly kind: "unrestricted" }
|
|
131
|
+
| { readonly kind: "deny_all" }
|
|
132
|
+
| { readonly kind: "allow"; readonly identities: ReadonlySet<string> };
|
|
133
|
+
|
|
134
|
+
const UNRESTRICTED: ToolSelection = { kind: "unrestricted" };
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Read the selector only where it states a client-call boundary.
|
|
138
|
+
*
|
|
139
|
+
* `auto`, `required` and an absent selector restrict nothing. A hosted selector
|
|
140
|
+
* (`{ type: "web_search" }`) forces a tool the PROVIDER runs and does not describe the client
|
|
141
|
+
* calls this turn may contain, so it is left alone rather than read as a deny-all: a false
|
|
142
|
+
* refusal would drop a call the caller could have executed.
|
|
143
|
+
*/
|
|
144
|
+
function toolSelection(body: Record<string, unknown>): ToolSelection {
|
|
145
|
+
const choice = body.tool_choice;
|
|
146
|
+
if (choice === "none") return { kind: "deny_all" };
|
|
147
|
+
if (!isPlainObject(choice)) return UNRESTRICTED;
|
|
148
|
+
if (choice.type === "allowed_tools") {
|
|
149
|
+
const identities = new Set<string>();
|
|
150
|
+
if (!Array.isArray(choice.tools)) return { kind: "allow", identities };
|
|
151
|
+
for (const entry of choice.tools) {
|
|
152
|
+
const identity = selectorIdentity(entry);
|
|
153
|
+
if (identity) identities.add(identityKey(identity));
|
|
154
|
+
}
|
|
155
|
+
// An allow-list carrying no client tool — emptied by normalization, or hosted entries only —
|
|
156
|
+
// still bounds this turn: it allows no client call.
|
|
157
|
+
return { kind: "allow", identities };
|
|
158
|
+
}
|
|
159
|
+
if (choice.type === "function" || choice.type === "custom") {
|
|
160
|
+
const identity = selectorIdentity(choice);
|
|
161
|
+
return {
|
|
162
|
+
kind: "allow",
|
|
163
|
+
identities: new Set(identity ? [identityKey(identity)] : []),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
return UNRESTRICTED;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export type RequestToolScope = {
|
|
170
|
+
/**
|
|
171
|
+
* The name a client call is refused under, or undefined when this request permits it.
|
|
172
|
+
* A nameless call type is not answered here: only the declaration guard knows those.
|
|
173
|
+
*/
|
|
174
|
+
forbiddenClientToolCallName(item: Record<string, unknown>): string | undefined;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The client-call boundary this request states, or undefined when it states none.
|
|
179
|
+
*
|
|
180
|
+
* Returning undefined for an unrestricted request keeps every ordinary turn on the path it
|
|
181
|
+
* already had: a caller that selected nothing gets no new refusal.
|
|
182
|
+
*/
|
|
183
|
+
export function requestToolScope(
|
|
184
|
+
body: unknown,
|
|
185
|
+
correspondence: RequestToolScopeCorrespondence = {},
|
|
186
|
+
): RequestToolScope | undefined {
|
|
187
|
+
if (!isPlainObject(body)) return undefined;
|
|
188
|
+
const selection = toolSelection(body);
|
|
189
|
+
// A readable catalog that declares no client-executable name is authoritative, exactly as it is
|
|
190
|
+
// for the declaration guard: an explicit empty list denies every client call. An absent catalog
|
|
191
|
+
// says nothing — a passthrough request may omit `tools` and still receive a call the client
|
|
192
|
+
// understands.
|
|
193
|
+
const catalogDeniesClientCalls = hasExplicitWireToolCatalog(body)
|
|
194
|
+
&& collectDeclaredWireToolNames(body).size === 0;
|
|
195
|
+
if (selection.kind === "unrestricted" && !catalogDeniesClientCalls) return undefined;
|
|
196
|
+
return {
|
|
197
|
+
forbiddenClientToolCallName(item: Record<string, unknown>): string | undefined {
|
|
198
|
+
if (typeof item.type !== "string" || !CLIENT_EXECUTED_CALL_TYPES.has(item.type)) {
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
const restored = callIdentity(item);
|
|
202
|
+
const reported = restored?.name;
|
|
203
|
+
if (reported === undefined) return undefined;
|
|
204
|
+
if (catalogDeniesClientCalls || selection.kind === "deny_all") return reported;
|
|
205
|
+
if (selection.kind === "allow") {
|
|
206
|
+
const candidates = outboundCallIdentities(item, correspondence);
|
|
207
|
+
return [...candidates].some(candidate => selection.identities.has(candidate))
|
|
208
|
+
? undefined
|
|
209
|
+
: reported;
|
|
210
|
+
}
|
|
211
|
+
return undefined;
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
@@ -13,7 +13,10 @@ import {
|
|
|
13
13
|
import { replaceSseDataPayload, sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite";
|
|
14
14
|
|
|
15
15
|
/** Item types the client executes through a request-declared wire name. */
|
|
16
|
-
const CLIENT_EXECUTED_CALL_TYPES = new Set([
|
|
16
|
+
export const CLIENT_EXECUTED_CALL_TYPES: ReadonlySet<string> = new Set([
|
|
17
|
+
"function_call",
|
|
18
|
+
"custom_tool_call",
|
|
19
|
+
]);
|
|
17
20
|
/** Codex groups ordinary top-level tools here; unlike an MCP namespace, it has no wire prefix. */
|
|
18
21
|
const BUILTIN_FUNCTIONS_NAMESPACE = "functions";
|
|
19
22
|
|
package/src/server/search.ts
CHANGED
|
@@ -25,6 +25,9 @@ import { codexAccountNamespaceForModel } from "../codex/account-namespace-match"
|
|
|
25
25
|
import { NATIVE_RESERVE_MODEL } from "../codex/catalog/native-models";
|
|
26
26
|
import { isCodexReserveRequestEligible } from "../codex/loopback-target";
|
|
27
27
|
import type { DataPlaneAdmission } from "./auth-cors";
|
|
28
|
+
import {
|
|
29
|
+
admissionScopeDenial,
|
|
30
|
+
} from "./admission-model-scope";
|
|
28
31
|
import { formatCodexProviderForLog } from "../codex/routing";
|
|
29
32
|
import { signalWithTimeout } from "../lib/abort";
|
|
30
33
|
import { readBoundedResponseBytes } from "../lib/bounded-body";
|
|
@@ -87,6 +90,11 @@ export async function handleSearch(
|
|
|
87
90
|
if (!route.codexAccountId || route.codexAccountNamespace !== accountNamespace) {
|
|
88
91
|
return formatErrorResponse(400, "invalid_request_error", "Invalid Codex account-qualified search model");
|
|
89
92
|
}
|
|
93
|
+
// This branch resolves a model through the router and bills the account it
|
|
94
|
+
// names, so a scoped key is held to the same destination rule it is held
|
|
95
|
+
// to on the inference path.
|
|
96
|
+
const denial = admissionScopeDenial(config, admission, model, route);
|
|
97
|
+
if (denial) return denial;
|
|
90
98
|
exactAccount = { accountId: route.codexAccountId, modelId: route.modelId };
|
|
91
99
|
logCtx.provider = `${route.providerName}-${accountNamespace}`;
|
|
92
100
|
logCtx.routeDecision = route.routeDecision;
|
|
@@ -108,7 +116,7 @@ export async function handleSearch(
|
|
|
108
116
|
}
|
|
109
117
|
const candidates = listOpenAiForwardSidecarCandidates(config);
|
|
110
118
|
if (candidates.length === 0) {
|
|
111
|
-
return handleAlphaSearchSidecarFallback(body, config, req.signal, logCtx);
|
|
119
|
+
return handleAlphaSearchSidecarFallback(body, config, req.signal, logCtx, admission);
|
|
112
120
|
}
|
|
113
121
|
|
|
114
122
|
let upstream: Awaited<ReturnType<typeof resolveFirstUsableOpenAiSidecar>>;
|
|
@@ -148,6 +156,22 @@ export async function handleSearch(
|
|
|
148
156
|
throw err;
|
|
149
157
|
}
|
|
150
158
|
|
|
159
|
+
if (!accountNamespace) {
|
|
160
|
+
// An unqualified search model is never routed: the caller's own string is
|
|
161
|
+
// relayed to whichever ChatGPT account this upstream resolved to, and that
|
|
162
|
+
// account is billed for it. The qualified branch above was already judged
|
|
163
|
+
// against the route it resolved, so it is not judged twice here.
|
|
164
|
+
const searchModel = typeof model === "string" && model.trim() ? model : undefined;
|
|
165
|
+
const denial = admissionScopeDenial(config, admission, searchModel, {
|
|
166
|
+
providerName: upstream.providerName,
|
|
167
|
+
modelId: searchModel,
|
|
168
|
+
});
|
|
169
|
+
if (denial) {
|
|
170
|
+
upstream.releaseProbeLease?.();
|
|
171
|
+
return denial;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
151
175
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
152
176
|
if (upstream.provider.headers) Object.assign(headers, upstream.provider.headers);
|
|
153
177
|
for (const [name, value] of upstream.headers) headers[name] = value;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime owner for the opt-in usage-ledger size limit (#5063).
|
|
3
|
+
*
|
|
4
|
+
* The compactor in `src/usage/ledger-retention.ts` knows how to publish a smaller ledger safely.
|
|
5
|
+
* This is the part that decides when, and -- the half #5063 was missing -- what has to be
|
|
6
|
+
* discarded afterwards.
|
|
7
|
+
*
|
|
8
|
+
* Deleting rows from usage.jsonl invalidates three readers that do not watch the file: the
|
|
9
|
+
* 2,000-entry Logs ring, which otherwise keeps serving rows the ledger no longer has; the
|
|
10
|
+
* retained usage aggregate and failure projection, whose checkpoints now point past a boundary
|
|
11
|
+
* that moved; and the request-history index, whose source identity has changed. A compaction
|
|
12
|
+
* that skips any of them makes the dashboard disagree with the ledger, which is the disagreement
|
|
13
|
+
* this batch exists to remove.
|
|
14
|
+
*/
|
|
15
|
+
import { currentUsageLogRevision, setUsageLedgerAppendHook } from "../usage/log";
|
|
16
|
+
import { enforceUsageLedgerSizeLimit } from "../usage/ledger-retention";
|
|
17
|
+
import { discardRetainedFailureProjection } from "../usage/failure-projection-cache";
|
|
18
|
+
import { rehydrateRequestLogsAfterLedgerReplacement } from "./request-log";
|
|
19
|
+
import type { UsageLedgerRetentionStatus } from "../usage/retention-contract";
|
|
20
|
+
|
|
21
|
+
let configuredMaxBytes: number | undefined;
|
|
22
|
+
let enforcing = false;
|
|
23
|
+
|
|
24
|
+
function invalidateLedgerReaders(): void {
|
|
25
|
+
// Ordered cheapest-first, and each guarded on its own: a projection that fails to discard
|
|
26
|
+
// must not stop the ring from being rebuilt, because the ring is the surface an operator is
|
|
27
|
+
// looking at while this happens.
|
|
28
|
+
try { discardRetainedFailureProjection(); } catch { /* rebuildable by construction */ }
|
|
29
|
+
void (async () => {
|
|
30
|
+
try {
|
|
31
|
+
const { discardRetainedUsageAggregate } = await import("./management/usage-aggregate-cache");
|
|
32
|
+
discardRetainedUsageAggregate();
|
|
33
|
+
} catch { /* rebuildable by construction */ }
|
|
34
|
+
try {
|
|
35
|
+
const { closeRequestHistoryIndex } = await import("../routing/history/indexer");
|
|
36
|
+
closeRequestHistoryIndex();
|
|
37
|
+
} catch { /* the index rebuilds from its own source-identity contract */ }
|
|
38
|
+
})();
|
|
39
|
+
try { rehydrateRequestLogsAfterLedgerReplacement(); } catch { /* the ring refills as rows arrive */ }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function enforceNow(): void {
|
|
43
|
+
// Re-entrancy guard, not a lock. The compaction itself runs inside the append call stack, and
|
|
44
|
+
// its own publication writes nothing through appendUsageEntry -- this exists so a future
|
|
45
|
+
// caller on that path cannot start a second pass over a file the first one is replacing.
|
|
46
|
+
if (enforcing) return;
|
|
47
|
+
enforcing = true;
|
|
48
|
+
try {
|
|
49
|
+
const result = enforceUsageLedgerSizeLimit(configuredMaxBytes);
|
|
50
|
+
if (result.kind === "replaced") invalidateLedgerReaders();
|
|
51
|
+
} catch (error) {
|
|
52
|
+
// Never fail a request because history could not be trimmed. The limit is not enforced and
|
|
53
|
+
// says so; the next append tries again from a fresh revision.
|
|
54
|
+
console.warn(
|
|
55
|
+
`[usage-retention] could not enforce the usage ledger size limit: ${error instanceof Error ? error.message : String(error)}`,
|
|
56
|
+
);
|
|
57
|
+
} finally {
|
|
58
|
+
enforcing = false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Install or update the policy. `undefined` removes the hook entirely. */
|
|
63
|
+
export function setUsageLedgerRetention(maxBytes: number | undefined): void {
|
|
64
|
+
configuredMaxBytes = maxBytes;
|
|
65
|
+
setUsageLedgerAppendHook(maxBytes === undefined ? null : enforceNow);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function usageLedgerRetentionStatus(): UsageLedgerRetentionStatus {
|
|
69
|
+
return {
|
|
70
|
+
...(configuredMaxBytes !== undefined ? { maxBytes: configuredMaxBytes } : {}),
|
|
71
|
+
currentBytes: currentUsageLogRevision()?.size ?? 0,
|
|
72
|
+
};
|
|
73
|
+
}
|