@bitkyc08/opencodex 2.55.0 → 2.57.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 +10 -0
- package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
- package/gui/dist/assets/{index-VuoiWj9J.js → index-Cz7CLdif.js} +21 -21
- package/gui/dist/index.html +2 -2
- package/package.json +4 -3
- package/src/adapters/base.ts +21 -0
- package/src/adapters/codebuddy/adapter.ts +2 -1
- package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
- package/src/adapters/command-code.ts +1 -1
- package/src/adapters/cursor/envelope-echo.ts +8 -2
- package/src/adapters/cursor/transport-retry.ts +46 -1
- package/src/adapters/cursor.ts +4 -0
- package/src/adapters/google.ts +7 -7
- package/src/adapters/kiro/adapter.ts +42 -1
- package/src/adapters/kiro/payload.ts +17 -3
- package/src/adapters/kiro/reasoning.ts +70 -7
- package/src/adapters/kiro/stream.ts +8 -2
- package/src/adapters/kiro/wire.ts +2 -1
- package/src/adapters/kiro-events.ts +21 -13
- package/src/adapters/kiro-retry.ts +23 -4
- package/src/adapters/openai-chat/errors.ts +116 -0
- package/src/adapters/openai-chat/messages.ts +346 -0
- package/src/adapters/openai-chat/passthrough.ts +146 -0
- package/src/adapters/openai-chat/response-events.ts +117 -0
- package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
- package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
- package/src/adapters/openai-chat/tool-schema.ts +495 -0
- package/src/adapters/openai-chat/wire.ts +50 -0
- package/src/adapters/openai-chat.ts +40 -1452
- package/src/adapters/openai-responses/canonical-forward.ts +202 -0
- package/src/adapters/openai-responses/image-gen.ts +406 -0
- package/src/adapters/openai-responses/internal.ts +3 -0
- package/src/adapters/openai-responses/passthrough.ts +642 -0
- package/src/adapters/openai-responses/prompt-cache.ts +83 -0
- package/src/adapters/openai-responses/reasoning.ts +220 -0
- package/src/adapters/openai-responses/request-strips.ts +185 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
- package/src/adapters/openai-responses/tool-schema.ts +293 -0
- package/src/adapters/openai-responses/web-search.ts +156 -0
- package/src/adapters/openai-responses.ts +4 -2625
- package/src/bridge/errors.ts +58 -0
- package/src/bridge/internal.ts +174 -0
- package/src/bridge/response-json.ts +630 -0
- package/src/bridge/sse.ts +1462 -0
- package/src/bridge.ts +5 -2204
- package/src/chat/inbound.ts +12 -1
- package/src/claude/desktop-profile.ts +66 -9
- package/src/claude/outbound.ts +18 -0
- package/src/cli/account-main.ts +1 -1
- package/src/cli/capabilities.ts +2 -2
- package/src/cli/combo.ts +10 -1
- package/src/cli/index.ts +48 -5
- package/src/cli/registry.ts +2 -1
- package/src/cli/system-command.ts +4 -4
- package/src/clients/config-export.ts +7 -3
- package/src/codex/account-label.ts +14 -3
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-store.ts +184 -35
- package/src/codex/account-usability.ts +21 -0
- package/src/codex/auth-api/account-list.ts +507 -0
- package/src/codex/auth-api/http.ts +32 -0
- package/src/codex/auth-api/login-flow.ts +566 -0
- package/src/codex/auth-api/login-state.ts +64 -0
- package/src/codex/auth-api/main-account-probe.ts +331 -0
- package/src/codex/auth-api/pool-mode-gate.ts +274 -0
- package/src/codex/auth-api/pool-quota-probe.ts +512 -0
- package/src/codex/auth-api/reset-credit-service.ts +431 -0
- package/src/codex/auth-api/routes.ts +425 -0
- package/src/codex/auth-api/runtime-config.ts +48 -0
- package/src/codex/auth-api.ts +27 -3118
- package/src/codex/auth-context.ts +252 -35
- package/src/codex/catalog/aggregation.ts +80 -1
- package/src/codex/catalog/auto-review.ts +507 -0
- package/src/codex/catalog/build-entries.ts +981 -0
- package/src/codex/catalog/combo-member.ts +375 -0
- package/src/codex/catalog/derive-entry.ts +229 -0
- package/src/codex/catalog/effort.ts +0 -1
- package/src/codex/catalog/gated-native-warn.ts +63 -0
- package/src/codex/catalog/gather-capture.ts +533 -0
- package/src/codex/catalog/model-hints.ts +691 -0
- package/src/codex/catalog/model-visibility.ts +305 -0
- package/src/codex/catalog/provider-fetch.ts +52 -2942
- package/src/codex/catalog/provider-models.ts +685 -0
- package/src/codex/catalog/remote.ts +30 -0
- package/src/codex/catalog/restore.ts +132 -0
- package/src/codex/catalog/retained-sync.ts +714 -0
- package/src/codex/catalog/routed-gather.ts +895 -0
- package/src/codex/catalog/subagent-roster.ts +176 -0
- package/src/codex/catalog/sync.ts +52 -2698
- package/src/codex/cli-install-provenance.ts +7 -1
- package/src/codex/convergence.ts +7 -2
- package/src/codex/desktop-app/types.ts +11 -2
- package/src/codex/desktop-app/windows.ts +5 -5
- package/src/codex/inject/config-toml.ts +563 -0
- package/src/codex/inject/remove.ts +192 -0
- package/src/codex/inject/restore.ts +567 -0
- package/src/codex/inject/routing-classify.ts +109 -0
- package/src/codex/inject/routing-target.ts +125 -0
- package/src/codex/inject.ts +89 -1444
- package/src/codex/lineage.ts +458 -0
- package/src/codex/model-entitlements.ts +152 -15
- package/src/codex/pool-refresh-backoff.ts +161 -0
- package/src/codex/quota-rejection.ts +104 -15
- package/src/codex/routing/active-account.ts +194 -0
- package/src/codex/routing/cache-affinity.ts +70 -0
- package/src/codex/routing/cooldown-math.ts +285 -0
- package/src/codex/routing/health-store.ts +402 -0
- package/src/codex/routing/probe-lease.ts +358 -0
- package/src/codex/routing/selection.ts +780 -0
- package/src/codex/routing/thread-affinity.ts +586 -0
- package/src/codex/routing/transient-hold-dispatch.ts +141 -0
- package/src/codex/routing.ts +370 -2271
- package/src/codex/shim-fingerprint.ts +223 -0
- package/src/codex/shim-inspect.ts +175 -0
- package/src/codex/shim-probe.ts +367 -0
- package/src/codex/shim-restore-lock.ts +169 -0
- package/src/codex/shim-state-file.ts +151 -0
- package/src/codex/shim-templates.ts +265 -0
- package/src/codex/shim.ts +48 -1268
- package/src/codex/warmup.ts +1 -1
- package/src/combos/failover.ts +85 -0
- package/src/combos/request.ts +17 -10
- package/src/combos/types.ts +23 -2
- package/src/config/diagnostics.ts +705 -0
- package/src/config/feature-flags.ts +55 -0
- package/src/config/live-reconcile.ts +403 -0
- package/src/config/load-degrade.ts +880 -0
- package/src/config/mutation-lock.ts +244 -0
- package/src/config/openai-tier-backup.ts +268 -0
- package/src/config/pending-teardown.ts +31 -0
- package/src/config/persist-unlocked.ts +92 -0
- package/src/config/proxy-env.ts +188 -0
- package/src/config/salvage.ts +244 -0
- package/src/config/schema/config-schema.ts +640 -0
- package/src/config/schema/leaf-validators.ts +855 -0
- package/src/config/warn-memo.ts +28 -0
- package/src/config.ts +234 -4481
- package/src/generated/compatibility-version.json +649 -121
- package/src/images/loop.ts +1 -1
- package/src/lib/errors.ts +17 -0
- package/src/lib/request-execution-budget.ts +198 -23
- package/src/lib/spend-reservation-ledger.ts +958 -0
- package/src/lib/state-store-registrations.ts +6 -2
- package/src/lib/test-home-guard.ts +85 -1
- package/src/lib/upstream-retry.ts +132 -21
- package/src/lib/windows-elevation.ts +76 -14
- package/src/lib/workflow-budget.ts +553 -30
- package/src/oauth/index.ts +2 -2
- package/src/oauth/key-providers.ts +2 -2
- package/src/providers/kiro-models.ts +4 -3
- package/src/providers/label.ts +19 -1
- package/src/providers/model-discovery.ts +16 -0
- package/src/providers/quota/account-cache.ts +441 -0
- package/src/providers/quota/antigravity.ts +295 -0
- package/src/providers/quota/report-cache.ts +320 -0
- package/src/providers/quota/vendor-probes-key.ts +1243 -0
- package/src/providers/quota/vendor-probes-oauth.ts +590 -0
- package/src/providers/quota.ts +324 -3079
- package/src/providers/registry/entries-core.ts +1228 -0
- package/src/providers/registry/entries-extended.ts +1213 -0
- package/src/providers/registry/model-seeds.ts +912 -0
- package/src/providers/registry/types.ts +352 -0
- package/src/providers/registry.ts +24 -3536
- package/src/responses/continuation-ownership.ts +29 -0
- package/src/responses/reasoning-envelope.ts +6 -3
- package/src/responses/state/replay-fingerprint.ts +80 -0
- package/src/responses/state/snapshot-codec.ts +104 -0
- package/src/responses/state/spill-failure.ts +118 -0
- package/src/responses/state/spill-queue.ts +665 -0
- package/src/responses/state/temp-recovery.ts +257 -0
- package/src/responses/state.ts +82 -1143
- package/src/routing/identity-domains.ts +456 -0
- package/src/routing/probe-lease.ts +613 -0
- package/src/server/chat-completions.ts +3 -1
- package/src/server/chat-native.ts +37 -9
- package/src/server/index/bounded-request.ts +88 -0
- package/src/server/index/live-sideband.ts +601 -0
- package/src/server/index/serve-options.ts +1766 -0
- package/src/server/index/startup-warnings.ts +213 -0
- package/src/server/index/websocket-handler.ts +339 -0
- package/src/server/index.ts +45 -2552
- package/src/server/inspection-tee.ts +107 -0
- package/src/server/live.ts +46 -1
- package/src/server/management/combo-routes.ts +10 -1
- package/src/server/management/route-registry.ts +26 -23
- package/src/server/management/shared.ts +8 -5
- package/src/server/management/workflow-budget-routes.ts +133 -0
- package/src/server/management-api.ts +12 -0
- package/src/server/relay-eager.ts +2 -0
- package/src/server/relay.ts +14 -19
- package/src/server/request-log-conversation.ts +9 -7
- package/src/server/request-log.ts +372 -4
- package/src/server/response-log-body.ts +153 -0
- package/src/server/responses/account-change-state.ts +307 -0
- package/src/server/responses/adapter-continuation.ts +540 -0
- package/src/server/responses/adapter-delivery.ts +208 -0
- package/src/server/responses/adapter-dispatch.ts +1042 -0
- package/src/server/responses/codex-ws-wire.ts +5 -0
- package/src/server/responses/collaboration.ts +74 -4
- package/src/server/responses/combo-session-recall.ts +68 -8
- package/src/server/responses/compact.ts +113 -17
- package/src/server/responses/completion-policy.ts +33 -0
- package/src/server/responses/core-auth.ts +529 -0
- package/src/server/responses/core-codex-account.ts +907 -0
- package/src/server/responses/core-combo-failure.ts +210 -0
- package/src/server/responses/core-combo.ts +787 -0
- package/src/server/responses/core-errors.ts +170 -0
- package/src/server/responses/core-lifetime.ts +95 -0
- package/src/server/responses/core-normalize.ts +350 -0
- package/src/server/responses/core-opaque-recovery.ts +380 -0
- package/src/server/responses/core-options.ts +159 -0
- package/src/server/responses/core-replay.ts +298 -0
- package/src/server/responses/core.ts +192 -8893
- package/src/server/responses/encrypted-payload.ts +0 -1
- package/src/server/responses/input-admission.ts +126 -6
- package/src/server/responses/passthrough-delivery.ts +869 -0
- package/src/server/responses/passthrough-dispatch.ts +1494 -0
- package/src/server/responses/passthrough-error.ts +38 -2
- package/src/server/responses/passthrough-execution.ts +54 -0
- package/src/server/responses/request-prepare.ts +1080 -0
- package/src/server/responses/request-send-budget.ts +259 -0
- package/src/server/responses/request-sidecar-auth.ts +149 -0
- package/src/server/responses/request-spend.ts +147 -0
- package/src/server/responses/request-transport.ts +803 -0
- package/src/server/responses/response-effects.ts +157 -0
- package/src/server/responses/run-turn-execution.ts +476 -0
- package/src/server/responses/sidecar-execution.ts +463 -0
- package/src/server/responses/terminal-guard.ts +65 -4
- package/src/server/responses-image-gen-repair.ts +1 -1
- package/src/server/responses-undeclared-tool-guard.ts +9 -5
- package/src/server/workflow-refusal.ts +84 -0
- package/src/service/windows-ops.ts +210 -16
- package/src/service/windows-scheduler.ts +28 -21
- package/src/service.ts +1 -1
- package/src/types/config.ts +34 -1
- package/src/types/request.ts +8 -5
- package/src/types/tools.ts +24 -0
- package/src/types.ts +2 -0
- package/src/update/index.ts +10 -0
- package/src/update/stop-contract.d.mts +1 -0
- package/src/update/stop-contract.mjs +19 -0
- package/src/update/stop-decision.d.mts +1 -1
- package/src/update/stop-decision.mjs +12 -3
- package/src/usage/log.ts +147 -1
- package/src/usage/summary.ts +171 -21
- package/src/vision/anthropic-describe.ts +1 -1
- package/src/vision/describe.ts +5 -5
- package/src/web-search/anthropic-executor.ts +1 -1
- package/src/web-search/exa-executor.ts +1 -1
- package/src/web-search/executor.ts +1 -1
- package/src/web-search/gemini-executor.ts +1 -1
- package/src/web-search/loop.ts +1 -1
- package/src/web-search/ollama-executor.ts +1 -1
- package/src/web-search/parse.ts +67 -14
- package/src/web-search/passthrough-bridge.ts +64 -31
- package/src/web-search/xai-executor.ts +1 -1
|
@@ -1,2627 +1,6 @@
|
|
|
1
|
-
import { normalizeRoutedAgentMessages } from "./routed-agent-messages";
|
|
2
|
-
import { stripBracketedModelSuffix } from "./openai-chat";
|
|
3
|
-
import { normalizeOpenCodeGoAdditionalTools } from "./opencode-go-additional-tools";
|
|
4
|
-
import { isXaiResponsesDestination } from "../providers/xai-transport";
|
|
5
|
-
import { createHash } from "node:crypto";
|
|
6
|
-
import { Buffer } from "node:buffer";
|
|
7
|
-
import type { IncomingMeta, ProviderAdapter } from "./base";
|
|
8
|
-
import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage, type TierDecision } from "../types";
|
|
9
|
-
import { catalogModelSupportsReasoningSummaries } from "../codex/catalog";
|
|
10
|
-
import { applyCodexRoutingHint, CODEX_RESPONSES_LITE_HEADER, CODEX_ROUTING_HINT_HEADER } from "../codex/forward-transport-headers";
|
|
11
|
-
import { COMPACT_PROMPT, compactionItemToText, decodeCompactionSummary, isCompactionItemType } from "../responses/compaction";
|
|
12
|
-
import { collectResponsesToolGroups } from "../responses/tool-groups";
|
|
13
|
-
import { isHostedToolUnsupportedForModel } from "../responses/hosted-tool-policy";
|
|
14
|
-
import { decodeServerSentEvents } from "../lib/sse-decoder";
|
|
15
|
-
import { debugProviderDiagnostic } from "../lib/debug";
|
|
16
|
-
import {
|
|
17
|
-
CODEX_FORWARD_BASE_URL,
|
|
18
|
-
destinationDecodesNativeCompactionBlob,
|
|
19
|
-
isCanonicalOpenAiForwardProvider,
|
|
20
|
-
isOpenAiOperatedResponsesDestination,
|
|
21
|
-
} from "../providers/openai-tiers";
|
|
22
|
-
import { OCX_REASONING_PREFIX } from "../responses/reasoning-envelope";
|
|
23
|
-
import { configuredReasoningEfforts, mapReasoningEffort, modelRecordValue } from "../reasoning-effort";
|
|
24
|
-
import type { TranslatorBudget } from "../lib/translator-budget";
|
|
25
|
-
import { rewriteRoutedCustomToolsForUpstream } from "../responses/custom-tool-compat";
|
|
26
|
-
import { rewriteRoutedToolSearchForUpstream } from "../responses/tool-search-compat";
|
|
27
|
-
import { rewriteRoutedNamespaceToolsForUpstream } from "../responses/namespace-tool-compat";
|
|
28
|
-
import { preparePlaintextV2AgentMessages } from "../responses/plaintext-v2-agent-messages";
|
|
29
|
-
import { isMetaAiResponsesDestination, rewriteMuseToolNamesForUpstream } from "../responses/muse-tool-name-alias";
|
|
30
|
-
import { openaiResponsesUrl } from "./openai-responses-url";
|
|
31
|
-
import { normalizeResponsesCodeMode } from "./responses-code-mode";
|
|
32
|
-
import { stripUnicodePropertyPatterns } from "./responses-tool-schema";
|
|
33
|
-
import { injectXaiResponsesXSearch, normalizeXaiResponsesWebSearch } from "./xai-web-search";
|
|
34
|
-
import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "./empty-tool-output-annotation";
|
|
35
|
-
import {
|
|
36
|
-
isXaiSchemaTarget,
|
|
37
|
-
normalizeXaiToolParameters,
|
|
38
|
-
XaiToolSchemaCompatibilityError,
|
|
39
|
-
} from "./xai-tool-schema";
|
|
40
|
-
import {
|
|
41
|
-
createAdapterTierMetadata,
|
|
42
|
-
} from "../providers/fastwire";
|
|
43
1
|
|
|
44
|
-
// Headers relayed verbatim from the caller in OAuth-passthrough ("forward") mode.
|
|
45
|
-
// Exported so the web-search sidecar reuses the exact same forwarded-auth set for its ChatGPT call.
|
|
46
|
-
export const FORWARD_HEADERS = [
|
|
47
|
-
"authorization",
|
|
48
|
-
"chatgpt-account-id",
|
|
49
|
-
"openai-beta",
|
|
50
|
-
"originator",
|
|
51
|
-
"session_id",
|
|
52
|
-
"session-id",
|
|
53
|
-
"thread-id",
|
|
54
|
-
"x-client-request-id",
|
|
55
|
-
"x-codex-beta-features",
|
|
56
|
-
"x-codex-installation-id",
|
|
57
|
-
"x-codex-parent-thread-id",
|
|
58
|
-
"x-codex-turn-metadata",
|
|
59
|
-
"x-codex-turn-state",
|
|
60
|
-
"x-codex-window-id",
|
|
61
|
-
"x-oai-attestation",
|
|
62
|
-
"x-openai-subagent",
|
|
63
|
-
"x-responsesapi-include-timing-metrics",
|
|
64
|
-
CODEX_RESPONSES_LITE_HEADER,
|
|
65
|
-
];
|
|
66
2
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
* requests stripping after a route-identity change or opaque-blob recovery. On routed/non-OpenAI
|
|
72
|
-
* destinations, a present non-array `content` field is omitted. Otherwise non-empty array content
|
|
73
|
-
* is blanked unless raw reasoning preservation is enabled; removing an `ocxr1:` envelope selects
|
|
74
|
-
* the same blanking path when non-array omission is not active.
|
|
75
|
-
*/
|
|
76
|
-
export function sanitizeReasoningInputContent(
|
|
77
|
-
body: unknown,
|
|
78
|
-
opts?: {
|
|
79
|
-
preserveRawReasoningContent?: boolean;
|
|
80
|
-
dropNullContentChannel?: boolean;
|
|
81
|
-
stripEncryptedContent?: boolean;
|
|
82
|
-
},
|
|
83
|
-
): unknown {
|
|
84
|
-
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
|
|
85
|
-
const raw = body as Record<string, unknown>;
|
|
86
|
-
if (!Array.isArray(raw.input)) return body;
|
|
87
|
-
|
|
88
|
-
let changed = false;
|
|
89
|
-
const input = raw.input.map(item => {
|
|
90
|
-
if (!item || typeof item !== "object" || Array.isArray(item)) return item;
|
|
91
|
-
const rec = item as Record<string, unknown>;
|
|
92
|
-
if (rec.type !== "reasoning") return item;
|
|
93
|
-
const hasRawContent = Array.isArray(rec.content) && rec.content.length > 0;
|
|
94
|
-
// ocxr1 envelopes are proxy-minted (Anthropic signatures), not OpenAI encryption — the native
|
|
95
|
-
// backend cannot decrypt them and would reject the request. Strip regardless of content shape.
|
|
96
|
-
const hasOcxEnvelope = typeof rec.encrypted_content === "string" && rec.encrypted_content.startsWith(OCX_REASONING_PREFIX);
|
|
97
|
-
const hasOutputStatus = Object.prototype.hasOwnProperty.call(rec, "status");
|
|
98
|
-
const hasEncryptedContent = Object.prototype.hasOwnProperty.call(rec, "encrypted_content");
|
|
99
|
-
const stripEncryptedContent = hasOcxEnvelope
|
|
100
|
-
|| (opts?.stripEncryptedContent === true && hasEncryptedContent);
|
|
101
|
-
// Codex serializes an absent reasoning content channel as `"content": null`. The field is
|
|
102
|
-
// optional and null carries nothing, but a strict gateway rejects the item on its declared type
|
|
103
|
-
// — xAI answers `Could not decode the compaction blob`, naming the sibling `encrypted_content`
|
|
104
|
-
// rather than the field it actually refused, which is why this reads as a blob failure. Drop the
|
|
105
|
-
// key so the item matches the shape the upstream issued.
|
|
106
|
-
//
|
|
107
|
-
// Gated to routed destinations. An OpenAI-operated backend rejects a blob-bearing item when its
|
|
108
|
-
// null `content` channel is deleted (`The encrypted content ... could not be verified`); that
|
|
109
|
-
// live result establishes this channel constraint, not whole-item shape preservation. The gate
|
|
110
|
-
// is also why this drop may touch an item that keeps its blob: xAI demonstrably accepts its own
|
|
111
|
-
// blob without the null channel. This is independent of the output-only status removal below.
|
|
112
|
-
const dropNullContentChannel = opts?.dropNullContentChannel === true
|
|
113
|
-
&& "content" in rec && !Array.isArray(rec.content);
|
|
114
|
-
// `status` is output-only. Measured OpenAI reasoning items never contain it, and Grok accepts
|
|
115
|
-
// its own encrypted_content with status removed. Keeping a foreign status beside a retained
|
|
116
|
-
// blob makes OpenAI reject the field before blob validation, starving the provenance recovery
|
|
117
|
-
// of the opaque-blob error it needs. Content blanking remains the separate pre-existing rule.
|
|
118
|
-
const stripOutputStatus = hasOutputStatus;
|
|
119
|
-
const blankContent = !dropNullContentChannel
|
|
120
|
-
&& !opts?.preserveRawReasoningContent
|
|
121
|
-
&& (hasRawContent || hasOcxEnvelope);
|
|
122
|
-
if (!blankContent && !stripOutputStatus && !stripEncryptedContent && !dropNullContentChannel) {
|
|
123
|
-
return item;
|
|
124
|
-
}
|
|
125
|
-
changed = true;
|
|
126
|
-
const next: Record<string, unknown> = { ...rec };
|
|
127
|
-
if (dropNullContentChannel) delete next.content;
|
|
128
|
-
if (stripOutputStatus) delete next.status;
|
|
129
|
-
if (stripEncryptedContent) delete next.encrypted_content;
|
|
130
|
-
// Routed models can produce raw `reasoning_text` output items. Codex echoes those in later
|
|
131
|
-
// native GPT requests, but ChatGPT's Responses backend accepts reasoning input only with empty
|
|
132
|
-
// `content`; keep summaries/ids and drop the raw content so native passthrough does not 400.
|
|
133
|
-
// DeepSeek's Responses API instead ACCEPTS plaintext reasoning replay (its compatibility
|
|
134
|
-
// guide merges reasoning items into the adjacent assistant message), so providers flagged
|
|
135
|
-
// `preserveResponsesReasoningContent` keep it — deleting valid replay content there breaks
|
|
136
|
-
// continuations after tool calls (issue #875 family).
|
|
137
|
-
if (blankContent) next.content = [];
|
|
138
|
-
return next;
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
return changed ? { ...raw, input } : body;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function stripUnsupportedReasoningSummaryDelivery(body: unknown, modelId: string): unknown {
|
|
145
|
-
if (catalogModelSupportsReasoningSummaries(modelId) !== false) return body;
|
|
146
|
-
if (!isPlainObject(body) || !isPlainObject(body.stream_options)) return body;
|
|
147
|
-
if (!("reasoning_summary_delivery" in body.stream_options)) return body;
|
|
148
|
-
|
|
149
|
-
const streamOptions = { ...body.stream_options };
|
|
150
|
-
delete streamOptions.reasoning_summary_delivery;
|
|
151
|
-
const next = { ...body };
|
|
152
|
-
if (Object.keys(streamOptions).length > 0) next.stream_options = streamOptions;
|
|
153
|
-
else delete next.stream_options;
|
|
154
|
-
return next;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function stripInvalidItemIds(body: unknown): unknown {
|
|
158
|
-
if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
|
|
159
|
-
|
|
160
|
-
const validPrefixes: Record<string, string> = {
|
|
161
|
-
message: "msg_",
|
|
162
|
-
agent_message: "amsg_",
|
|
163
|
-
reasoning: "rs_",
|
|
164
|
-
function_call: "fc_",
|
|
165
|
-
custom_tool_call: "ctc_",
|
|
166
|
-
tool_search_call: "tsc_",
|
|
167
|
-
web_search_call: "ws_",
|
|
168
|
-
};
|
|
169
|
-
let changed = false;
|
|
170
|
-
const input = body.input.map(item => {
|
|
171
|
-
if (!isPlainObject(item) || typeof item.type !== "string") return item;
|
|
172
|
-
const validPrefix = validPrefixes[item.type];
|
|
173
|
-
if (!validPrefix) return item;
|
|
174
|
-
if (typeof item.id === "string" && item.id.startsWith(validPrefix)) return item;
|
|
175
|
-
if (!("id" in item)) return item;
|
|
176
|
-
changed = true;
|
|
177
|
-
const next = { ...item };
|
|
178
|
-
delete next.id;
|
|
179
|
-
return next;
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
return changed ? { ...body, input } : body;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Codex-private tool fields that only the ChatGPT backend understands.
|
|
187
|
-
*
|
|
188
|
-
* A third-party Responses gateway validates its schema and rejects the whole request before
|
|
189
|
-
* inference — xAI answers `Argument not supported: external_web_access` — so these are removed at
|
|
190
|
-
* the noncanonical boundary while the tool and every public option stay.
|
|
191
|
-
*
|
|
192
|
-
* Keep this a table. Each private bit Codex attaches has so far arrived as its own bespoke strip
|
|
193
|
-
* with its own traversal, and the traversals disagreed about which containers they covered; a new
|
|
194
|
-
* one should be a row here instead. `toolTypes` omitted means the field is private on any tool.
|
|
195
|
-
*/
|
|
196
|
-
const CANONICAL_ONLY_TOOL_FIELDS: readonly { field: string; toolTypes?: ReadonlySet<string>; capabilityGated?: boolean }[] = [
|
|
197
|
-
// ChatGPT's browsing policy bit. The public hosted tool is enabled by its presence alone.
|
|
198
|
-
// OWNERSHIP: official OpenAI API-key traffic and unclassified gateways ACCEPT this field, so
|
|
199
|
-
// it is only stripped when the provider capability denies it (supportsOpenAiWebSearchToolFields
|
|
200
|
-
// === false), matching stripOpenAiOnlyWebSearchFields; see
|
|
201
|
-
// tests/responses/responses-routed-web-search-fields.test.ts.
|
|
202
|
-
{ field: "external_web_access", toolTypes: new Set(["web_search", "web_search_preview"]), capabilityGated: true },
|
|
203
|
-
// Deferred-discovery marker. `activateDeferredTool` clears it only for tools a `tool_search_output`
|
|
204
|
-
// already loaded, so a still-deferred declaration — including one promoted out of a namespace
|
|
205
|
-
// group — otherwise reaches the wire carrying it.
|
|
206
|
-
{ field: "defer_loading" },
|
|
207
|
-
];
|
|
208
|
-
|
|
209
|
-
function stripCanonicalOnlyToolFields(body: unknown, includeCapabilityGated: boolean): unknown {
|
|
210
|
-
if (!isPlainObject(body)) return body;
|
|
211
|
-
|
|
212
|
-
const rewriteTools = (tools: unknown[]): unknown[] => {
|
|
213
|
-
let changed = false;
|
|
214
|
-
const rewritten = tools.map(tool => {
|
|
215
|
-
if (!isPlainObject(tool)) return tool;
|
|
216
|
-
let next = tool;
|
|
217
|
-
for (const { field, toolTypes, capabilityGated } of CANONICAL_ONLY_TOOL_FIELDS) {
|
|
218
|
-
if (capabilityGated && !includeCapabilityGated) continue;
|
|
219
|
-
if (!Object.hasOwn(next, field)) continue;
|
|
220
|
-
if (toolTypes && (typeof next.type !== "string" || !toolTypes.has(next.type))) continue;
|
|
221
|
-
const { [field]: _private, ...rest } = next;
|
|
222
|
-
next = rest;
|
|
223
|
-
}
|
|
224
|
-
if (next === tool) return tool;
|
|
225
|
-
changed = true;
|
|
226
|
-
return next;
|
|
227
|
-
});
|
|
228
|
-
return changed ? rewritten : tools;
|
|
229
|
-
};
|
|
230
|
-
|
|
231
|
-
let rewrittenBody = body;
|
|
232
|
-
if (Array.isArray(body.tools)) {
|
|
233
|
-
const tools = rewriteTools(body.tools);
|
|
234
|
-
if (tools !== body.tools) rewrittenBody = { ...rewrittenBody, tools };
|
|
235
|
-
}
|
|
236
|
-
if (!Array.isArray(body.input)) return rewrittenBody;
|
|
237
|
-
|
|
238
|
-
let input: unknown[] | undefined;
|
|
239
|
-
for (let index = 0; index < body.input.length; index += 1) {
|
|
240
|
-
const item = body.input[index];
|
|
241
|
-
if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) continue;
|
|
242
|
-
const tools = rewriteTools(item.tools);
|
|
243
|
-
if (tools === item.tools) continue;
|
|
244
|
-
input ??= [...body.input];
|
|
245
|
-
input[index] = { ...item, tools };
|
|
246
|
-
}
|
|
247
|
-
return input ? { ...rewrittenBody, input } : rewrittenBody;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* Codex keeps this ChatGPT-internal item metadata when its configured provider name is `openai`.
|
|
252
|
-
* Loopback OpenCodex injection intentionally retains that provider identity for history continuity,
|
|
253
|
-
* even when the proxy ultimately routes the request to a public Responses destination. Those
|
|
254
|
-
* destinations reject the private field as an unknown `input[*]` parameter, so remove it at the
|
|
255
|
-
* noncanonical boundary without mutating the caller-owned raw body.
|
|
256
|
-
*/
|
|
257
|
-
function stripInternalChatMessageMetadataPassthrough(body: unknown): unknown {
|
|
258
|
-
if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
|
|
259
|
-
|
|
260
|
-
let changed = false;
|
|
261
|
-
const input = body.input.map(item => {
|
|
262
|
-
if (!isPlainObject(item) || !Object.hasOwn(item, "internal_chat_message_metadata_passthrough")) {
|
|
263
|
-
return item;
|
|
264
|
-
}
|
|
265
|
-
changed = true;
|
|
266
|
-
const next = { ...item };
|
|
267
|
-
delete next.internal_chat_message_metadata_passthrough;
|
|
268
|
-
return next;
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
return changed ? { ...body, input } : body;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/**
|
|
275
|
-
* When `store` is false, the upstream API does not persist response items. Any item ID
|
|
276
|
-
* forwarded in `input` is then interpreted as a reference to a stored item that does not
|
|
277
|
-
* exist, producing a 404. Strip all item IDs in this case — `call_id` pairing is unaffected.
|
|
278
|
-
* Matches codex-rs behavior (core/src/client.rs:918-925).
|
|
279
|
-
*/
|
|
280
|
-
function stripItemIdsWhenUnstored(body: unknown): unknown {
|
|
281
|
-
if (!isPlainObject(body) || body.store !== false) return body;
|
|
282
|
-
if (!Array.isArray(body.input)) return body;
|
|
283
|
-
|
|
284
|
-
let changed = false;
|
|
285
|
-
const input = body.input.map(item => {
|
|
286
|
-
if (!isPlainObject(item) || !("id" in item)) return item;
|
|
287
|
-
changed = true;
|
|
288
|
-
const next = { ...item };
|
|
289
|
-
delete next.id;
|
|
290
|
-
return next;
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
return changed ? { ...body, input } : body;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
/**
|
|
297
|
-
* Normalize replayed compaction items for the destination backend.
|
|
298
|
-
*
|
|
299
|
-
* A compaction item carries an `encrypted_content` blob the client replays verbatim on every later
|
|
300
|
-
* turn, and only the backend that minted it can decode it. Proxy-minted `ocx1:` envelopes are
|
|
301
|
-
* transparent base64 rather than encryption, so no upstream can read them and they always become
|
|
302
|
-
* plain user messages. Native blobs have multiple possible minters, so a destination's ability to
|
|
303
|
-
* decode its own blobs does not make a blob from a previous serving identity portable. On a known
|
|
304
|
-
* identity mismatch the blob degrades to the same note the bridged parser uses, even when the
|
|
305
|
-
* destination normally accepts native blobs. Without a known mismatch, the destination capability
|
|
306
|
-
* keeps the existing behavior.
|
|
307
|
-
*
|
|
308
|
-
* A bare `context_compaction` marker carries no blob and is forwarded untouched.
|
|
309
|
-
*/
|
|
310
|
-
function scrubOcxCompactionItems(
|
|
311
|
-
body: unknown,
|
|
312
|
-
destinationDecodesNativeBlob: boolean,
|
|
313
|
-
threadServingIdentityChanged: boolean,
|
|
314
|
-
): unknown {
|
|
315
|
-
if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
|
|
316
|
-
|
|
317
|
-
let changed = false;
|
|
318
|
-
const input = body.input.map(item => {
|
|
319
|
-
if (!isPlainObject(item) || !isCompactionItemType(item.type)) return item;
|
|
320
|
-
const encrypted = typeof item.encrypted_content === "string" ? item.encrypted_content : undefined;
|
|
321
|
-
if (encrypted === undefined) return item;
|
|
322
|
-
if (
|
|
323
|
-
decodeCompactionSummary(encrypted) === null
|
|
324
|
-
&& destinationDecodesNativeBlob
|
|
325
|
-
&& !threadServingIdentityChanged
|
|
326
|
-
) return item;
|
|
327
|
-
changed = true;
|
|
328
|
-
return {
|
|
329
|
-
type: "message",
|
|
330
|
-
role: "user",
|
|
331
|
-
content: [{ type: "input_text", text: compactionItemToText(encrypted) }],
|
|
332
|
-
};
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
return changed ? { ...body, input } : body;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* GPT-5.6 retired the legacy 24-hour retention field, and the ChatGPT backend 400s the whole
|
|
340
|
-
* request when that field is present (issue #2092).
|
|
341
|
-
*
|
|
342
|
-
* The retired field is NOT translated to the replacement: 5.6 carries a different TTL contract,
|
|
343
|
-
* and implicit caching still applies when the caller sent no replacement options. Inventing a
|
|
344
|
-
* value here would silently change a caching decision the caller never made.
|
|
345
|
-
*
|
|
346
|
-
* Deliberately narrow on both axes, because a wider strip is a behavior change rather than a fix:
|
|
347
|
-
* only the gpt-5.6 family (an older model may still honor the field), and only on the canonical
|
|
348
|
-
* ChatGPT backend, which is the deployment that rejects it. Matching is exact-or-dashed-prefix so
|
|
349
|
-
* a future `gpt-5.60` is not swept up by a bare `startsWith`.
|
|
350
|
-
*/
|
|
351
|
-
function stripDeprecatedPromptCacheRetention(body: unknown, modelId: unknown): unknown {
|
|
352
|
-
if (!isPlainObject(body)) return body;
|
|
353
|
-
if (typeof modelId !== "string") return body;
|
|
354
|
-
if (modelId !== "gpt-5.6" && !modelId.startsWith("gpt-5.6-")) return body;
|
|
355
|
-
if (!Object.hasOwn(body, "prompt_cache_retention")) return body;
|
|
356
|
-
const { prompt_cache_retention: _retention, ...rest } = body;
|
|
357
|
-
return rest;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/**
|
|
361
|
-
* Public Responses clients can send `prompt_cache_options`, but the canonical ChatGPT Codex
|
|
362
|
-
* backend rejects the top-level field before inference (issue #2765). Custom forward gateways and
|
|
363
|
-
* API-key Responses providers own different wire contracts, so the caller applies this only after
|
|
364
|
-
* the canonical destination predicate succeeds.
|
|
365
|
-
*/
|
|
366
|
-
function stripCanonicalForwardPromptCacheOptions(body: unknown): unknown {
|
|
367
|
-
if (!isPlainObject(body) || !Object.hasOwn(body, "prompt_cache_options")) return body;
|
|
368
|
-
const { prompt_cache_options: _options, ...rest } = body;
|
|
369
|
-
return rest;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
/**
|
|
373
|
-
* A false model capability prevents Codex from emitting summary fields after the catalog refresh.
|
|
374
|
-
* Strip them here as well so an already-running client with a stale catalog cannot keep sending an
|
|
375
|
-
* upstream-rejected `reasoning_summary_delivery` value (issue #323).
|
|
376
|
-
*/
|
|
377
|
-
function stripDisabledReasoningSummaries(
|
|
378
|
-
body: unknown,
|
|
379
|
-
provider: OcxProviderConfig,
|
|
380
|
-
modelId: string,
|
|
381
|
-
): unknown {
|
|
382
|
-
if (modelRecordValue(provider.modelSupportsReasoningSummaries, modelId) !== false || !isPlainObject(body)) {
|
|
383
|
-
return body;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
let changed = false;
|
|
387
|
-
let streamOptions = body.stream_options;
|
|
388
|
-
if (isPlainObject(streamOptions) && Object.hasOwn(streamOptions, "reasoning_summary_delivery")) {
|
|
389
|
-
const { reasoning_summary_delivery: _delivery, ...rest } = streamOptions;
|
|
390
|
-
streamOptions = rest;
|
|
391
|
-
changed = true;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
let reasoning = body.reasoning;
|
|
395
|
-
if (isPlainObject(reasoning)) {
|
|
396
|
-
const { summary: _summary, generate_summary: _generateSummary, ...rest } = reasoning;
|
|
397
|
-
if (_summary !== undefined || _generateSummary !== undefined) {
|
|
398
|
-
reasoning = rest;
|
|
399
|
-
changed = true;
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
if (!changed) return body;
|
|
404
|
-
return {
|
|
405
|
-
...body,
|
|
406
|
-
...(isPlainObject(streamOptions) && Object.keys(streamOptions).length > 0
|
|
407
|
-
? { stream_options: streamOptions }
|
|
408
|
-
: { stream_options: undefined }),
|
|
409
|
-
...(isPlainObject(reasoning) && Object.keys(reasoning).length > 0
|
|
410
|
-
? { reasoning }
|
|
411
|
-
: { reasoning: undefined }),
|
|
412
|
-
};
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
/**
|
|
416
|
-
* Hide a no-op Responses verbosity control from the wire as well as the catalog. This runs at
|
|
417
|
-
* final serialization so a stale catalog or direct caller cannot bypass the capability. Other
|
|
418
|
-
* `text` settings (notably structured-output `format`) remain untouched.
|
|
419
|
-
*/
|
|
420
|
-
function stripDisabledVerbosity(
|
|
421
|
-
body: unknown,
|
|
422
|
-
provider: OcxProviderConfig,
|
|
423
|
-
modelId: string,
|
|
424
|
-
): unknown {
|
|
425
|
-
if (modelRecordValue(provider.modelSupportsVerbosity, modelId) !== false || !isPlainObject(body)) {
|
|
426
|
-
return body;
|
|
427
|
-
}
|
|
428
|
-
if (!isPlainObject(body.text) || !Object.hasOwn(body.text, "verbosity")) return body;
|
|
429
|
-
const { verbosity: _verbosity, ...rest } = body.text;
|
|
430
|
-
return {
|
|
431
|
-
...body,
|
|
432
|
-
...(Object.keys(rest).length > 0 ? { text: rest } : { text: undefined }),
|
|
433
|
-
};
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
/**
|
|
437
|
-
* Normalize only the delivery enum Codex already emitted. Do not inject a field into callers that
|
|
438
|
-
* did not request summaries, and leave every unconfigured provider/model byte-for-byte unchanged.
|
|
439
|
-
*/
|
|
440
|
-
function normalizeConfiguredReasoningSummaryDelivery(
|
|
441
|
-
body: unknown,
|
|
442
|
-
provider: OcxProviderConfig,
|
|
443
|
-
modelId: string,
|
|
444
|
-
): unknown {
|
|
445
|
-
const delivery = modelRecordValue(provider.modelReasoningSummaryDelivery, modelId);
|
|
446
|
-
if (delivery === undefined || !isPlainObject(body) || !isPlainObject(body.stream_options)) return body;
|
|
447
|
-
if (!Object.hasOwn(body.stream_options, "reasoning_summary_delivery")) return body;
|
|
448
|
-
if (body.stream_options.reasoning_summary_delivery === delivery) return body;
|
|
449
|
-
return {
|
|
450
|
-
...body,
|
|
451
|
-
stream_options: {
|
|
452
|
-
...body.stream_options,
|
|
453
|
-
reasoning_summary_delivery: delivery,
|
|
454
|
-
},
|
|
455
|
-
};
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
459
|
-
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
/**
|
|
463
|
-
* Apply the routed provider's real effort ladder to an existing Responses reasoning field.
|
|
464
|
-
* Native forward requests keep the server-owned native clamp; unknown third-party ladders stay
|
|
465
|
-
* byte-equivalent instead of acquiring a policy from this adapter.
|
|
466
|
-
*/
|
|
467
|
-
function mapRoutedResponsesReasoningEffort(
|
|
468
|
-
body: unknown,
|
|
469
|
-
provider: OcxProviderConfig,
|
|
470
|
-
modelId: string,
|
|
471
|
-
): unknown {
|
|
472
|
-
if (provider.authMode === "forward") return body;
|
|
473
|
-
if (configuredReasoningEfforts(provider, modelId) === undefined) return body;
|
|
474
|
-
if (!isPlainObject(body) || !isPlainObject(body.reasoning)) return body;
|
|
475
|
-
const declaredEfforts = modelRecordValue(provider.modelReasoningEfforts, modelId) ?? provider.reasoningEfforts;
|
|
476
|
-
// An explicitly empty ladder means no effort control, not no reasoning output.
|
|
477
|
-
// Omit only effort so the upstream default applies; unknown/non-rankable ladders stay untouched.
|
|
478
|
-
if (declaredEfforts?.length === 0 && Object.hasOwn(body.reasoning, "effort")) {
|
|
479
|
-
const { effort: _effort, ...reasoning } = body.reasoning;
|
|
480
|
-
return { ...body, reasoning: Object.keys(reasoning).length > 0 ? reasoning : undefined };
|
|
481
|
-
}
|
|
482
|
-
const requested = body.reasoning.effort;
|
|
483
|
-
if (typeof requested !== "string") return body;
|
|
484
|
-
|
|
485
|
-
const mapped = mapReasoningEffort(provider, modelId, requested);
|
|
486
|
-
if (!mapped || mapped === requested) return body;
|
|
487
|
-
return { ...body, reasoning: { ...body.reasoning, effort: mapped } };
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
function normalizeFunctionToolSchema(tool: unknown, xaiTarget: boolean): unknown | undefined {
|
|
491
|
-
if (!isPlainObject(tool) || tool.type !== "function") return tool;
|
|
492
|
-
// Runs for every Responses destination, forward auth included: the ChatGPT backend is where
|
|
493
|
-
// the `\p{…}` rejection was observed, and it reaches this function through the same seam.
|
|
494
|
-
const compatible = stripUnicodePropertyPatterns(tool);
|
|
495
|
-
const source = isPlainObject(compatible) ? compatible : tool;
|
|
496
|
-
if (xaiTarget) {
|
|
497
|
-
const parameters = normalizeXaiToolParameters(isPlainObject(source.parameters) ? source.parameters : {});
|
|
498
|
-
return parameters === undefined ? undefined : { ...source, parameters };
|
|
499
|
-
}
|
|
500
|
-
if (isPlainObject(source.parameters) && source.parameters.type === "object") return source;
|
|
501
|
-
return {
|
|
502
|
-
...source,
|
|
503
|
-
parameters: { ...(isPlainObject(source.parameters) ? source.parameters : {}), type: "object" },
|
|
504
|
-
};
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
/**
|
|
508
|
-
* Re-point `tool_choice` after an incompatible function was dropped from the catalog. Names here
|
|
509
|
-
* are already wire names, because namespace lowering rewrote the declarations and the selector
|
|
510
|
-
* together before this runs. A selector left naming an omitted tool reaches Grok as a dangling
|
|
511
|
-
* reference it rejects, and silently relaxing it to `auto` is worse: the turn would quietly
|
|
512
|
-
* proceed without the tool the caller required. So an `allowed_tools` list drops the omitted
|
|
513
|
-
* entries while any remain, and a selection with nothing left to point at fails locally with the
|
|
514
|
-
* same 400 the caller gets for a tool catalog this proxy cannot lower.
|
|
515
|
-
*/
|
|
516
|
-
function reconcileToolChoiceForOmittedTools(
|
|
517
|
-
body: Record<string, unknown>,
|
|
518
|
-
omittedFunctionNames: ReadonlySet<string>,
|
|
519
|
-
): Record<string, unknown> {
|
|
520
|
-
if (omittedFunctionNames.size === 0) return body;
|
|
521
|
-
const toolChoice = body.tool_choice;
|
|
522
|
-
if (!isPlainObject(toolChoice)) return body;
|
|
523
|
-
|
|
524
|
-
const refuse = (name: string): never => {
|
|
525
|
-
throw new XaiToolSchemaCompatibilityError(
|
|
526
|
-
`tool_choice requires function "${name}", but its parameter schema cannot be represented for this destination; `
|
|
527
|
-
+ "relax tool_choice or simplify the tool's parameter schema",
|
|
528
|
-
);
|
|
529
|
-
};
|
|
530
|
-
|
|
531
|
-
if (toolChoice.type === "function" && typeof toolChoice.name === "string") {
|
|
532
|
-
return omittedFunctionNames.has(toolChoice.name) ? refuse(toolChoice.name) : body;
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
if (toolChoice.type === "allowed_tools" && Array.isArray(toolChoice.tools)) {
|
|
536
|
-
const omitted = toolChoice.tools.filter(tool =>
|
|
537
|
-
isPlainObject(tool)
|
|
538
|
-
&& tool.type === "function"
|
|
539
|
-
&& typeof tool.name === "string"
|
|
540
|
-
&& omittedFunctionNames.has(tool.name));
|
|
541
|
-
if (omitted.length === 0) return body;
|
|
542
|
-
const kept = toolChoice.tools.filter(tool => !omitted.includes(tool));
|
|
543
|
-
if (kept.length === 0) {
|
|
544
|
-
const first = omitted[0];
|
|
545
|
-
return refuse(isPlainObject(first) && typeof first.name === "string" ? first.name : "unknown");
|
|
546
|
-
}
|
|
547
|
-
return { ...body, tool_choice: { ...toolChoice, tools: kept } };
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
return body;
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
function normalizeToolSchemas(body: unknown, xaiTarget: boolean): unknown {
|
|
554
|
-
if (!isPlainObject(body)) return body;
|
|
555
|
-
|
|
556
|
-
const omittedFunctionNames = new Set<string>();
|
|
557
|
-
const normalizeTools = (tools: unknown[]): unknown[] => {
|
|
558
|
-
let changed = false;
|
|
559
|
-
const normalized: unknown[] = [];
|
|
560
|
-
for (const tool of tools) {
|
|
561
|
-
const fixed = normalizeFunctionToolSchema(tool, xaiTarget);
|
|
562
|
-
if (fixed === undefined) {
|
|
563
|
-
changed = true;
|
|
564
|
-
if (isPlainObject(tool) && typeof tool.name === "string") omittedFunctionNames.add(tool.name);
|
|
565
|
-
continue;
|
|
566
|
-
}
|
|
567
|
-
if (fixed !== tool) changed = true;
|
|
568
|
-
normalized.push(fixed);
|
|
569
|
-
}
|
|
570
|
-
return changed ? normalized : tools;
|
|
571
|
-
};
|
|
572
|
-
|
|
573
|
-
let normalizedBody = body;
|
|
574
|
-
if (Array.isArray(body.tools)) {
|
|
575
|
-
const tools = normalizeTools(body.tools);
|
|
576
|
-
if (tools !== body.tools) normalizedBody = { ...normalizedBody, tools };
|
|
577
|
-
}
|
|
578
|
-
if (Array.isArray(normalizedBody.input)) {
|
|
579
|
-
let inputChanged = false;
|
|
580
|
-
const input = normalizedBody.input.map((item) => {
|
|
581
|
-
if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item;
|
|
582
|
-
const tools = normalizeTools(item.tools);
|
|
583
|
-
if (tools === item.tools) return item;
|
|
584
|
-
inputChanged = true;
|
|
585
|
-
return { ...item, tools };
|
|
586
|
-
});
|
|
587
|
-
if (inputChanged) normalizedBody = { ...normalizedBody, input };
|
|
588
|
-
}
|
|
589
|
-
if (omittedFunctionNames.size > 0) {
|
|
590
|
-
// A dropped tool is a capability the caller declared and will not get, and the only other
|
|
591
|
-
// trace of it is a turn that never makes the call. Name them so the cause is recoverable.
|
|
592
|
-
debugProviderDiagnostic("openai-responses", "tool-schema-omitted", {
|
|
593
|
-
omitted: [...omittedFunctionNames],
|
|
594
|
-
});
|
|
595
|
-
}
|
|
596
|
-
return reconcileToolChoiceForOmittedTools(normalizedBody, omittedFunctionNames);
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
function activateDeferredTool(tool: Record<string, unknown>): Record<string, unknown> {
|
|
600
|
-
const { defer_loading: _, ...activeTool } = tool;
|
|
601
|
-
if (tool.type !== "namespace" || !Array.isArray(tool.tools)) return activeTool;
|
|
602
|
-
return {
|
|
603
|
-
...activeTool,
|
|
604
|
-
tools: tool.tools.map(inner => isPlainObject(inner) ? activateDeferredTool(inner) : inner),
|
|
605
|
-
};
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
function mergeLoadedTools(declaredTools: unknown[], loadedTools: unknown[]): unknown[] {
|
|
609
|
-
const merged = [...declaredTools];
|
|
610
|
-
let changed = false;
|
|
611
|
-
|
|
612
|
-
for (const candidate of loadedTools) {
|
|
613
|
-
if (!isPlainObject(candidate) || typeof candidate.name !== "string") continue;
|
|
614
|
-
const loaded = activateDeferredTool(candidate);
|
|
615
|
-
if (loaded.type === "namespace" && Array.isArray(loaded.tools)) {
|
|
616
|
-
const namespaceIndex = merged.findIndex(tool =>
|
|
617
|
-
isPlainObject(tool) && tool.type === "namespace" && tool.name === loaded.name
|
|
618
|
-
);
|
|
619
|
-
if (namespaceIndex < 0) {
|
|
620
|
-
merged.push(loaded);
|
|
621
|
-
changed = true;
|
|
622
|
-
continue;
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
const namespace = merged[namespaceIndex];
|
|
626
|
-
if (!isPlainObject(namespace)) continue;
|
|
627
|
-
const namespaceTools = Array.isArray(namespace.tools) ? namespace.tools : [];
|
|
628
|
-
const nextNamespaceTools = [...namespaceTools];
|
|
629
|
-
let namespaceChanged = "defer_loading" in namespace;
|
|
630
|
-
for (const tool of loaded.tools) {
|
|
631
|
-
if (!isPlainObject(tool) || typeof tool.name !== "string") continue;
|
|
632
|
-
const declaredIndex = nextNamespaceTools.findIndex(declared =>
|
|
633
|
-
isPlainObject(declared) && declared.name === tool.name
|
|
634
|
-
);
|
|
635
|
-
if (declaredIndex < 0) {
|
|
636
|
-
nextNamespaceTools.push(tool);
|
|
637
|
-
namespaceChanged = true;
|
|
638
|
-
continue;
|
|
639
|
-
}
|
|
640
|
-
const declared = nextNamespaceTools[declaredIndex];
|
|
641
|
-
if (isPlainObject(declared) && "defer_loading" in declared) {
|
|
642
|
-
nextNamespaceTools[declaredIndex] = activateDeferredTool(declared);
|
|
643
|
-
namespaceChanged = true;
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
if (!namespaceChanged) continue;
|
|
647
|
-
const { defer_loading: _, ...activeNamespace } = namespace;
|
|
648
|
-
merged[namespaceIndex] = { ...activeNamespace, tools: nextNamespaceTools };
|
|
649
|
-
changed = true;
|
|
650
|
-
continue;
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
const declaredIndex = merged.findIndex(tool =>
|
|
654
|
-
isPlainObject(tool) && tool.type !== "namespace" && tool.name === loaded.name
|
|
655
|
-
);
|
|
656
|
-
if (declaredIndex < 0) {
|
|
657
|
-
merged.push(loaded);
|
|
658
|
-
changed = true;
|
|
659
|
-
} else {
|
|
660
|
-
const declared = merged[declaredIndex];
|
|
661
|
-
if (isPlainObject(declared) && "defer_loading" in declared) {
|
|
662
|
-
merged[declaredIndex] = activateDeferredTool(declared);
|
|
663
|
-
changed = true;
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
return changed ? merged : declaredTools;
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
/**
|
|
672
|
-
* Client-executed tool search only changes Codex's parsed tool context. Routed passthrough keeps
|
|
673
|
-
* serializing the raw request, so activate those returned definitions for upstreams that do not
|
|
674
|
-
* implement the native deferred-loading handshake themselves.
|
|
675
|
-
*/
|
|
676
|
-
function promoteClientLoadedTools(body: unknown): unknown {
|
|
677
|
-
if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
|
|
678
|
-
|
|
679
|
-
const loadedTools = body.input.flatMap(item =>
|
|
680
|
-
isPlainObject(item) && item.type === "tool_search_output" && Array.isArray(item.tools)
|
|
681
|
-
? item.tools
|
|
682
|
-
: []
|
|
683
|
-
);
|
|
684
|
-
if (loadedTools.length === 0) return body;
|
|
685
|
-
|
|
686
|
-
if (Array.isArray(body.tools)) {
|
|
687
|
-
const tools = mergeLoadedTools(body.tools, loadedTools);
|
|
688
|
-
return tools === body.tools ? body : { ...body, tools };
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
const additionalToolsIndex = body.input.findIndex(item =>
|
|
692
|
-
isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools)
|
|
693
|
-
);
|
|
694
|
-
if (additionalToolsIndex < 0) return { ...body, tools: mergeLoadedTools([], loadedTools) };
|
|
695
|
-
|
|
696
|
-
const additionalTools = body.input[additionalToolsIndex];
|
|
697
|
-
if (!isPlainObject(additionalTools) || !Array.isArray(additionalTools.tools)) return body;
|
|
698
|
-
const tools = mergeLoadedTools(additionalTools.tools, loadedTools);
|
|
699
|
-
if (tools === additionalTools.tools) return body;
|
|
700
|
-
const input = [...body.input];
|
|
701
|
-
input[additionalToolsIndex] = { ...additionalTools, tools };
|
|
702
|
-
return { ...body, input };
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
const MAX_RESPONSES_CALL_ID_LENGTH = 64;
|
|
706
|
-
|
|
707
|
-
const REPAIRED_CALL_ID_PREFIX = "call_ocx_";
|
|
708
|
-
const REPAIRED_CALL_ID_DIGEST_LENGTH = MAX_RESPONSES_CALL_ID_LENGTH - REPAIRED_CALL_ID_PREFIX.length;
|
|
709
|
-
|
|
710
|
-
/**
|
|
711
|
-
* The ChatGPT Responses backend rejects input `call_id` values longer than 64 characters. Codex
|
|
712
|
-
* sidechat/fork replay can namespace call ids from routed providers past that limit. Forward mode
|
|
713
|
-
* already sends explicit replay input without `previous_response_id`, so it is safe to replace each
|
|
714
|
-
* oversized id and every matching call/output occurrence with one deterministic request-local alias.
|
|
715
|
-
* Raw API-key continuations are intentionally excluded because an output-only continuation may
|
|
716
|
-
* reference a call stored upstream under the original id. Proxy-expanded API-key replays are
|
|
717
|
-
* explicit and stateless here, so they are safe to repair too.
|
|
718
|
-
*/
|
|
719
|
-
function repairOversizedReplayCallIds(body: unknown): unknown {
|
|
720
|
-
if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
|
|
721
|
-
|
|
722
|
-
const occupied = new Set<string>();
|
|
723
|
-
for (const item of body.input) {
|
|
724
|
-
if (!isPlainObject(item) || typeof item.call_id !== "string") continue;
|
|
725
|
-
if (item.call_id.length <= MAX_RESPONSES_CALL_ID_LENGTH) occupied.add(item.call_id);
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
const aliases = new Map<string, string>();
|
|
729
|
-
let changed = false;
|
|
730
|
-
const input = body.input.map(item => {
|
|
731
|
-
if (!isPlainObject(item) || typeof item.call_id !== "string") return item;
|
|
732
|
-
const original = item.call_id;
|
|
733
|
-
if (original.length <= MAX_RESPONSES_CALL_ID_LENGTH) return item;
|
|
734
|
-
|
|
735
|
-
let alias = aliases.get(original);
|
|
736
|
-
if (!alias) {
|
|
737
|
-
let salt = 0;
|
|
738
|
-
do {
|
|
739
|
-
const hashInput = salt === 0 ? original : `${original}\0${salt}`;
|
|
740
|
-
const digest = createHash("sha256").update(hashInput).digest("hex");
|
|
741
|
-
alias = `${REPAIRED_CALL_ID_PREFIX}${digest.slice(0, REPAIRED_CALL_ID_DIGEST_LENGTH)}`;
|
|
742
|
-
salt += 1;
|
|
743
|
-
} while (occupied.has(alias));
|
|
744
|
-
aliases.set(original, alias);
|
|
745
|
-
occupied.add(alias);
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
changed = true;
|
|
749
|
-
return { ...item, call_id: alias };
|
|
750
|
-
});
|
|
751
|
-
|
|
752
|
-
return changed ? { ...body, input } : body;
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
/** Flatten a Responses tool-output `output` value (string or content-part array) to plain text. */
|
|
756
|
-
function toolOutputText(output: unknown): string {
|
|
757
|
-
if (typeof output === "string") return output;
|
|
758
|
-
if (!Array.isArray(output)) return JSON.stringify(output ?? "");
|
|
759
|
-
return output.map(part => {
|
|
760
|
-
if (!isPlainObject(part)) return "";
|
|
761
|
-
if (typeof part.text === "string") return part.text;
|
|
762
|
-
if (part.type === "refusal" && typeof part.refusal === "string") return `[refusal] ${part.refusal}`;
|
|
763
|
-
return "";
|
|
764
|
-
}).filter(Boolean).join("\n");
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
/** True when an output can be losslessly represented as user-message content. */
|
|
768
|
-
function isRepairableToolOutput(output: unknown): output is string | Record<string, unknown>[] {
|
|
769
|
-
if (typeof output === "string") return true;
|
|
770
|
-
if (!Array.isArray(output)) return false;
|
|
771
|
-
return output.every(part => {
|
|
772
|
-
if (!isPlainObject(part)) return false;
|
|
773
|
-
if (typeof part.type !== "string") return false;
|
|
774
|
-
if (["output_text", "text", "input_text"].includes(part.type)) {
|
|
775
|
-
return typeof part.text === "string";
|
|
776
|
-
}
|
|
777
|
-
if (part.type === "refusal") return typeof part.refusal === "string";
|
|
778
|
-
if (part.type === "encrypted_content") return typeof part.encrypted_content === "string";
|
|
779
|
-
if (part.type !== "input_image") return false;
|
|
780
|
-
const imageUrl = part.image_url;
|
|
781
|
-
const fileId = part.file_id;
|
|
782
|
-
const imageUrlIsString = typeof imageUrl === "string";
|
|
783
|
-
const fileIdIsString = typeof fileId === "string";
|
|
784
|
-
const hasUsableSource = (imageUrlIsString && imageUrl.length > 0)
|
|
785
|
-
|| (fileIdIsString && fileId.length > 0);
|
|
786
|
-
const validSource = hasUsableSource
|
|
787
|
-
&& (part.image_url === undefined || imageUrlIsString)
|
|
788
|
-
&& (part.file_id === undefined || fileIdIsString);
|
|
789
|
-
const validDetail = part.detail === undefined
|
|
790
|
-
|| (typeof part.detail === "string"
|
|
791
|
-
&& ["auto", "low", "high", "original"].includes(part.detail));
|
|
792
|
-
return validSource && validDetail;
|
|
793
|
-
});
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
/** Convert orphaned tool output to user-message content without discarding valid images. */
|
|
797
|
-
function orphanedToolOutputContent(output: unknown, callId = ""): Record<string, unknown>[] {
|
|
798
|
-
const marker = `[tool output for ${callId || "unknown call"}]`;
|
|
799
|
-
if (typeof output !== "string" && !Array.isArray(output)) {
|
|
800
|
-
return [{ type: "input_text", text: marker }];
|
|
801
|
-
}
|
|
802
|
-
if (!Array.isArray(output)) {
|
|
803
|
-
return [{ type: "input_text", text: `${marker}\n${toolOutputText(output)}` }];
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
const content: Record<string, unknown>[] = [{ type: "input_text", text: marker }];
|
|
807
|
-
for (const part of output) {
|
|
808
|
-
if (!isPlainObject(part)) continue;
|
|
809
|
-
if (part.type === "input_image") {
|
|
810
|
-
content.push(part);
|
|
811
|
-
} else if (part.type === "encrypted_content" && typeof part.encrypted_content === "string") {
|
|
812
|
-
content.push({ type: "input_text", text: "[encrypted content omitted]" });
|
|
813
|
-
} else if (typeof part.text === "string") {
|
|
814
|
-
content.push({ type: "input_text", text: part.text });
|
|
815
|
-
} else if (part.type === "refusal" && typeof part.refusal === "string") {
|
|
816
|
-
content.push({ type: "input_text", text: `[refusal] ${part.refusal}` });
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
return content;
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
/** True when a Responses tool output item is present but carries no usable content. */
|
|
823
|
-
function isToolOutputEmpty(output: unknown): boolean {
|
|
824
|
-
if (typeof output === "string") return output.trim() === "";
|
|
825
|
-
if (Array.isArray(output)) {
|
|
826
|
-
// Mirror the Chat wire rule through the shared contract: only a pure
|
|
827
|
-
// text/refusal part array whose joined content trims empty is annotated.
|
|
828
|
-
// input_image, encrypted_content, input_file and any other non-text part is
|
|
829
|
-
// real output and must never be replaced.
|
|
830
|
-
return isWhitespaceOnlyTextPartArray(output);
|
|
831
|
-
}
|
|
832
|
-
// A missing or null `output` is not a present-but-empty result: it is an
|
|
833
|
-
// incomplete payload. Leave it untouched so the upstream contract fails
|
|
834
|
-
// closed, and the orphan repair can surface it honestly instead of claiming
|
|
835
|
-
// the tool ran with no output.
|
|
836
|
-
return false;
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
/**
|
|
840
|
-
* Rewrite present-but-empty tool outputs to an explicit annotation. Synthetic
|
|
841
|
-
* missing-result placeholders are non-empty and pass through untouched. No-op unless
|
|
842
|
-
* the provider opts in (`annotateEmptyToolOutputs`).
|
|
843
|
-
*/
|
|
844
|
-
function annotateEmptyResponsesToolOutputs(body: unknown, enabled: boolean): unknown {
|
|
845
|
-
if (!enabled || !isPlainObject(body) || !Array.isArray(body.input)) return body;
|
|
846
|
-
let changed = false;
|
|
847
|
-
const input = body.input.map(item => {
|
|
848
|
-
if (!isPlainObject(item) || (item.type !== "function_call_output" && item.type !== "custom_tool_call_output")) return item;
|
|
849
|
-
if (!isToolOutputEmpty(item.output)) return item;
|
|
850
|
-
changed = true;
|
|
851
|
-
return { ...item, output: EMPTY_TOOL_OUTPUT_ANNOTATION };
|
|
852
|
-
});
|
|
853
|
-
return changed ? { ...body, input } : body;
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
/**
|
|
857
|
-
* Preserve the text of structurally invalid tool-output items before they reach a strict
|
|
858
|
-
* Responses parser. Stateful destinations may legitimately receive an output whose matching
|
|
859
|
-
* call lives behind `previous_response_id`, so ordinary orphan repair cannot run universally.
|
|
860
|
-
* A missing or empty `call_id`, however, cannot identify stored state on any destination.
|
|
861
|
-
*/
|
|
862
|
-
function repairUnidentifiedToolOutputItems(body: unknown): unknown {
|
|
863
|
-
if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
|
|
864
|
-
let changed = false;
|
|
865
|
-
const input = body.input.map(item => {
|
|
866
|
-
if (!isPlainObject(item)
|
|
867
|
-
|| (item.type !== "function_call_output" && item.type !== "custom_tool_call_output")
|
|
868
|
-
|| (typeof item.call_id === "string" && item.call_id.length > 0)) {
|
|
869
|
-
return item;
|
|
870
|
-
}
|
|
871
|
-
if (!isRepairableToolOutput(item.output)) return item;
|
|
872
|
-
changed = true;
|
|
873
|
-
return {
|
|
874
|
-
type: "message",
|
|
875
|
-
role: "user",
|
|
876
|
-
content: orphanedToolOutputContent(item.output),
|
|
877
|
-
};
|
|
878
|
-
});
|
|
879
|
-
return changed ? { ...body, input } : body;
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
/**
|
|
883
|
-
* Repair a forward-mode input array whose continuation context was lost. When the replay
|
|
884
|
-
* expansion misses (proxy restart, unrecorded prior turn), previous_response_id is stripped
|
|
885
|
-
* (the ChatGPT backend rejects it), so the delta may carry items that reference now-absent
|
|
886
|
-
* prior items and 400 upstream:
|
|
887
|
-
* - `function_call`/`local_shell_call`/`custom_tool_call` without their paired output item
|
|
888
|
-
* ("No tool output found for tool call <call_id>"). A stateless upstream cannot resolve
|
|
889
|
-
* the pair from its own storage, so a placeholder output is synthesized to keep the
|
|
890
|
-
* turn continuable without pretending the result was real. Synthetic outputs are
|
|
891
|
-
* emitted after the complete parallel call batch, in call order alongside any real
|
|
892
|
-
* outputs, so the adjacency normalizer can still recognize the batch as one
|
|
893
|
-
* reasoning-bearing assistant turn (#1477). Gated on
|
|
894
|
-
* `synthesizeMissingCallOutputs` (stateless AND non-forward wires); forward replay keeps
|
|
895
|
-
* fail-closed behavior.
|
|
896
|
-
* - `function_call_output`/`custom_tool_call_output` without their paired call item
|
|
897
|
-
* ("No tool call found for function call output with call_id ..."). Converted to user
|
|
898
|
-
* messages so the result text survives. `function_call_output` also pairs with
|
|
899
|
-
* `local_shell_call` (codex-rs emits shell outputs as function_call_output).
|
|
900
|
-
* - `reasoning` items ("Item 'rs_*' ... was provided without its required following item").
|
|
901
|
-
* Dropped, but only when `dropReasoning` (unexpanded miss): on a replay hit the prior
|
|
902
|
-
* reasoning chain is intact and must be preserved.
|
|
903
|
-
* Runs on every forward request; with intact pairs it returns the original reference.
|
|
904
|
-
*/
|
|
905
|
-
/**
|
|
906
|
-
* Repair a replayed `web_search_call` action that is missing either key.
|
|
907
|
-
*
|
|
908
|
-
* `webSearchAction()` in the bridge now emits both keys, but that only helps items
|
|
909
|
-
* created after the fix. A conversation that already recorded
|
|
910
|
-
* `{type:"search", query:"..."}` or `{type:"search", queries:[...]}` replays that stored
|
|
911
|
-
* item on every subsequent turn. DeepSeek's native Responses parser requires `queries`
|
|
912
|
-
* (#930) and Console Go's validator requires `query` (#3071), so upgrading alone leaves
|
|
913
|
-
* those threads permanently 400ing in one direction or the other. The repair runs both
|
|
914
|
-
* ways.
|
|
915
|
-
*
|
|
916
|
-
* Input items carry a loose schema, so a stored `queries` is not necessarily an array of
|
|
917
|
-
* strings. A partly- or wholly-malformed array is left alone rather than used as a source
|
|
918
|
-
* for the singular field: writing `query: 123` would satisfy the presence check and still
|
|
919
|
-
* fail the validator this repair exists to satisfy, and deriving `query` from
|
|
920
|
-
* `["a", 42]` would satisfy Console Go while leaving DeepSeek to reject the same replay.
|
|
921
|
-
* An empty `queries: []` canonicalizes to the shape the bridge emits for an empty search,
|
|
922
|
-
* keeping an existing `query` when the item has one.
|
|
923
|
-
*
|
|
924
|
-
* Runs on every Responses request, on both `input` items and the `action` nested inside
|
|
925
|
-
* them. Returns the original reference when nothing needs repair, so the common path
|
|
926
|
-
* allocates nothing.
|
|
927
|
-
*/
|
|
928
|
-
function backfillWebSearchQueries(body: unknown): unknown {
|
|
929
|
-
if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
|
|
930
|
-
let changed = false;
|
|
931
|
-
const input = body.input.map(item => {
|
|
932
|
-
if (!isPlainObject(item) || item.type !== "web_search_call") return item;
|
|
933
|
-
const action = item.action;
|
|
934
|
-
if (!isPlainObject(action) || action.type !== "search") return item;
|
|
935
|
-
// Repair whichever side is missing so both strict parsers pass:
|
|
936
|
-
// DeepSeek native Responses requires `queries`; Console Go requires `query`.
|
|
937
|
-
const rep: Record<string, unknown> = { ...action };
|
|
938
|
-
let itemChanged = false;
|
|
939
|
-
const hasQuery = typeof action.query === "string";
|
|
940
|
-
const queries = Array.isArray(action.queries) ? action.queries : undefined;
|
|
941
|
-
if (queries !== undefined && queries.length === 0) {
|
|
942
|
-
// An empty array satisfies neither validator. Canonicalize to the empty-search
|
|
943
|
-
// shape the bridge emits, keeping an existing query rather than discarding it.
|
|
944
|
-
const query = hasQuery ? action.query as string : "";
|
|
945
|
-
rep.query = query;
|
|
946
|
-
rep.queries = [query];
|
|
947
|
-
itemChanged = true;
|
|
948
|
-
} else if (!hasQuery && queries !== undefined) {
|
|
949
|
-
// A plural array is only a usable source for the singular field when EVERY member
|
|
950
|
-
// is a string: deriving `query` from a partly-malformed array would satisfy Console
|
|
951
|
-
// Go while leaving DeepSeek to reject the same replay. Wholly malformed arrays are
|
|
952
|
-
// left untouched — coercing or dropping members would invent semantics the stored
|
|
953
|
-
// item never had.
|
|
954
|
-
if (queries.every(entry => typeof entry === "string")) {
|
|
955
|
-
rep.query = queries[0]; // multi-query item recorded before the fix
|
|
956
|
-
itemChanged = true;
|
|
957
|
-
}
|
|
958
|
-
} else if (hasQuery && queries === undefined) {
|
|
959
|
-
rep.queries = [action.query]; // single-query item recorded before the fix
|
|
960
|
-
itemChanged = true;
|
|
961
|
-
}
|
|
962
|
-
if (itemChanged) changed = true;
|
|
963
|
-
return itemChanged ? { ...item, action: rep } : item;
|
|
964
|
-
});
|
|
965
|
-
return changed ? { ...body, input } : body;
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
function repairOrphanedInputItems(body: unknown, dropReasoning: boolean, synthesizeMissingCallOutputs = false): unknown {
|
|
969
|
-
if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
|
|
970
|
-
const input = body.input;
|
|
971
|
-
|
|
972
|
-
const functionCallIds = new Set<string>();
|
|
973
|
-
const customCallIds = new Set<string>();
|
|
974
|
-
const functionOutputIds = new Set<string>();
|
|
975
|
-
const customOutputIds = new Set<string>();
|
|
976
|
-
for (const item of input) {
|
|
977
|
-
if (!isPlainObject(item) || typeof item.call_id !== "string") continue;
|
|
978
|
-
if (item.type === "function_call" || item.type === "local_shell_call") functionCallIds.add(item.call_id);
|
|
979
|
-
else if (item.type === "custom_tool_call") customCallIds.add(item.call_id);
|
|
980
|
-
else if (item.type === "function_call_output") functionOutputIds.add(item.call_id);
|
|
981
|
-
else if (item.type === "custom_tool_call_output") customOutputIds.add(item.call_id);
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
let changed = false;
|
|
985
|
-
const repaired: unknown[] = [];
|
|
986
|
-
const syntheticKeys = new Set<string>();
|
|
987
|
-
const pendingSyntheticOutputs: unknown[] = [];
|
|
988
|
-
const flushPendingSyntheticOutputs = (): void => {
|
|
989
|
-
if (pendingSyntheticOutputs.length === 0) return;
|
|
990
|
-
repaired.push(...pendingSyntheticOutputs);
|
|
991
|
-
pendingSyntheticOutputs.length = 0;
|
|
992
|
-
};
|
|
993
|
-
for (const item of input) {
|
|
994
|
-
if (!isPlainObject(item)) { flushPendingSyntheticOutputs(); repaired.push(item); continue; }
|
|
995
|
-
if (dropReasoning && item.type === "reasoning") { changed = true; continue; }
|
|
996
|
-
const isFnOutput = item.type === "function_call_output";
|
|
997
|
-
const isCustomOutput = item.type === "custom_tool_call_output";
|
|
998
|
-
if (isFnOutput || isCustomOutput) {
|
|
999
|
-
flushPendingSyntheticOutputs();
|
|
1000
|
-
const callId = typeof item.call_id === "string" ? item.call_id : "";
|
|
1001
|
-
const paired = isFnOutput ? functionCallIds.has(callId) : customCallIds.has(callId);
|
|
1002
|
-
const usableOutput = isRepairableToolOutput(item.output);
|
|
1003
|
-
// A known orphan call is still useful as a labeled user message even when its output is
|
|
1004
|
-
// incomplete. With no call id and no output, preserve the invalid item so validation fails
|
|
1005
|
-
// closed rather than pretending any tool result exists.
|
|
1006
|
-
const knownNullOutput = callId.length > 0 && item.output == null;
|
|
1007
|
-
if (!paired && (knownNullOutput || usableOutput)) {
|
|
1008
|
-
changed = true;
|
|
1009
|
-
repaired.push({
|
|
1010
|
-
type: "message",
|
|
1011
|
-
role: "user",
|
|
1012
|
-
content: orphanedToolOutputContent(item.output, callId),
|
|
1013
|
-
});
|
|
1014
|
-
continue;
|
|
1015
|
-
}
|
|
1016
|
-
}
|
|
1017
|
-
const isFnCall = item.type === "function_call" || item.type === "local_shell_call";
|
|
1018
|
-
const isCustomCall = item.type === "custom_tool_call";
|
|
1019
|
-
if (isFnCall || isCustomCall) {
|
|
1020
|
-
repaired.push(item);
|
|
1021
|
-
if (synthesizeMissingCallOutputs) {
|
|
1022
|
-
const callId = typeof item.call_id === "string" ? item.call_id : "";
|
|
1023
|
-
const hasOutput = isFnCall ? functionOutputIds.has(callId) : customOutputIds.has(callId);
|
|
1024
|
-
if (!hasOutput && callId) {
|
|
1025
|
-
changed = true;
|
|
1026
|
-
const name = typeof item.name === "string" && item.name.length > 0 ? item.name : callId;
|
|
1027
|
-
const text = `[ocx] no tool result was recorded for "${name}"; execution status unknown — do not treat this as success, failure, or user-provided input.`;
|
|
1028
|
-
syntheticKeys.add(`${isFnCall ? "function" : "custom"}:${callId}`);
|
|
1029
|
-
pendingSyntheticOutputs.push(isFnCall
|
|
1030
|
-
? { type: "function_call_output", call_id: callId, output: text }
|
|
1031
|
-
: { type: "custom_tool_call_output", call_id: callId, output: text });
|
|
1032
|
-
}
|
|
1033
|
-
}
|
|
1034
|
-
continue;
|
|
1035
|
-
}
|
|
1036
|
-
flushPendingSyntheticOutputs();
|
|
1037
|
-
repaired.push(item);
|
|
1038
|
-
}
|
|
1039
|
-
flushPendingSyntheticOutputs();
|
|
1040
|
-
|
|
1041
|
-
const callKeyOf = (item: unknown): string | null => {
|
|
1042
|
-
if (!isPlainObject(item) || typeof item.call_id !== "string") return null;
|
|
1043
|
-
if (item.type === "function_call" || item.type === "local_shell_call") return `function:${item.call_id}`;
|
|
1044
|
-
if (item.type === "custom_tool_call") return `custom:${item.call_id}`;
|
|
1045
|
-
return null;
|
|
1046
|
-
};
|
|
1047
|
-
const outputKeyOf = (item: unknown): string | null => {
|
|
1048
|
-
if (!isPlainObject(item) || typeof item.call_id !== "string") return null;
|
|
1049
|
-
if (item.type === "function_call_output") return `function:${item.call_id}`;
|
|
1050
|
-
if (item.type === "custom_tool_call_output") return `custom:${item.call_id}`;
|
|
1051
|
-
return null;
|
|
1052
|
-
};
|
|
1053
|
-
const reorderBatchOutputs = (items: unknown[]): unknown[] => {
|
|
1054
|
-
const ordered: unknown[] = [];
|
|
1055
|
-
const claimedOutputIndexes = new Set<number>();
|
|
1056
|
-
const outputIndexesByKey = new Map<string, { indexes: number[]; offset: number }>();
|
|
1057
|
-
for (let outputIndex = 0; outputIndex < items.length; outputIndex += 1) {
|
|
1058
|
-
const outputKey = outputKeyOf(items[outputIndex]);
|
|
1059
|
-
if (outputKey === null) continue;
|
|
1060
|
-
const bucket = outputIndexesByKey.get(outputKey);
|
|
1061
|
-
if (bucket) bucket.indexes.push(outputIndex);
|
|
1062
|
-
else outputIndexesByKey.set(outputKey, { indexes: [outputIndex], offset: 0 });
|
|
1063
|
-
}
|
|
1064
|
-
let index = 0;
|
|
1065
|
-
while (index < items.length) {
|
|
1066
|
-
if (claimedOutputIndexes.has(index)) { index += 1; continue; }
|
|
1067
|
-
const key = callKeyOf(items[index]);
|
|
1068
|
-
if (key === null) { ordered.push(items[index]); index += 1; continue; }
|
|
1069
|
-
const batch: unknown[] = [];
|
|
1070
|
-
const batchKeys: string[] = [];
|
|
1071
|
-
let cursor = index;
|
|
1072
|
-
while (cursor < items.length) {
|
|
1073
|
-
const nextKey = callKeyOf(items[cursor]);
|
|
1074
|
-
if (nextKey === null) break;
|
|
1075
|
-
batch.push(items[cursor]);
|
|
1076
|
-
batchKeys.push(nextKey);
|
|
1077
|
-
cursor += 1;
|
|
1078
|
-
}
|
|
1079
|
-
const hasSynthetic = batchKeys.some(batchKey => syntheticKeys.has(batchKey));
|
|
1080
|
-
if (!hasSynthetic) {
|
|
1081
|
-
ordered.push(...batch);
|
|
1082
|
-
index = cursor;
|
|
1083
|
-
continue;
|
|
1084
|
-
}
|
|
1085
|
-
const batchOutputs: unknown[] = [];
|
|
1086
|
-
for (const batchKey of batchKeys) {
|
|
1087
|
-
const bucket = outputIndexesByKey.get(batchKey);
|
|
1088
|
-
if (!bucket) continue;
|
|
1089
|
-
while (bucket.offset < bucket.indexes.length && bucket.indexes[bucket.offset]! < cursor) {
|
|
1090
|
-
bucket.offset += 1;
|
|
1091
|
-
}
|
|
1092
|
-
while (bucket.offset < bucket.indexes.length) {
|
|
1093
|
-
const outputIndex = bucket.indexes[bucket.offset]!;
|
|
1094
|
-
bucket.offset += 1;
|
|
1095
|
-
if (claimedOutputIndexes.has(outputIndex)) continue;
|
|
1096
|
-
claimedOutputIndexes.add(outputIndex);
|
|
1097
|
-
batchOutputs.push(items[outputIndex]);
|
|
1098
|
-
break;
|
|
1099
|
-
}
|
|
1100
|
-
}
|
|
1101
|
-
ordered.push(...batch, ...batchOutputs);
|
|
1102
|
-
index = cursor;
|
|
1103
|
-
}
|
|
1104
|
-
return ordered;
|
|
1105
|
-
};
|
|
1106
|
-
|
|
1107
|
-
return changed ? { ...body, input: reorderBatchOutputs(repaired) } : body;
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
/**
|
|
1111
|
-
* Make unambiguous Responses tool batches contiguous for upstream parsers that require it.
|
|
1112
|
-
*
|
|
1113
|
-
* [Decision Log]
|
|
1114
|
-
* - 목적과 의도: Keep Codex hook-injected developer context without splitting a parallel tool-call turn away from its reasoning or making a strict upstream reject matching results.
|
|
1115
|
-
* - 기존 구현 및 제약 조건: The orphan repair verifies only pair presence, while the original pair-by-pair reorder turned `reasoning, call A, call B, output A, output B` into two assistant turns and made DeepSeek reject call B for missing reasoning (#1477).
|
|
1116
|
-
* - 검토한 주요 대안: Disable parallel calls (DeepSeek always enables them); duplicate reasoning per call; reorder each pair; or normalize the complete unambiguous call batch.
|
|
1117
|
-
* - 선택한 방식: Treat calls emitted before the first matched result as one batch, emit all calls followed by their matched outputs, and preserve intervening non-tool items immediately after the batch.
|
|
1118
|
-
* - 다른 대안 대신 이 방식을 선택한 이유: Batch normalization matches the Responses parallel-call shape without fabricating reasoning, while the provider gate and unique-pair requirement keep the blast radius narrow.
|
|
1119
|
-
* - 장점, 단점 및 영향: DeepSeek keeps one reasoning-bearing assistant turn for parallel calls and still accepts hook-interleaved single calls; tolerant providers stay byte/order equivalent, and duplicate, missing, or backwards call/result pairs are not guessed.
|
|
1120
|
-
*/
|
|
1121
|
-
function normalizeResponsesToolResultAdjacency(body: unknown): unknown {
|
|
1122
|
-
if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
|
|
1123
|
-
const input = body.input;
|
|
1124
|
-
const calls = new Map<string, number[]>();
|
|
1125
|
-
const outputs = new Map<string, number[]>();
|
|
1126
|
-
|
|
1127
|
-
const appendIndex = (map: Map<string, number[]>, key: string, index: number): void => {
|
|
1128
|
-
const existing = map.get(key);
|
|
1129
|
-
if (existing) existing.push(index);
|
|
1130
|
-
else map.set(key, [index]);
|
|
1131
|
-
};
|
|
1132
|
-
|
|
1133
|
-
for (let index = 0; index < input.length; index += 1) {
|
|
1134
|
-
const item = input[index];
|
|
1135
|
-
if (!isPlainObject(item) || typeof item.call_id !== "string" || item.call_id.length === 0) continue;
|
|
1136
|
-
if (item.type === "function_call" || item.type === "local_shell_call") {
|
|
1137
|
-
appendIndex(calls, `function:${item.call_id}`, index);
|
|
1138
|
-
} else if (item.type === "custom_tool_call") {
|
|
1139
|
-
appendIndex(calls, `custom:${item.call_id}`, index);
|
|
1140
|
-
} else if (item.type === "function_call_output") {
|
|
1141
|
-
appendIndex(outputs, `function:${item.call_id}`, index);
|
|
1142
|
-
} else if (item.type === "custom_tool_call_output") {
|
|
1143
|
-
appendIndex(outputs, `custom:${item.call_id}`, index);
|
|
1144
|
-
}
|
|
1145
|
-
}
|
|
1146
|
-
|
|
1147
|
-
const pairs: Array<{ callIndex: number; outputIndex: number }> = [];
|
|
1148
|
-
for (const [key, callIndices] of calls) {
|
|
1149
|
-
const outputIndices = outputs.get(key);
|
|
1150
|
-
if (!outputIndices) return body;
|
|
1151
|
-
if (callIndices.length !== 1 || outputIndices.length !== 1) return body;
|
|
1152
|
-
const callIndex = callIndices[0]!;
|
|
1153
|
-
const outputIndex = outputIndices[0]!;
|
|
1154
|
-
if (outputIndex <= callIndex) return body;
|
|
1155
|
-
pairs.push({ callIndex, outputIndex });
|
|
1156
|
-
}
|
|
1157
|
-
// Reject any collected output that lacks exactly one matching call. A lone or
|
|
1158
|
-
// duplicated output is ambiguous, and normalizing on top of it could sever a
|
|
1159
|
-
// result from the reasoning-bearing call turn it belongs to.
|
|
1160
|
-
for (const [key, outputIndices] of outputs) {
|
|
1161
|
-
const callIndices = calls.get(key);
|
|
1162
|
-
if (!callIndices || callIndices.length !== 1 || outputIndices.length !== 1) return body;
|
|
1163
|
-
}
|
|
1164
|
-
pairs.sort((left, right) => left.callIndex - right.callIndex);
|
|
1165
|
-
|
|
1166
|
-
const movedIndices = new Set<number>();
|
|
1167
|
-
const batchAt = new Map<number, unknown[]>();
|
|
1168
|
-
for (let cursor = 0; cursor < pairs.length;) {
|
|
1169
|
-
const group = [pairs[cursor]!];
|
|
1170
|
-
let firstOutputIndex = pairs[cursor]!.outputIndex;
|
|
1171
|
-
let next = cursor + 1;
|
|
1172
|
-
while (next < pairs.length && pairs[next]!.callIndex < firstOutputIndex) {
|
|
1173
|
-
group.push(pairs[next]!);
|
|
1174
|
-
firstOutputIndex = Math.min(firstOutputIndex, pairs[next]!.outputIndex);
|
|
1175
|
-
next += 1;
|
|
1176
|
-
}
|
|
1177
|
-
|
|
1178
|
-
// Within one reasoning turn the outputs must appear in the same order as their
|
|
1179
|
-
// calls. If they are reversed, normalizing would fabricate a new output order;
|
|
1180
|
-
// leave the ambiguous history untouched instead.
|
|
1181
|
-
for (let groupIndex = 1; groupIndex < group.length; groupIndex += 1) {
|
|
1182
|
-
if (group[groupIndex]!.outputIndex < group[groupIndex - 1]!.outputIndex) return body;
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
const batch = [
|
|
1186
|
-
...group.map(pair => input[pair.callIndex]),
|
|
1187
|
-
...group.map(pair => input[pair.outputIndex]),
|
|
1188
|
-
];
|
|
1189
|
-
const anchor = group[0]!.callIndex;
|
|
1190
|
-
const alreadyContiguous = batch.every((item, offset) => input[anchor + offset] === item);
|
|
1191
|
-
if (!alreadyContiguous) {
|
|
1192
|
-
batchAt.set(anchor, batch);
|
|
1193
|
-
for (const pair of group) {
|
|
1194
|
-
movedIndices.add(pair.callIndex);
|
|
1195
|
-
movedIndices.add(pair.outputIndex);
|
|
1196
|
-
}
|
|
1197
|
-
}
|
|
1198
|
-
cursor = next;
|
|
1199
|
-
}
|
|
1200
|
-
if (batchAt.size === 0) return body;
|
|
1201
|
-
|
|
1202
|
-
const normalized: unknown[] = [];
|
|
1203
|
-
for (let index = 0; index < input.length; index += 1) {
|
|
1204
|
-
const batch = batchAt.get(index);
|
|
1205
|
-
if (batch) normalized.push(...batch);
|
|
1206
|
-
if (!movedIndices.has(index)) normalized.push(input[index]);
|
|
1207
|
-
}
|
|
1208
|
-
return { ...body, input: normalized };
|
|
1209
|
-
}
|
|
1210
|
-
|
|
1211
|
-
/**
|
|
1212
|
-
* Remove `previous_response_id` before forwarding. Two triggers:
|
|
1213
|
-
* - the proxy expanded the request into a full input replay (the id is now redundant), or
|
|
1214
|
-
* - the target is the ChatGPT backend (`authMode: "forward"`), whose Codex REST endpoint
|
|
1215
|
-
* categorically rejects the parameter with `{"detail":"Unsupported parameter:
|
|
1216
|
-
* previous_response_id"}` (strict allowlist; it also rejects `metadata` and
|
|
1217
|
-
* `max_output_tokens`). Codex only sends the id on WS turns, and ocx converts those to
|
|
1218
|
-
* internal HTTP requests, so forwarding it upstream is a guaranteed 400 — stripping is
|
|
1219
|
-
* strictly better even when the local replay state missed. API-key mode keeps the field on
|
|
1220
|
-
* unexpanded requests: the platform `/v1/responses` supports real server-side storage.
|
|
1221
|
-
*/
|
|
1222
|
-
function stripPreviousResponseId(body: unknown, strip: boolean): unknown {
|
|
1223
|
-
if (!strip || !isPlainObject(body) || !Object.prototype.hasOwnProperty.call(body, "previous_response_id")) return body;
|
|
1224
|
-
const { previous_response_id: _previousResponseId, ...rest } = body;
|
|
1225
|
-
return rest;
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
/** Apply the settled tier only to a fresh outbound object; `_rawBody` remains caller-owned. */
|
|
1229
|
-
function applyTierDecisionToResponsesBody(body: unknown, decision: TierDecision | undefined): unknown {
|
|
1230
|
-
if (!decision || decision.kind === "forward-caller" || !isPlainObject(body)) return body;
|
|
1231
|
-
const next: Record<string, unknown> = { ...body };
|
|
1232
|
-
if (decision.kind === "set") next.service_tier = decision.value;
|
|
1233
|
-
else delete next.service_tier;
|
|
1234
|
-
return next;
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
/**
|
|
1238
|
-
* Drop request parameters a stateless Responses upstream cannot implement, and pin
|
|
1239
|
-
* `store` false.
|
|
1240
|
-
*
|
|
1241
|
-
* `previous_response_id` is listed here as well as in `stripPreviousResponseId`
|
|
1242
|
-
* because that helper's strip is conditional on replay expansion, and it keeps the
|
|
1243
|
-
* field for API-key providers on the premise that the platform offers real
|
|
1244
|
-
* server-side storage. DeepSeek documents the opposite: "the API is stateless:
|
|
1245
|
-
* responses and conversations are not stored on the server", so the field can never
|
|
1246
|
-
* be honoured regardless of expansion state.
|
|
1247
|
-
*
|
|
1248
|
-
* `prompt` is a reference to a server-stored prompt template — the most stateful
|
|
1249
|
-
* field in the accepted schema.
|
|
1250
|
-
*
|
|
1251
|
-
* `service_tier` is deliberately NOT dropped: the final TierDecision is applied to a
|
|
1252
|
-
* detached outbound body before this sanitizer chain, and silently deleting a configured knob is
|
|
1253
|
-
* worse than forwarding a parameter the upstream ignores.
|
|
1254
|
-
*
|
|
1255
|
-
* MUST run before the composed sanitize chain below: `stripItemIdsWhenUnstored` keys
|
|
1256
|
-
* off `store === false`, and a stateless upstream cannot resolve a stored item id.
|
|
1257
|
-
* Returns a copy, so `parsed._rawBody` keeps the client's original `store` value and
|
|
1258
|
-
* the local replay cache still records the turn.
|
|
1259
|
-
*/
|
|
1260
|
-
function stripStatefulResponsesParams(body: unknown): unknown {
|
|
1261
|
-
if (!isPlainObject(body)) return body;
|
|
1262
|
-
const drop = ["previous_response_id", "conversation", "background", "metadata", "prompt"] as const;
|
|
1263
|
-
const present = drop.some(key => Object.prototype.hasOwnProperty.call(body, key));
|
|
1264
|
-
if (!present && body.store === false) return body;
|
|
1265
|
-
const next: Record<string, unknown> = { ...body };
|
|
1266
|
-
for (const key of drop) delete next[key];
|
|
1267
|
-
next.store = false;
|
|
1268
|
-
return next;
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
/**
|
|
1272
|
-
* Remove top-level parameters the ChatGPT backend (`authMode: "forward"`) rejects
|
|
1273
|
-
* with `{"detail":"Unsupported parameter: …"}` (strict allowlist). Codex CLI never
|
|
1274
|
-
* sends these — it controls output length via `reasoning.effort` — but third-party
|
|
1275
|
-
* Responses API clients (GJC, SDK wrappers) include `max_output_tokens` per the
|
|
1276
|
-
* public spec. `metadata` is likewise absent from the allowlist. No-op when the
|
|
1277
|
-
* body carries neither field, keeping the common Codex path allocation-free.
|
|
1278
|
-
*/
|
|
1279
|
-
function stripUnsupportedForwardParams(body: unknown): unknown {
|
|
1280
|
-
if (!isPlainObject(body)) return body;
|
|
1281
|
-
const hasMot = Object.prototype.hasOwnProperty.call(body, "max_output_tokens");
|
|
1282
|
-
const hasMeta = Object.prototype.hasOwnProperty.call(body, "metadata");
|
|
1283
|
-
if (!hasMot && !hasMeta) return body;
|
|
1284
|
-
const { max_output_tokens: _mot, metadata: _meta, ...rest } = body;
|
|
1285
|
-
return rest;
|
|
1286
|
-
}
|
|
1287
|
-
|
|
1288
|
-
/** Sampling controls the canonical ChatGPT backend rejects; other forward gateways accept them. */
|
|
1289
|
-
const CANONICAL_FORWARD_UNSUPPORTED_SAMPLING = ["temperature", "top_p", "stop", "user"] as const;
|
|
1290
|
-
|
|
1291
|
-
/**
|
|
1292
|
-
* Remove sampling controls only the canonical ChatGPT backend rejects.
|
|
1293
|
-
*
|
|
1294
|
-
* A translated Chat turn used to lose these at the Chat ingress for every provider on
|
|
1295
|
-
* the `openai-responses` adapter, which silently discarded caller intent on generic
|
|
1296
|
-
* key gateways that accept them. Deciding at the ingress was also unsound for combo
|
|
1297
|
-
* and policy routes, whose concrete child is chosen later — so the decision belongs
|
|
1298
|
-
* here, on the provider that actually receives the body.
|
|
1299
|
-
*
|
|
1300
|
-
* Returns a copy and never mutates, so `parsed._rawBody` stays caller-owned, and
|
|
1301
|
-
* no-ops when the body carries none of these keys.
|
|
1302
|
-
*/
|
|
1303
|
-
export function stripCanonicalForwardSamplingParams(body: unknown): unknown {
|
|
1304
|
-
if (!isPlainObject(body)) return body;
|
|
1305
|
-
if (!CANONICAL_FORWARD_UNSUPPORTED_SAMPLING.some(key => Object.prototype.hasOwnProperty.call(body, key))) {
|
|
1306
|
-
return body;
|
|
1307
|
-
}
|
|
1308
|
-
const next: Record<string, unknown> = { ...body };
|
|
1309
|
-
for (const key of CANONICAL_FORWARD_UNSUPPORTED_SAMPLING) delete next[key];
|
|
1310
|
-
return next;
|
|
1311
|
-
}
|
|
1312
|
-
|
|
1313
|
-
/** Return the lossless text represented by one system message, or null when it is multimodal. */
|
|
1314
|
-
function canonicalForwardSystemText(item: Record<string, unknown>): string | null {
|
|
1315
|
-
const content = item.content;
|
|
1316
|
-
if (content === undefined) return "";
|
|
1317
|
-
if (typeof content === "string") return content;
|
|
1318
|
-
if (!Array.isArray(content)) return null;
|
|
1319
|
-
let text = "";
|
|
1320
|
-
for (const block of content) {
|
|
1321
|
-
if (!isPlainObject(block)) return null;
|
|
1322
|
-
if (block.type !== "input_text" && block.type !== "text") return null;
|
|
1323
|
-
if (typeof block.text !== "string") return null;
|
|
1324
|
-
text += block.text;
|
|
1325
|
-
}
|
|
1326
|
-
return text;
|
|
1327
|
-
}
|
|
1328
|
-
|
|
1329
|
-
/** Only message items may carry privileged system instructions. */
|
|
1330
|
-
function isCanonicalForwardSystemMessage(item: unknown): item is Record<string, unknown> {
|
|
1331
|
-
return isPlainObject(item)
|
|
1332
|
-
&& (item.type === undefined || item.type === "message")
|
|
1333
|
-
&& item.role === "system";
|
|
1334
|
-
}
|
|
1335
|
-
|
|
1336
|
-
/**
|
|
1337
|
-
* The public Responses API accepts input system messages and `truncation`, but the canonical
|
|
1338
|
-
* ChatGPT Codex forward endpoint rejects both. Fold only fully textual system messages into the
|
|
1339
|
-
* existing top-level instructions and remove the unsupported flag at this destination boundary.
|
|
1340
|
-
*
|
|
1341
|
-
* The fold is atomic: if any system message contains a non-text block, keep every message in
|
|
1342
|
-
* place so the proxy never silently drops multimodal content. The backend may still reject that
|
|
1343
|
-
* unsupported shape, but it will not receive a partially rewritten prompt.
|
|
1344
|
-
*/
|
|
1345
|
-
function normalizeCanonicalForwardPromptEnvelope(body: unknown): unknown {
|
|
1346
|
-
if (!isPlainObject(body)) return body;
|
|
1347
|
-
const stripTruncation = Object.hasOwn(body, "truncation");
|
|
1348
|
-
const input = Array.isArray(body.input) ? body.input : undefined;
|
|
1349
|
-
if (!input) {
|
|
1350
|
-
if (!stripTruncation) return body;
|
|
1351
|
-
const { truncation: _truncation, ...rest } = body;
|
|
1352
|
-
return rest;
|
|
1353
|
-
}
|
|
1354
|
-
|
|
1355
|
-
const foldedText: string[] = [];
|
|
1356
|
-
let sawSystemMessage = false;
|
|
1357
|
-
let canFoldAllSystemMessages = true;
|
|
1358
|
-
for (const item of input) {
|
|
1359
|
-
if (!isCanonicalForwardSystemMessage(item)) continue;
|
|
1360
|
-
sawSystemMessage = true;
|
|
1361
|
-
const text = canonicalForwardSystemText(item);
|
|
1362
|
-
if (text === null) {
|
|
1363
|
-
canFoldAllSystemMessages = false;
|
|
1364
|
-
break;
|
|
1365
|
-
}
|
|
1366
|
-
foldedText.push(text);
|
|
1367
|
-
}
|
|
1368
|
-
if (!stripTruncation && (!sawSystemMessage || !canFoldAllSystemMessages)) return body;
|
|
1369
|
-
|
|
1370
|
-
const next: Record<string, unknown> = { ...body };
|
|
1371
|
-
if (stripTruncation) delete next.truncation;
|
|
1372
|
-
if (sawSystemMessage && canFoldAllSystemMessages) {
|
|
1373
|
-
next.input = input.filter(item => !isCanonicalForwardSystemMessage(item));
|
|
1374
|
-
const folded = foldedText.join("\n\n");
|
|
1375
|
-
if (folded !== "") {
|
|
1376
|
-
const existing = typeof body.instructions === "string" ? body.instructions : "";
|
|
1377
|
-
next.instructions = existing !== "" ? `${existing}\n\n${folded}` : folded;
|
|
1378
|
-
}
|
|
1379
|
-
}
|
|
1380
|
-
return next;
|
|
1381
|
-
}
|
|
1382
|
-
|
|
1383
|
-
const POSIT_CACHE_MARKER_MAX_DEPTH = 64;
|
|
1384
|
-
const POSIT_CACHE_MARKER_MAX_NODES = 100_000;
|
|
1385
|
-
|
|
1386
|
-
type PromptCacheMarkerRewrite = {
|
|
1387
|
-
value: unknown;
|
|
1388
|
-
changed: boolean;
|
|
1389
|
-
complete: boolean;
|
|
1390
|
-
};
|
|
1391
|
-
|
|
1392
|
-
/**
|
|
1393
|
-
* Remove Posit/Anthropic-style prompt-cache markers without trusting request nesting. The walk
|
|
1394
|
-
* aborts atomically when its depth or node budget is exceeded, so a hostile extension object can
|
|
1395
|
-
* neither overflow the stack nor receive a partially rewritten subtree.
|
|
1396
|
-
*/
|
|
1397
|
-
function stripPromptCacheBreakpoints(
|
|
1398
|
-
value: unknown,
|
|
1399
|
-
state: { nodes: number },
|
|
1400
|
-
depth = 0,
|
|
1401
|
-
): PromptCacheMarkerRewrite {
|
|
1402
|
-
state.nodes += 1;
|
|
1403
|
-
if (depth > POSIT_CACHE_MARKER_MAX_DEPTH || state.nodes > POSIT_CACHE_MARKER_MAX_NODES) {
|
|
1404
|
-
return { value, changed: false, complete: false };
|
|
1405
|
-
}
|
|
1406
|
-
if (Array.isArray(value)) {
|
|
1407
|
-
let changed = false;
|
|
1408
|
-
const next: unknown[] = [];
|
|
1409
|
-
for (const entry of value) {
|
|
1410
|
-
const rewritten = stripPromptCacheBreakpoints(entry, state, depth + 1);
|
|
1411
|
-
if (!rewritten.complete) return { value, changed: false, complete: false };
|
|
1412
|
-
changed ||= rewritten.changed;
|
|
1413
|
-
next.push(rewritten.value);
|
|
1414
|
-
}
|
|
1415
|
-
return { value: changed ? next : value, changed, complete: true };
|
|
1416
|
-
}
|
|
1417
|
-
if (!isPlainObject(value)) return { value, changed: false, complete: true };
|
|
1418
|
-
|
|
1419
|
-
let changed = Object.hasOwn(value, "prompt_cache_breakpoint");
|
|
1420
|
-
const next: Record<string, unknown> = {};
|
|
1421
|
-
for (const [key, entry] of Object.entries(value)) {
|
|
1422
|
-
if (key === "prompt_cache_breakpoint") continue;
|
|
1423
|
-
const rewritten = stripPromptCacheBreakpoints(entry, state, depth + 1);
|
|
1424
|
-
if (!rewritten.complete) return { value, changed: false, complete: false };
|
|
1425
|
-
changed ||= rewritten.changed;
|
|
1426
|
-
next[key] = rewritten.value;
|
|
1427
|
-
}
|
|
1428
|
-
return { value: changed ? next : value, changed, complete: true };
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
|
-
/**
|
|
1432
|
-
* Posit Assistant can replay client-only cache markers and stored-item references on a
|
|
1433
|
-
* `store: false` continuation. The canonical ChatGPT Codex backend rejects both. Remove the
|
|
1434
|
-
* markers recursively and drop only `item_reference` rows that cannot name persisted state;
|
|
1435
|
-
* ordinary item ids are handled later by stripItemIdsWhenUnstored and tool call_id pairs remain.
|
|
1436
|
-
*/
|
|
1437
|
-
function normalizeCanonicalForwardContinuationEnvelope(body: unknown): unknown {
|
|
1438
|
-
if (!isPlainObject(body) || !Array.isArray(body.input)) return body;
|
|
1439
|
-
let input: unknown[] = body.input;
|
|
1440
|
-
let changed = false;
|
|
1441
|
-
if (body.store === false) {
|
|
1442
|
-
const withoutReferences = input.filter(item => !isPlainObject(item) || item.type !== "item_reference");
|
|
1443
|
-
if (withoutReferences.length !== input.length) {
|
|
1444
|
-
input = withoutReferences;
|
|
1445
|
-
changed = true;
|
|
1446
|
-
}
|
|
1447
|
-
}
|
|
1448
|
-
|
|
1449
|
-
const markerRewrite = stripPromptCacheBreakpoints(input, { nodes: 0 });
|
|
1450
|
-
if (markerRewrite.complete && markerRewrite.changed) {
|
|
1451
|
-
input = markerRewrite.value as unknown[];
|
|
1452
|
-
changed = true;
|
|
1453
|
-
}
|
|
1454
|
-
return changed ? { ...body, input } : body;
|
|
1455
|
-
}
|
|
1456
|
-
|
|
1457
|
-
const IMAGE_GEN_NAMESPACE = "image_gen";
|
|
1458
|
-
const HOSTED_IMAGE_GENERATION_TOOL = "image_generation";
|
|
1459
|
-
const IMAGE_GEN_DOTTED_PREFIX = `${IMAGE_GEN_NAMESPACE}.`;
|
|
1460
|
-
const IMAGE_GEN_WIRE_PREFIX = `${IMAGE_GEN_NAMESPACE}__`;
|
|
1461
|
-
|
|
1462
|
-
/** Remove a supported client prefix before constructing the canonical image-gen wire alias. */
|
|
1463
|
-
function imageGenLocalName(name: string): string {
|
|
1464
|
-
if (name.startsWith(IMAGE_GEN_DOTTED_PREFIX)) return name.slice(IMAGE_GEN_DOTTED_PREFIX.length);
|
|
1465
|
-
if (name.startsWith(IMAGE_GEN_WIRE_PREFIX)) return name.slice(IMAGE_GEN_WIRE_PREFIX.length);
|
|
1466
|
-
return name;
|
|
1467
|
-
}
|
|
1468
|
-
|
|
1469
|
-
/** Build the flat public-Responses name used only on the upstream wire. */
|
|
1470
|
-
function imageGenWireName(name: string): string {
|
|
1471
|
-
return namespacedToolName(IMAGE_GEN_NAMESPACE, imageGenLocalName(name));
|
|
1472
|
-
}
|
|
1473
|
-
|
|
1474
|
-
/** Match client image-gen declarations across namespace, legacy dotted, and canonical wire forms. */
|
|
1475
|
-
function isImageGenClientName(name: string): boolean {
|
|
1476
|
-
return name === IMAGE_GEN_NAMESPACE
|
|
1477
|
-
|| name.startsWith(IMAGE_GEN_DOTTED_PREFIX)
|
|
1478
|
-
|| name.startsWith(IMAGE_GEN_WIRE_PREFIX);
|
|
1479
|
-
}
|
|
1480
|
-
|
|
1481
|
-
/** Identify declarations that should activate image-gen request normalization. */
|
|
1482
|
-
function declaresImageGenClientTool(tool: unknown): boolean {
|
|
1483
|
-
if (!isPlainObject(tool) || typeof tool.name !== "string") return false;
|
|
1484
|
-
if (tool.type === "namespace") return tool.name === IMAGE_GEN_NAMESPACE;
|
|
1485
|
-
return isImageGenClientName(tool.name);
|
|
1486
|
-
}
|
|
1487
|
-
|
|
1488
|
-
/** Rewrite client image-gen selectors to the hosted tool without widening caller restrictions. */
|
|
1489
|
-
function preferHostedImageGenToolChoice(toolChoice: unknown): unknown {
|
|
1490
|
-
if (!isPlainObject(toolChoice)) return toolChoice;
|
|
1491
|
-
if ((toolChoice.type === "function" || toolChoice.type === "custom") && typeof toolChoice.name === "string") {
|
|
1492
|
-
return isImageGenClientName(toolChoice.name) ? { type: HOSTED_IMAGE_GENERATION_TOOL } : toolChoice;
|
|
1493
|
-
}
|
|
1494
|
-
if (toolChoice.type !== "allowed_tools" || !Array.isArray(toolChoice.tools)) return toolChoice;
|
|
1495
|
-
const hasHostedImageTool = toolChoice.tools.some(tool => isPlainObject(tool) && tool.type === HOSTED_IMAGE_GENERATION_TOOL);
|
|
1496
|
-
let changed = false;
|
|
1497
|
-
let addedHostedImageTool = false;
|
|
1498
|
-
const tools: unknown[] = [];
|
|
1499
|
-
for (const tool of toolChoice.tools) {
|
|
1500
|
-
const isClientImageTool = isPlainObject(tool)
|
|
1501
|
-
&& (tool.type === "function" || tool.type === "custom")
|
|
1502
|
-
&& typeof tool.name === "string"
|
|
1503
|
-
&& isImageGenClientName(tool.name);
|
|
1504
|
-
if (!isClientImageTool) {
|
|
1505
|
-
tools.push(tool);
|
|
1506
|
-
continue;
|
|
1507
|
-
}
|
|
1508
|
-
changed = true;
|
|
1509
|
-
if (!hasHostedImageTool && !addedHostedImageTool) {
|
|
1510
|
-
tools.push({ type: HOSTED_IMAGE_GENERATION_TOOL });
|
|
1511
|
-
addedHostedImageTool = true;
|
|
1512
|
-
}
|
|
1513
|
-
}
|
|
1514
|
-
return changed ? { ...toolChoice, tools } : toolChoice;
|
|
1515
|
-
}
|
|
1516
|
-
|
|
1517
|
-
/**
|
|
1518
|
-
* Some Responses-compatible gateways reserve the hosted image namespace even when the request
|
|
1519
|
-
* does not explicitly declare `image_generation`. For an explicitly configured model, remove only
|
|
1520
|
-
* colliding client declarations so the gateway's hosted tool can take precedence.
|
|
1521
|
-
*/
|
|
1522
|
-
function preferConfiguredHostedTools(
|
|
1523
|
-
body: unknown,
|
|
1524
|
-
provider: OcxProviderConfig,
|
|
1525
|
-
modelId: string,
|
|
1526
|
-
selectedModelId?: string,
|
|
1527
|
-
): unknown {
|
|
1528
|
-
// A virtual model's advertised id takes precedence over its resolved wire-model id.
|
|
1529
|
-
// Read own properties only: a routed model id of `constructor`/`toString` would
|
|
1530
|
-
// otherwise resolve to an inherited Object.prototype function and throw on the
|
|
1531
|
-
// membership test below, failing the request before it is dispatched.
|
|
1532
|
-
const preferenceMap = provider.modelPreferHostedTools;
|
|
1533
|
-
const ownPreference = (key: string | undefined): string[] | undefined => {
|
|
1534
|
-
if (!key || !preferenceMap || !Object.prototype.hasOwnProperty.call(preferenceMap, key)) return undefined;
|
|
1535
|
-
const entry = preferenceMap[key];
|
|
1536
|
-
return Array.isArray(entry) ? entry : undefined;
|
|
1537
|
-
};
|
|
1538
|
-
const preferredTools = ownPreference(selectedModelId) ?? ownPreference(modelId);
|
|
1539
|
-
if (!preferredTools?.includes(HOSTED_IMAGE_GENERATION_TOOL) || !isPlainObject(body)) return body;
|
|
1540
|
-
|
|
1541
|
-
const stripGroup = (tools: unknown[]): unknown[] => {
|
|
1542
|
-
const filtered = tools.filter(tool => !declaresImageGenClientTool(tool));
|
|
1543
|
-
return filtered.length === tools.length ? tools : filtered;
|
|
1544
|
-
};
|
|
1545
|
-
|
|
1546
|
-
let changed = false;
|
|
1547
|
-
let tools = body.tools;
|
|
1548
|
-
let strippedTopLevelImageGenTool = false;
|
|
1549
|
-
if (Array.isArray(body.tools)) {
|
|
1550
|
-
tools = stripGroup(body.tools);
|
|
1551
|
-
strippedTopLevelImageGenTool = tools !== body.tools;
|
|
1552
|
-
changed ||= strippedTopLevelImageGenTool;
|
|
1553
|
-
}
|
|
1554
|
-
|
|
1555
|
-
let input = body.input;
|
|
1556
|
-
const strippedAdditionalToolsIndices = new Set<number>();
|
|
1557
|
-
if (Array.isArray(body.input)) {
|
|
1558
|
-
let nestedChanged = false;
|
|
1559
|
-
const mappedInput = body.input.map((item, index) => {
|
|
1560
|
-
if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item;
|
|
1561
|
-
const nestedTools = stripGroup(item.tools);
|
|
1562
|
-
if (nestedTools === item.tools) return item;
|
|
1563
|
-
strippedAdditionalToolsIndices.add(index);
|
|
1564
|
-
nestedChanged = true;
|
|
1565
|
-
return { ...item, tools: nestedTools };
|
|
1566
|
-
});
|
|
1567
|
-
if (nestedChanged) {
|
|
1568
|
-
input = mappedInput;
|
|
1569
|
-
changed = true;
|
|
1570
|
-
}
|
|
1571
|
-
}
|
|
1572
|
-
|
|
1573
|
-
const hasToolChoice = Object.hasOwn(body, "tool_choice");
|
|
1574
|
-
const toolChoice = hasToolChoice ? preferHostedImageGenToolChoice(body.tool_choice) : body.tool_choice;
|
|
1575
|
-
const toolChoiceChanged = hasToolChoice && toolChoice !== body.tool_choice;
|
|
1576
|
-
const hasHostedImageGenTool = (toolGroup: unknown): boolean => Array.isArray(toolGroup)
|
|
1577
|
-
&& toolGroup.some(tool => isPlainObject(tool) && tool.type === HOSTED_IMAGE_GENERATION_TOOL);
|
|
1578
|
-
const hasHostedImageGenDeclaration = hasHostedImageGenTool(tools)
|
|
1579
|
-
|| (Array.isArray(input) && input.some(item => isPlainObject(item)
|
|
1580
|
-
&& item.type === "additional_tools"
|
|
1581
|
-
&& hasHostedImageGenTool(item.tools)));
|
|
1582
|
-
if ((strippedTopLevelImageGenTool || strippedAdditionalToolsIndices.size > 0) && !hasHostedImageGenDeclaration) {
|
|
1583
|
-
if (strippedTopLevelImageGenTool && Array.isArray(tools)) {
|
|
1584
|
-
tools = [...tools, { type: HOSTED_IMAGE_GENERATION_TOOL }];
|
|
1585
|
-
} else if (strippedAdditionalToolsIndices.size > 0 && Array.isArray(input)) {
|
|
1586
|
-
// Restore into the FIRST stripped container only. Tool declarations are
|
|
1587
|
-
// request-scoped, not container-scoped — the containers are separate carriers for
|
|
1588
|
-
// one tool set, so a single hosted declaration covers the request. An earlier
|
|
1589
|
-
// revision restored into every stripped container and put `image_generation` on
|
|
1590
|
-
// the wire twice; review caught it.
|
|
1591
|
-
const firstStripped = Math.min(...strippedAdditionalToolsIndices);
|
|
1592
|
-
input = input.map((item, index) => index === firstStripped
|
|
1593
|
-
&& isPlainObject(item)
|
|
1594
|
-
&& Array.isArray(item.tools)
|
|
1595
|
-
? { ...item, tools: [...item.tools, { type: HOSTED_IMAGE_GENERATION_TOOL }] }
|
|
1596
|
-
: item);
|
|
1597
|
-
}
|
|
1598
|
-
}
|
|
1599
|
-
changed ||= toolChoiceChanged;
|
|
1600
|
-
if (!changed) return body;
|
|
1601
|
-
const next: Record<string, unknown> = {
|
|
1602
|
-
...body,
|
|
1603
|
-
...(Array.isArray(body.tools) ? { tools } : {}),
|
|
1604
|
-
...(Array.isArray(body.input) ? { input } : {}),
|
|
1605
|
-
};
|
|
1606
|
-
if (toolChoiceChanged) next.tool_choice = toolChoice;
|
|
1607
|
-
return next;
|
|
1608
|
-
}
|
|
1609
|
-
|
|
1610
|
-
/**
|
|
1611
|
-
* Lower one complete Codex image-gen namespace to public Responses function tools.
|
|
1612
|
-
*
|
|
1613
|
-
* The public API reserves the `image_gen` namespace and restricts function names to a flat safe
|
|
1614
|
-
* alphabet. `image_gen__<tool>` is therefore an upstream-only alias; client-facing responses are
|
|
1615
|
-
* restored to explicit `{ namespace: "image_gen", name: "<tool>" }` calls by the server. Only a
|
|
1616
|
-
* non-empty namespace containing named function tools is safe to lower. Malformed, empty, and
|
|
1617
|
-
* future namespace shapes stay untouched instead of silently losing client capabilities.
|
|
1618
|
-
*/
|
|
1619
|
-
function flattenImageGenNamespace(tool: unknown): Record<string, unknown>[] | undefined {
|
|
1620
|
-
if (
|
|
1621
|
-
!isPlainObject(tool)
|
|
1622
|
-
|| tool.type !== "namespace"
|
|
1623
|
-
|| tool.name !== IMAGE_GEN_NAMESPACE
|
|
1624
|
-
|| !Array.isArray(tool.tools)
|
|
1625
|
-
|| tool.tools.length === 0
|
|
1626
|
-
) return undefined;
|
|
1627
|
-
|
|
1628
|
-
for (const innerTool of tool.tools) {
|
|
1629
|
-
if (
|
|
1630
|
-
!isPlainObject(innerTool)
|
|
1631
|
-
|| innerTool.type !== "function"
|
|
1632
|
-
|| typeof innerTool.name !== "string"
|
|
1633
|
-
|| innerTool.name.length === 0
|
|
1634
|
-
) return undefined;
|
|
1635
|
-
}
|
|
1636
|
-
|
|
1637
|
-
return tool.tools.map(innerTool => {
|
|
1638
|
-
const functionTool = innerTool as Record<string, unknown> & { name: string };
|
|
1639
|
-
return {
|
|
1640
|
-
...functionTool,
|
|
1641
|
-
name: imageGenWireName(functionTool.name),
|
|
1642
|
-
};
|
|
1643
|
-
});
|
|
1644
|
-
}
|
|
1645
|
-
|
|
1646
|
-
/** Convert a legacy dotted function declaration while preserving all other function metadata. */
|
|
1647
|
-
function normalizeFlatImageGenFunction(tool: unknown): unknown {
|
|
1648
|
-
if (
|
|
1649
|
-
!isPlainObject(tool)
|
|
1650
|
-
|| tool.type !== "function"
|
|
1651
|
-
|| typeof tool.name !== "string"
|
|
1652
|
-
|| !tool.name.startsWith(IMAGE_GEN_DOTTED_PREFIX)
|
|
1653
|
-
) return tool;
|
|
1654
|
-
return { ...tool, name: imageGenWireName(tool.name) };
|
|
1655
|
-
}
|
|
1656
|
-
|
|
1657
|
-
/** Return the image-gen function name used for stable cross-container deduplication. */
|
|
1658
|
-
function imageGenFunctionName(tool: unknown): string | undefined {
|
|
1659
|
-
if (!isPlainObject(tool) || tool.type !== "function" || typeof tool.name !== "string") {
|
|
1660
|
-
return undefined;
|
|
1661
|
-
}
|
|
1662
|
-
return isImageGenClientName(tool.name) ? tool.name : undefined;
|
|
1663
|
-
}
|
|
1664
|
-
|
|
1665
|
-
/** True only when a declaration can yield a callable upstream-safe image-gen function alias. */
|
|
1666
|
-
function declaresUsableImageGenAlias(tool: unknown): boolean {
|
|
1667
|
-
if (flattenImageGenNamespace(tool)) return true;
|
|
1668
|
-
if (!isPlainObject(tool) || tool.type !== "function" || typeof tool.name !== "string") {
|
|
1669
|
-
return false;
|
|
1670
|
-
}
|
|
1671
|
-
if (tool.name.startsWith(IMAGE_GEN_DOTTED_PREFIX)) {
|
|
1672
|
-
return tool.name.length > IMAGE_GEN_DOTTED_PREFIX.length;
|
|
1673
|
-
}
|
|
1674
|
-
return tool.name.startsWith(IMAGE_GEN_WIRE_PREFIX)
|
|
1675
|
-
&& tool.name.length > IMAGE_GEN_WIRE_PREFIX.length;
|
|
1676
|
-
}
|
|
1677
|
-
|
|
1678
|
-
/** Collect client tool-choice names and the exact upstream aliases declared for them. */
|
|
1679
|
-
function imageGenToolChoiceAliases(toolGroups: unknown[][]): Map<string, string> {
|
|
1680
|
-
const aliases = new Map<string, string>();
|
|
1681
|
-
|
|
1682
|
-
for (const group of toolGroups) {
|
|
1683
|
-
for (const tool of group) {
|
|
1684
|
-
const flattened = flattenImageGenNamespace(tool);
|
|
1685
|
-
if (flattened) {
|
|
1686
|
-
for (const candidate of flattened) {
|
|
1687
|
-
const wireName = candidate.name as string;
|
|
1688
|
-
aliases.set(`${IMAGE_GEN_DOTTED_PREFIX}${imageGenLocalName(wireName)}`, wireName);
|
|
1689
|
-
aliases.set(wireName, wireName);
|
|
1690
|
-
}
|
|
1691
|
-
continue;
|
|
1692
|
-
}
|
|
1693
|
-
if (!isPlainObject(tool) || tool.type !== "function" || typeof tool.name !== "string") {
|
|
1694
|
-
continue;
|
|
1695
|
-
}
|
|
1696
|
-
if (
|
|
1697
|
-
tool.name.startsWith(IMAGE_GEN_DOTTED_PREFIX)
|
|
1698
|
-
&& tool.name.length > IMAGE_GEN_DOTTED_PREFIX.length
|
|
1699
|
-
) {
|
|
1700
|
-
aliases.set(tool.name, imageGenWireName(tool.name));
|
|
1701
|
-
} else if (
|
|
1702
|
-
tool.name.startsWith(IMAGE_GEN_WIRE_PREFIX)
|
|
1703
|
-
&& tool.name.length > IMAGE_GEN_WIRE_PREFIX.length
|
|
1704
|
-
) {
|
|
1705
|
-
aliases.set(tool.name, tool.name);
|
|
1706
|
-
}
|
|
1707
|
-
}
|
|
1708
|
-
}
|
|
1709
|
-
|
|
1710
|
-
return aliases;
|
|
1711
|
-
}
|
|
1712
|
-
|
|
1713
|
-
/** Rewrite function selectors only when their corresponding declaration receives a wire alias. */
|
|
1714
|
-
function normalizeImageGenToolChoice(
|
|
1715
|
-
toolChoice: unknown,
|
|
1716
|
-
aliases: ReadonlyMap<string, string>,
|
|
1717
|
-
): unknown {
|
|
1718
|
-
if (!isPlainObject(toolChoice)) return toolChoice;
|
|
1719
|
-
|
|
1720
|
-
if (toolChoice.type === "function" && typeof toolChoice.name === "string") {
|
|
1721
|
-
const alias = aliases.get(toolChoice.name);
|
|
1722
|
-
return alias && alias !== toolChoice.name ? { ...toolChoice, name: alias } : toolChoice;
|
|
1723
|
-
}
|
|
1724
|
-
|
|
1725
|
-
if (toolChoice.type !== "allowed_tools" || !Array.isArray(toolChoice.tools)) return toolChoice;
|
|
1726
|
-
let changed = false;
|
|
1727
|
-
const tools = toolChoice.tools.map(tool => {
|
|
1728
|
-
if (!isPlainObject(tool) || tool.type !== "function" || typeof tool.name !== "string") {
|
|
1729
|
-
return tool;
|
|
1730
|
-
}
|
|
1731
|
-
const alias = aliases.get(tool.name);
|
|
1732
|
-
if (!alias || alias === tool.name) return tool;
|
|
1733
|
-
changed = true;
|
|
1734
|
-
return { ...tool, name: alias };
|
|
1735
|
-
});
|
|
1736
|
-
return changed ? { ...toolChoice, tools } : toolChoice;
|
|
1737
|
-
}
|
|
1738
|
-
|
|
1739
|
-
/** Identify replayed image-gen calls that require upstream wire encoding. */
|
|
1740
|
-
function declaresImageGenFunctionCall(item: unknown): boolean {
|
|
1741
|
-
if (!isPlainObject(item) || item.type !== "function_call" || typeof item.name !== "string") {
|
|
1742
|
-
return false;
|
|
1743
|
-
}
|
|
1744
|
-
return item.namespace === IMAGE_GEN_NAMESPACE || isImageGenClientName(item.name);
|
|
1745
|
-
}
|
|
1746
|
-
|
|
1747
|
-
/** Encode native or legacy replay calls to the same flat name used by tool declarations. */
|
|
1748
|
-
function normalizeImageGenFunctionCall(item: unknown): unknown {
|
|
1749
|
-
if (!declaresImageGenFunctionCall(item) || !isPlainObject(item) || typeof item.name !== "string") {
|
|
1750
|
-
return item;
|
|
1751
|
-
}
|
|
1752
|
-
if (item.namespace === IMAGE_GEN_NAMESPACE) {
|
|
1753
|
-
const { namespace: _namespace, ...rest } = item;
|
|
1754
|
-
return { ...rest, name: imageGenWireName(item.name) };
|
|
1755
|
-
}
|
|
1756
|
-
if (item.name.startsWith(IMAGE_GEN_DOTTED_PREFIX)) {
|
|
1757
|
-
return { ...item, name: imageGenWireName(item.name) };
|
|
1758
|
-
}
|
|
1759
|
-
return item;
|
|
1760
|
-
}
|
|
1761
|
-
|
|
1762
|
-
/**
|
|
1763
|
-
* Normalize Codex's private image-gen tool declaration for API-key Responses providers.
|
|
1764
|
-
*
|
|
1765
|
-
* A complete `image_gen` namespace is flattened to safe `image_gen__<tool>` aliases even when it is
|
|
1766
|
-
* the only image tool in the request. Replayed client calls are encoded to the same alias, including
|
|
1767
|
-
* legacy dotted calls from older compatibility attempts. When a usable alias replaces a client
|
|
1768
|
-
* image-gen declaration, the duplicate hosted `image_generation` entry is removed. Duplicate aliases
|
|
1769
|
-
* are resolved in stable container order: top-level tools first, then Responses Lite
|
|
1770
|
-
* `additional_tools` entries.
|
|
1771
|
-
*
|
|
1772
|
-
* This function is called only on the API-key path. ChatGPT forward mode understands the private
|
|
1773
|
-
* namespace and must keep it. Copy-on-write preserves the original request reference when no
|
|
1774
|
-
* namespace is flattened, hosted tool removed, or duplicate function discarded.
|
|
1775
|
-
*/
|
|
1776
|
-
function normalizeImageGenClientTools(body: unknown): unknown {
|
|
1777
|
-
if (!isPlainObject(body)) return body;
|
|
1778
|
-
|
|
1779
|
-
const toolGroups = collectResponsesToolGroups(body);
|
|
1780
|
-
const hasImageGenClientTool = toolGroups.some(group => group.some(declaresImageGenClientTool))
|
|
1781
|
-
|| (Array.isArray(body.input) && body.input.some(declaresImageGenFunctionCall));
|
|
1782
|
-
if (!hasImageGenClientTool) return body;
|
|
1783
|
-
const hasUsableImageGenAlias = toolGroups.some(group => group.some(declaresUsableImageGenAlias));
|
|
1784
|
-
const toolChoiceAliases = imageGenToolChoiceAliases(toolGroups);
|
|
1785
|
-
|
|
1786
|
-
const seenFunctionNames = new Set<string>();
|
|
1787
|
-
const normalizeGroup = (tools: unknown[]): unknown[] => {
|
|
1788
|
-
const normalized: unknown[] = [];
|
|
1789
|
-
let groupChanged = false;
|
|
1790
|
-
|
|
1791
|
-
for (const tool of tools) {
|
|
1792
|
-
if (
|
|
1793
|
-
hasUsableImageGenAlias
|
|
1794
|
-
&& isPlainObject(tool)
|
|
1795
|
-
&& tool.type === HOSTED_IMAGE_GENERATION_TOOL
|
|
1796
|
-
) {
|
|
1797
|
-
groupChanged = true;
|
|
1798
|
-
continue;
|
|
1799
|
-
}
|
|
1800
|
-
|
|
1801
|
-
const flattened = flattenImageGenNamespace(tool);
|
|
1802
|
-
const candidates = flattened ?? [tool];
|
|
1803
|
-
if (flattened) groupChanged = true;
|
|
1804
|
-
|
|
1805
|
-
for (const candidate of candidates) {
|
|
1806
|
-
const normalizedCandidate = normalizeFlatImageGenFunction(candidate);
|
|
1807
|
-
if (normalizedCandidate !== candidate) groupChanged = true;
|
|
1808
|
-
const functionName = imageGenFunctionName(normalizedCandidate);
|
|
1809
|
-
if (functionName && seenFunctionNames.has(functionName)) {
|
|
1810
|
-
groupChanged = true;
|
|
1811
|
-
continue;
|
|
1812
|
-
}
|
|
1813
|
-
if (functionName) seenFunctionNames.add(functionName);
|
|
1814
|
-
normalized.push(normalizedCandidate);
|
|
1815
|
-
}
|
|
1816
|
-
}
|
|
1817
|
-
|
|
1818
|
-
return groupChanged ? normalized : tools;
|
|
1819
|
-
};
|
|
1820
|
-
|
|
1821
|
-
let changed = false;
|
|
1822
|
-
let tools = body.tools;
|
|
1823
|
-
if (Array.isArray(body.tools)) {
|
|
1824
|
-
tools = normalizeGroup(body.tools);
|
|
1825
|
-
changed ||= tools !== body.tools;
|
|
1826
|
-
}
|
|
1827
|
-
|
|
1828
|
-
let input = body.input;
|
|
1829
|
-
if (Array.isArray(body.input)) {
|
|
1830
|
-
let nestedChanged = false;
|
|
1831
|
-
const mappedInput = body.input.map(item => {
|
|
1832
|
-
if (isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools)) {
|
|
1833
|
-
const nestedTools = normalizeGroup(item.tools);
|
|
1834
|
-
if (nestedTools === item.tools) return item;
|
|
1835
|
-
nestedChanged = true;
|
|
1836
|
-
return { ...item, tools: nestedTools };
|
|
1837
|
-
}
|
|
1838
|
-
const normalizedCall = normalizeImageGenFunctionCall(item);
|
|
1839
|
-
if (normalizedCall !== item) nestedChanged = true;
|
|
1840
|
-
return normalizedCall;
|
|
1841
|
-
});
|
|
1842
|
-
if (nestedChanged) {
|
|
1843
|
-
input = mappedInput;
|
|
1844
|
-
changed = true;
|
|
1845
|
-
}
|
|
1846
|
-
}
|
|
1847
|
-
|
|
1848
|
-
const toolChoice = normalizeImageGenToolChoice(body.tool_choice, toolChoiceAliases);
|
|
1849
|
-
changed ||= toolChoice !== body.tool_choice;
|
|
1850
|
-
|
|
1851
|
-
if (!changed) return body;
|
|
1852
|
-
return {
|
|
1853
|
-
...body,
|
|
1854
|
-
...(Array.isArray(body.tools) ? { tools } : {}),
|
|
1855
|
-
...(Array.isArray(body.input) ? { input } : {}),
|
|
1856
|
-
...(Object.prototype.hasOwnProperty.call(body, "tool_choice") ? { tool_choice: toolChoice } : {}),
|
|
1857
|
-
};
|
|
1858
|
-
}
|
|
1859
|
-
|
|
1860
|
-
/**
|
|
1861
|
-
* Remove hosted tool entries the target native slug rejects, so the OAuth-passthrough body never
|
|
1862
|
-
* carries a tool the upstream model 400s on. No-op (returns the original reference) when nothing
|
|
1863
|
-
* matches, keeping the common path allocation-free.
|
|
1864
|
-
*/
|
|
1865
|
-
function stripUnsupportedHostedTools(body: unknown, provider: Pick<OcxProviderConfig, "baseUrl">): unknown {
|
|
1866
|
-
if (!isPlainObject(body)) return body;
|
|
1867
|
-
const model = typeof body.model === "string" ? body.model : "";
|
|
1868
|
-
const filterTools = (tools: unknown[]): unknown[] => {
|
|
1869
|
-
const filtered = tools.filter(t => {
|
|
1870
|
-
const type = isPlainObject(t) && typeof t.type === "string" ? t.type : undefined;
|
|
1871
|
-
return !type || !isHostedToolUnsupportedForModel(model, type, provider.baseUrl);
|
|
1872
|
-
});
|
|
1873
|
-
return filtered.length === tools.length ? tools : filtered;
|
|
1874
|
-
};
|
|
1875
|
-
|
|
1876
|
-
let next: Record<string, unknown> = body;
|
|
1877
|
-
let changed = false;
|
|
1878
|
-
if (Array.isArray(body.tools)) {
|
|
1879
|
-
const tools = filterTools(body.tools);
|
|
1880
|
-
if (tools !== body.tools) {
|
|
1881
|
-
next = { ...next, tools };
|
|
1882
|
-
changed = true;
|
|
1883
|
-
}
|
|
1884
|
-
}
|
|
1885
|
-
if (Array.isArray(body.input)) {
|
|
1886
|
-
let inputChanged = false;
|
|
1887
|
-
const input = body.input.map(item => {
|
|
1888
|
-
if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item;
|
|
1889
|
-
const tools = filterTools(item.tools);
|
|
1890
|
-
if (tools === item.tools) return item;
|
|
1891
|
-
inputChanged = true;
|
|
1892
|
-
return { ...item, tools };
|
|
1893
|
-
});
|
|
1894
|
-
if (inputChanged) {
|
|
1895
|
-
next = { ...next, input };
|
|
1896
|
-
changed = true;
|
|
1897
|
-
}
|
|
1898
|
-
}
|
|
1899
|
-
|
|
1900
|
-
const toolChoice = next.tool_choice;
|
|
1901
|
-
if (isPlainObject(toolChoice) && toolChoice.type === "allowed_tools" && Array.isArray(toolChoice.tools)) {
|
|
1902
|
-
const tools = filterTools(toolChoice.tools);
|
|
1903
|
-
if (tools !== toolChoice.tools) {
|
|
1904
|
-
next = { ...next, tool_choice: tools.length > 0 ? { ...toolChoice, tools } : "none" };
|
|
1905
|
-
changed = true;
|
|
1906
|
-
}
|
|
1907
|
-
} else if (
|
|
1908
|
-
isPlainObject(toolChoice)
|
|
1909
|
-
&& typeof toolChoice.type === "string"
|
|
1910
|
-
&& isHostedToolUnsupportedForModel(model, toolChoice.type, provider.baseUrl)
|
|
1911
|
-
) {
|
|
1912
|
-
next = { ...next, tool_choice: "none" };
|
|
1913
|
-
changed = true;
|
|
1914
|
-
} else if (changed && toolChoice === "required") {
|
|
1915
|
-
const hasDeclaredTools = (Array.isArray(next.tools) && next.tools.length > 0)
|
|
1916
|
-
|| (Array.isArray(next.input) && next.input.some(item =>
|
|
1917
|
-
isPlainObject(item)
|
|
1918
|
-
&& item.type === "additional_tools"
|
|
1919
|
-
&& Array.isArray(item.tools)
|
|
1920
|
-
&& item.tools.length > 0));
|
|
1921
|
-
if (!hasDeclaredTools) {
|
|
1922
|
-
next = { ...next, tool_choice: "none" };
|
|
1923
|
-
}
|
|
1924
|
-
}
|
|
1925
|
-
return changed ? next : body;
|
|
1926
|
-
}
|
|
1927
|
-
|
|
1928
|
-
/**
|
|
1929
|
-
* OpenAI hosted web_search config fields that a capability-classified Responses
|
|
1930
|
-
* upstream may reject wholesale. xAI's /v1/responses 400s the entire request on
|
|
1931
|
-
* `external_web_access` and `search_context_size` ("Argument not supported"),
|
|
1932
|
-
* which killed every routed Grok turn whose client (Codex) attaches its
|
|
1933
|
-
* default web_search tool config (probe 2026-08-21: both fields 400
|
|
1934
|
-
* individually; `user_location` and `filters` are accepted and kept).
|
|
1935
|
-
* The caller decides whether to apply this compatibility transform from explicit
|
|
1936
|
-
* provider capability metadata; an unclassified upstream keeps the fields.
|
|
1937
|
-
*/
|
|
1938
|
-
const OPENAI_ONLY_WEB_SEARCH_FIELDS = ["external_web_access", "search_context_size"] as const;
|
|
1939
|
-
|
|
1940
|
-
function stripOpenAiOnlyWebSearchFieldsFromTools(tools: unknown[]): {
|
|
1941
|
-
tools: unknown[];
|
|
1942
|
-
changed: boolean;
|
|
1943
|
-
} {
|
|
1944
|
-
let changed = false;
|
|
1945
|
-
const stripped = tools.map(tool => {
|
|
1946
|
-
if (!isPlainObject(tool) || (tool.type !== "web_search" && tool.type !== "web_search_preview")) {
|
|
1947
|
-
return tool;
|
|
1948
|
-
}
|
|
1949
|
-
if (!OPENAI_ONLY_WEB_SEARCH_FIELDS.some(field => Object.hasOwn(tool, field))) return tool;
|
|
1950
|
-
const { external_web_access: _access, search_context_size: _size, ...rest } = tool;
|
|
1951
|
-
changed = true;
|
|
1952
|
-
return rest;
|
|
1953
|
-
});
|
|
1954
|
-
return { tools: changed ? stripped : tools, changed };
|
|
1955
|
-
}
|
|
1956
|
-
|
|
1957
|
-
export function stripOpenAiOnlyWebSearchFields(body: unknown): unknown {
|
|
1958
|
-
if (!isPlainObject(body)) return body;
|
|
1959
|
-
|
|
1960
|
-
let next: Record<string, unknown> = body;
|
|
1961
|
-
let changed = false;
|
|
1962
|
-
if (Array.isArray(body.tools)) {
|
|
1963
|
-
const stripped = stripOpenAiOnlyWebSearchFieldsFromTools(body.tools);
|
|
1964
|
-
if (stripped.changed) {
|
|
1965
|
-
next = { ...next, tools: stripped.tools };
|
|
1966
|
-
changed = true;
|
|
1967
|
-
}
|
|
1968
|
-
}
|
|
1969
|
-
|
|
1970
|
-
if (Array.isArray(body.input)) {
|
|
1971
|
-
let inputChanged = false;
|
|
1972
|
-
const input = body.input.map(item => {
|
|
1973
|
-
if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) {
|
|
1974
|
-
return item;
|
|
1975
|
-
}
|
|
1976
|
-
const stripped = stripOpenAiOnlyWebSearchFieldsFromTools(item.tools);
|
|
1977
|
-
if (!stripped.changed) return item;
|
|
1978
|
-
inputChanged = true;
|
|
1979
|
-
return { ...item, tools: stripped.tools };
|
|
1980
|
-
});
|
|
1981
|
-
if (inputChanged) {
|
|
1982
|
-
next = { ...next, input };
|
|
1983
|
-
changed = true;
|
|
1984
|
-
}
|
|
1985
|
-
}
|
|
1986
|
-
|
|
1987
|
-
return changed ? next : body;
|
|
1988
|
-
}
|
|
1989
|
-
|
|
1990
|
-
/**
|
|
1991
|
-
* Muse Spark ids whose Responses gateway refuses provider-specific fields on a plain
|
|
1992
|
-
* `web_search` tool. Membership, not equality: 1.3 shipped 2026-09-02 as the
|
|
1993
|
-
* same-shaped successor to 1.2 on the same Zen wire, and an equality check would
|
|
1994
|
-
* have let a Codex-emitted `web_search` body reach the
|
|
1995
|
-
* gateway and come back 400 for every request the moment 1.3 was selected.
|
|
1996
|
-
*/
|
|
1997
|
-
const MUSE_SPARK_WEB_SEARCH_STRICT_MODELS = new Set([
|
|
1998
|
-
"muse-spark-1.3-contributor",
|
|
1999
|
-
"muse-spark-1.3-contributor-free",
|
|
2000
|
-
"muse-spark-1.2-contributor",
|
|
2001
|
-
"muse-spark-1.2-contributor-free",
|
|
2002
|
-
]);
|
|
2003
|
-
|
|
2004
|
-
const MUSE_SPARK_WEB_SEARCH_STRICT_RESPONSE_URLS = new Set([
|
|
2005
|
-
"https://opencode.ai/zen/v1/responses",
|
|
2006
|
-
"https://opencode.ai/zen/go/v1/responses",
|
|
2007
|
-
"https://api.meta.ai/v1/responses",
|
|
2008
|
-
]);
|
|
2009
|
-
|
|
2010
|
-
const MUSE_SPARK_UNSUPPORTED_WEB_SEARCH_FIELDS = [
|
|
2011
|
-
"search_content_types",
|
|
2012
|
-
"indexed_web_access",
|
|
2013
|
-
] as const;
|
|
2014
|
-
|
|
2015
|
-
/**
|
|
2016
|
-
* OpenCode Zen / Go and the direct Meta Muse Spark Responses gateways refuse a
|
|
2017
|
-
* short list of Codex `web_search` fields. `web_search_preview` keeps its accepted
|
|
2018
|
-
* shape, and Luna remains untouched. Match the exact effective request URL;
|
|
2019
|
-
* malformed, credentialed, or parameterized destinations keep their original body
|
|
2020
|
-
* instead of assuming this gateway contract. Keep the rejected names together so a
|
|
2021
|
-
* newly identified field is a one-line compatibility update rather than another
|
|
2022
|
-
* bespoke rewrite.
|
|
2023
|
-
*/
|
|
2024
|
-
function stripMuseSparkUnsupportedWebSearchFields(
|
|
2025
|
-
body: unknown,
|
|
2026
|
-
modelId: unknown,
|
|
2027
|
-
responseUrl: string,
|
|
2028
|
-
): unknown {
|
|
2029
|
-
if (!isPlainObject(body)) return body;
|
|
2030
|
-
if (typeof modelId !== "string") return body;
|
|
2031
|
-
if (!MUSE_SPARK_WEB_SEARCH_STRICT_MODELS.has(modelId.trim().toLowerCase())) return body;
|
|
2032
|
-
let destination: string;
|
|
2033
|
-
try {
|
|
2034
|
-
const url = new URL(responseUrl);
|
|
2035
|
-
if (url.username || url.password || url.search || url.hash) return body;
|
|
2036
|
-
destination = `${url.origin.toLowerCase()}${url.pathname.replace(/\/+$/, "")}`;
|
|
2037
|
-
} catch {
|
|
2038
|
-
return body;
|
|
2039
|
-
}
|
|
2040
|
-
if (!MUSE_SPARK_WEB_SEARCH_STRICT_RESPONSE_URLS.has(destination)) return body;
|
|
2041
|
-
|
|
2042
|
-
const rewriteTools = (tools: unknown[]): { tools: unknown[]; changed: boolean } => {
|
|
2043
|
-
let changed = false;
|
|
2044
|
-
const rewritten = tools.map(tool => {
|
|
2045
|
-
if (!isPlainObject(tool) || tool.type !== "web_search") return tool;
|
|
2046
|
-
if (!MUSE_SPARK_UNSUPPORTED_WEB_SEARCH_FIELDS.some(field => Object.hasOwn(tool, field))) {
|
|
2047
|
-
return tool;
|
|
2048
|
-
}
|
|
2049
|
-
const rest = { ...tool };
|
|
2050
|
-
for (const field of MUSE_SPARK_UNSUPPORTED_WEB_SEARCH_FIELDS) delete rest[field];
|
|
2051
|
-
changed = true;
|
|
2052
|
-
return rest;
|
|
2053
|
-
});
|
|
2054
|
-
return { tools: changed ? rewritten : tools, changed };
|
|
2055
|
-
};
|
|
2056
|
-
|
|
2057
|
-
let next: Record<string, unknown> = body;
|
|
2058
|
-
let changed = false;
|
|
2059
|
-
if (Array.isArray(body.tools)) {
|
|
2060
|
-
const rewritten = rewriteTools(body.tools);
|
|
2061
|
-
if (rewritten.changed) {
|
|
2062
|
-
next = { ...next, tools: rewritten.tools };
|
|
2063
|
-
changed = true;
|
|
2064
|
-
}
|
|
2065
|
-
}
|
|
2066
|
-
if (Array.isArray(next.input)) {
|
|
2067
|
-
let inputChanged = false;
|
|
2068
|
-
const input = next.input.map(item => {
|
|
2069
|
-
if (!isPlainObject(item) || item.type !== "additional_tools" || !Array.isArray(item.tools)) return item;
|
|
2070
|
-
const rewritten = rewriteTools(item.tools);
|
|
2071
|
-
if (!rewritten.changed) return item;
|
|
2072
|
-
inputChanged = true;
|
|
2073
|
-
return { ...item, tools: rewritten.tools };
|
|
2074
|
-
});
|
|
2075
|
-
if (inputChanged) {
|
|
2076
|
-
next = { ...next, input };
|
|
2077
|
-
changed = true;
|
|
2078
|
-
}
|
|
2079
|
-
}
|
|
2080
|
-
return changed ? next : body;
|
|
2081
|
-
}
|
|
2082
|
-
|
|
2083
|
-
/** Replace every `input_image` part under a routed-compaction body with a short marker. */
|
|
2084
|
-
function stripInputImagesDeep(value: unknown): unknown {
|
|
2085
|
-
if (Array.isArray(value)) return value.map(stripInputImagesDeep);
|
|
2086
|
-
if (!isPlainObject(value)) return value;
|
|
2087
|
-
if (value.type === "input_image") {
|
|
2088
|
-
return { type: "input_text", text: "[image omitted for compaction]" };
|
|
2089
|
-
}
|
|
2090
|
-
const out: Record<string, unknown> = {};
|
|
2091
|
-
for (const [key, entry] of Object.entries(value)) out[key] = stripInputImagesDeep(entry);
|
|
2092
|
-
return out;
|
|
2093
|
-
}
|
|
2094
|
-
|
|
2095
|
-
/**
|
|
2096
|
-
* Rewrite a compaction turn for an upstream that does not speak Codex's private
|
|
2097
|
-
* `compaction_trigger` item: drop the trigger and the whole tool surface, and ask
|
|
2098
|
-
* for the handoff summary in plain terms instead (#422).
|
|
2099
|
-
*
|
|
2100
|
-
* The adapter builds from `parsed._rawBody`, so the summarizer prompt that
|
|
2101
|
-
* handleResponses() pushed onto `parsed.context` never reaches the wire — it has to
|
|
2102
|
-
* be applied here. Images go too: a summary needs no pixels, and a text-only
|
|
2103
|
-
* gateway would reject them.
|
|
2104
|
-
*/
|
|
2105
|
-
function buildRoutedCompactionBody(body: unknown): unknown {
|
|
2106
|
-
if (!isPlainObject(body)) return body;
|
|
2107
|
-
// `text` goes with the tool fields: the summary must be prose, not schema-constrained JSON.
|
|
2108
|
-
const { tools: _tools, tool_choice: _toolChoice, parallel_tool_calls: _parallel, text: _text, ...rest } = body;
|
|
2109
|
-
const input = Array.isArray(body.input) ? body.input : [];
|
|
2110
|
-
const kept = input.filter(item => !isPlainObject(item)
|
|
2111
|
-
// `additional_tools` is how Codex Desktop's responses-lite shape carries tools;
|
|
2112
|
-
// leaving it in would break the no-tools invariant even with `tools` removed.
|
|
2113
|
-
|| (item.type !== "compaction_trigger" && item.type !== "additional_tools"));
|
|
2114
|
-
return {
|
|
2115
|
-
...rest,
|
|
2116
|
-
input: [
|
|
2117
|
-
...(stripInputImagesDeep(kept) as unknown[]),
|
|
2118
|
-
{ type: "message", role: "user", content: [{ type: "input_text", text: COMPACT_PROMPT }] },
|
|
2119
|
-
],
|
|
2120
|
-
};
|
|
2121
|
-
}
|
|
2122
|
-
|
|
2123
|
-
/** Read the Responses `usage` block, if the gateway sent one. */
|
|
2124
|
-
function usageFromResponsesPayload(payload: unknown): OcxUsage | undefined {
|
|
2125
|
-
if (!isPlainObject(payload) || !isPlainObject(payload.usage)) return undefined;
|
|
2126
|
-
const usage = payload.usage;
|
|
2127
|
-
const inputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : 0;
|
|
2128
|
-
const outputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : 0;
|
|
2129
|
-
// openai/codex#41980: the raw usage object is wire data a rebuilt response.completed must keep —
|
|
2130
|
-
// unknown keys (subscription metadata, future counters) ride along even when the token counts
|
|
2131
|
-
// themselves are zero or absent (metadata-only usage).
|
|
2132
|
-
const knownKeys = new Set(["input_tokens", "output_tokens", "total_tokens", "input_tokens_details", "output_tokens_details"]);
|
|
2133
|
-
const hasExtras = Object.keys(usage).some(key => !knownKeys.has(key))
|
|
2134
|
-
|| (isPlainObject(usage.input_tokens_details)
|
|
2135
|
-
&& Object.keys(usage.input_tokens_details).some(key => key !== "cached_tokens" && key !== "cache_write_tokens"))
|
|
2136
|
-
|| (isPlainObject(usage.output_tokens_details)
|
|
2137
|
-
&& Object.keys(usage.output_tokens_details).some(key => key !== "reasoning_tokens"));
|
|
2138
|
-
if (inputTokens === 0 && outputTokens === 0 && !hasExtras) return undefined;
|
|
2139
|
-
const inputDetails = isPlainObject(usage.input_tokens_details) ? usage.input_tokens_details : undefined;
|
|
2140
|
-
const outputDetails = isPlainObject(usage.output_tokens_details) ? usage.output_tokens_details : undefined;
|
|
2141
|
-
return {
|
|
2142
|
-
inputTokens,
|
|
2143
|
-
outputTokens,
|
|
2144
|
-
...(typeof usage.total_tokens === "number" ? { totalTokens: usage.total_tokens } : {}),
|
|
2145
|
-
...(typeof inputDetails?.cached_tokens === "number" ? { cachedInputTokens: inputDetails.cached_tokens } : {}),
|
|
2146
|
-
...(typeof inputDetails?.cache_write_tokens === "number" ? { cacheCreationInputTokens: inputDetails.cache_write_tokens } : {}),
|
|
2147
|
-
...(typeof outputDetails?.reasoning_tokens === "number" ? { reasoningOutputTokens: outputDetails.reasoning_tokens } : {}),
|
|
2148
|
-
...(hasExtras ? { rawUsage: { ...usage } } : {}),
|
|
2149
|
-
};
|
|
2150
|
-
}
|
|
2151
|
-
|
|
2152
|
-
function responsesPayloadText(response: unknown): string {
|
|
2153
|
-
if (!isPlainObject(response) || !Array.isArray(response.output)) return "";
|
|
2154
|
-
return response.output
|
|
2155
|
-
.filter(item => isPlainObject(item) && item.type === "message")
|
|
2156
|
-
.flatMap(item => (Array.isArray((item as Record<string, unknown>).content)
|
|
2157
|
-
? (item as { content: unknown[] }).content
|
|
2158
|
-
: []))
|
|
2159
|
-
.filter(part => isPlainObject(part) && part.type === "output_text")
|
|
2160
|
-
.map(part => String((part as { text?: unknown }).text ?? ""))
|
|
2161
|
-
.join("");
|
|
2162
|
-
}
|
|
2163
|
-
|
|
2164
|
-
function responsesErrorMessage(payload: unknown): string {
|
|
2165
|
-
if (!isPlainObject(payload)) return "upstream compaction failed";
|
|
2166
|
-
const err = payload.error;
|
|
2167
|
-
if (typeof err === "string") return err;
|
|
2168
|
-
if (isPlainObject(err) && typeof err.message === "string") return err.message;
|
|
2169
|
-
const incomplete = payload.incomplete_details;
|
|
2170
|
-
if (isPlainObject(incomplete) && typeof incomplete.reason === "string") return incomplete.reason;
|
|
2171
|
-
return "upstream compaction failed";
|
|
2172
|
-
}
|
|
2173
|
-
|
|
2174
|
-
/** Count an append without rescanning accumulated text, including split surrogate pairs. */
|
|
2175
|
-
function appendedUtf8Bytes(previousBytes: number, lastCodeUnit: number, fragment: string): number {
|
|
2176
|
-
const first = fragment.charCodeAt(0);
|
|
2177
|
-
// Separate lone surrogates each count as a three-byte replacement character; together
|
|
2178
|
-
// they encode as one four-byte scalar. Empty fragments produce NaN and never pair.
|
|
2179
|
-
const joinsSurrogatePair = lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff && first >= 0xdc00 && first <= 0xdfff;
|
|
2180
|
-
return previousBytes + Buffer.byteLength(fragment, "utf8") - (joinsSurrogatePair ? 2 : 0);
|
|
2181
|
-
}
|
|
2182
|
-
|
|
2183
|
-
export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): ProviderAdapter & { passthrough: true } {
|
|
2184
|
-
return {
|
|
2185
|
-
name: "openai-responses",
|
|
2186
|
-
passthrough: true as const,
|
|
2187
|
-
|
|
2188
|
-
buildRequest(parsed: OcxParsedRequest, incoming: IncomingMeta) {
|
|
2189
|
-
const translatorBudget = incoming.translatorBudget;
|
|
2190
|
-
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
2191
|
-
let url: string;
|
|
2192
|
-
|
|
2193
|
-
if (provider.authMode === "forward") {
|
|
2194
|
-
const mayForwardCallerCredentials = isCanonicalOpenAiForwardProvider(provider);
|
|
2195
|
-
// OAuth passthrough: ChatGPT backend path is `${baseUrl}/responses` (no /v1).
|
|
2196
|
-
const baseUrl = mayForwardCallerCredentials
|
|
2197
|
-
? CODEX_FORWARD_BASE_URL
|
|
2198
|
-
: provider.baseUrl.replace(/\/+$/, "");
|
|
2199
|
-
url = `${baseUrl}/responses`;
|
|
2200
|
-
if (provider.headers) Object.assign(headers, provider.headers); // static headers first…
|
|
2201
|
-
const runtimeProvider = provider as {
|
|
2202
|
-
_codexAccountOverride?: { accessToken: string; chatgptAccountId: string };
|
|
2203
|
-
_codexAccountRequired?: boolean;
|
|
2204
|
-
};
|
|
2205
|
-
if (
|
|
2206
|
-
mayForwardCallerCredentials
|
|
2207
|
-
&& runtimeProvider._codexAccountRequired
|
|
2208
|
-
&& !runtimeProvider._codexAccountOverride
|
|
2209
|
-
) {
|
|
2210
|
-
throw new Error("Codex pool account auth is required but unavailable");
|
|
2211
|
-
}
|
|
2212
|
-
if (mayForwardCallerCredentials) {
|
|
2213
|
-
for (const h of FORWARD_HEADERS) {
|
|
2214
|
-
const v = incoming?.headers.get(h);
|
|
2215
|
-
if (v) {
|
|
2216
|
-
if (h === CODEX_RESPONSES_LITE_HEADER) {
|
|
2217
|
-
for (const name of Object.keys(headers)) {
|
|
2218
|
-
if (name.toLowerCase() === h) delete headers[name];
|
|
2219
|
-
}
|
|
2220
|
-
}
|
|
2221
|
-
headers[h] = v; // …so genuine forwarded fields win.
|
|
2222
|
-
}
|
|
2223
|
-
}
|
|
2224
|
-
}
|
|
2225
|
-
const override = runtimeProvider._codexAccountOverride;
|
|
2226
|
-
if (override && mayForwardCallerCredentials) {
|
|
2227
|
-
headers["authorization"] = `Bearer ${override.accessToken}`;
|
|
2228
|
-
headers["chatgpt-account-id"] = override.chatgptAccountId;
|
|
2229
|
-
}
|
|
2230
|
-
} else {
|
|
2231
|
-
if (provider.responsesPath === undefined) {
|
|
2232
|
-
url = openaiResponsesUrl(provider.baseUrl);
|
|
2233
|
-
} else {
|
|
2234
|
-
const base = provider.baseUrl.replace(/\/$/, "");
|
|
2235
|
-
url = `${base}${provider.responsesPath}`;
|
|
2236
|
-
}
|
|
2237
|
-
if (provider.apiKey) headers["Authorization"] = `Bearer ${provider.apiKey}`;
|
|
2238
|
-
if (provider.headers) Object.assign(headers, provider.headers);
|
|
2239
|
-
}
|
|
2240
|
-
|
|
2241
|
-
const forward = provider.authMode === "forward";
|
|
2242
|
-
let convertedRoutedCustomToolNames: Set<string> | undefined;
|
|
2243
|
-
let routedCustomToolRepairNames: Set<string> | undefined;
|
|
2244
|
-
let convertedRoutedToolSearchNames: Set<string> | undefined;
|
|
2245
|
-
let convertedRoutedNamespaceToolAliases: Map<string, { namespace: string; name: string; kind: "function" | "custom" }> | undefined;
|
|
2246
|
-
let plaintextV2AgentMessageToolNames: ReadonlySet<string> | undefined;
|
|
2247
|
-
let plaintextV2AgentMessageAliasedToolNames: ReadonlySet<string> | undefined;
|
|
2248
|
-
let convertedMuseToolNameAliases: Map<string, string> | undefined;
|
|
2249
|
-
const unexpandedMiss = !!parsed.previousResponseId && parsed._previousResponseInputExpanded !== true;
|
|
2250
|
-
let outBody = stripPreviousResponseId(
|
|
2251
|
-
parsed._rawBody,
|
|
2252
|
-
forward || parsed._previousResponseInputExpanded === true,
|
|
2253
|
-
);
|
|
2254
|
-
if (!forward) outBody = normalizeRoutedAgentMessages(outBody, {
|
|
2255
|
-
allowStringContent: isXaiResponsesDestination(provider),
|
|
2256
|
-
});
|
|
2257
|
-
outBody = mapRoutedResponsesReasoningEffort(outBody, provider, parsed.modelId);
|
|
2258
|
-
// stripPreviousResponseId() intentionally returns its input on a no-op. Detach before the
|
|
2259
|
-
// tier write so a force-fast/default decision can never mutate parsed._rawBody.
|
|
2260
|
-
outBody = applyTierDecisionToResponsesBody(outBody, parsed.options?.tierDecision);
|
|
2261
|
-
const stateless = provider.statelessResponses === true;
|
|
2262
|
-
if (stateless) outBody = stripStatefulResponsesParams(outBody);
|
|
2263
|
-
// A replay miss can leave a function_call_output whose paired function_call sat
|
|
2264
|
-
// in the prefix that was never expanded. A stateless upstream cannot resolve the
|
|
2265
|
-
// pair from its own storage either, so it needs the same repair the forward
|
|
2266
|
-
// backend gets — dropping previous_response_id is not much use if the body that
|
|
2267
|
-
// reaches the wire is unparseable.
|
|
2268
|
-
if (provider.annotateEmptyToolOutputs === true) {
|
|
2269
|
-
outBody = annotateEmptyResponsesToolOutputs(outBody, true);
|
|
2270
|
-
}
|
|
2271
|
-
if (forward || stateless) {
|
|
2272
|
-
outBody = repairOrphanedInputItems(outBody, unexpandedMiss, stateless && !forward);
|
|
2273
|
-
}
|
|
2274
|
-
if (provider.requiresAdjacentResponsesToolResults === true) {
|
|
2275
|
-
outBody = normalizeResponsesToolResultAdjacency(outBody);
|
|
2276
|
-
}
|
|
2277
|
-
if (forward) {
|
|
2278
|
-
outBody = stripUnsupportedForwardParams(outBody);
|
|
2279
|
-
// Only the canonical ChatGPT backend rejects the retired field; a self-hosted or
|
|
2280
|
-
// third-party forward gateway may still accept it, so this must not be widened.
|
|
2281
|
-
if (isCanonicalOpenAiForwardProvider(provider)) {
|
|
2282
|
-
outBody = stripCanonicalForwardSamplingParams(outBody);
|
|
2283
|
-
outBody = stripDeprecatedPromptCacheRetention(outBody, parsed.modelId);
|
|
2284
|
-
outBody = stripCanonicalForwardPromptCacheOptions(outBody);
|
|
2285
|
-
outBody = normalizeCanonicalForwardPromptEnvelope(outBody);
|
|
2286
|
-
outBody = normalizeCanonicalForwardContinuationEnvelope(outBody);
|
|
2287
|
-
}
|
|
2288
|
-
} else {
|
|
2289
|
-
outBody = preferConfiguredHostedTools(
|
|
2290
|
-
outBody,
|
|
2291
|
-
provider,
|
|
2292
|
-
parsed.modelId,
|
|
2293
|
-
parsed._openAiVirtualSelectedModelId,
|
|
2294
|
-
);
|
|
2295
|
-
outBody = normalizeImageGenClientTools(outBody);
|
|
2296
|
-
}
|
|
2297
|
-
if (forward || parsed._previousResponseInputExpanded === true) {
|
|
2298
|
-
outBody = repairOversizedReplayCallIds(outBody);
|
|
2299
|
-
}
|
|
2300
|
-
outBody = stripUnsupportedReasoningSummaryDelivery(outBody, parsed.modelId);
|
|
2301
|
-
// Repair stored history from before the bridge emitted both keys, in either
|
|
2302
|
-
// direction: a conversation that already recorded a web_search_call replays it
|
|
2303
|
-
// every turn, and a strict parser rejects the whole request over the missing key —
|
|
2304
|
-
// `queries` for DeepSeek (#930), `query` for Console Go (#3071).
|
|
2305
|
-
outBody = backfillWebSearchQueries(outBody);
|
|
2306
|
-
if (!isCanonicalOpenAiForwardProvider(provider)) {
|
|
2307
|
-
outBody = stripInternalChatMessageMetadataPassthrough(outBody);
|
|
2308
|
-
outBody = promoteClientLoadedTools(outBody);
|
|
2309
|
-
}
|
|
2310
|
-
if (!isCanonicalOpenAiForwardProvider(provider)) {
|
|
2311
|
-
const rewritten = rewriteRoutedCustomToolsForUpstream(
|
|
2312
|
-
outBody,
|
|
2313
|
-
provider.supportsResponsesCustomTools,
|
|
2314
|
-
);
|
|
2315
|
-
outBody = rewritten.body;
|
|
2316
|
-
convertedRoutedCustomToolNames = rewritten.names;
|
|
2317
|
-
routedCustomToolRepairNames = rewritten.repairNames;
|
|
2318
|
-
}
|
|
2319
|
-
if (!isCanonicalOpenAiForwardProvider(provider)) {
|
|
2320
|
-
// Run after custom-tool lowering so the search compatibility layer can choose a
|
|
2321
|
-
// collision-free public function name against the final routed function catalog.
|
|
2322
|
-
const rewritten = rewriteRoutedToolSearchForUpstream(outBody);
|
|
2323
|
-
outBody = rewritten.body;
|
|
2324
|
-
convertedRoutedToolSearchNames = rewritten.names;
|
|
2325
|
-
}
|
|
2326
|
-
if (!isCanonicalOpenAiForwardProvider(provider)) {
|
|
2327
|
-
// Codex 0.147 emits private namespace tool groups, while public/third-party Responses
|
|
2328
|
-
// gateways accept only flat tool variants. Run after custom/tool-search lowering so
|
|
2329
|
-
// namespace children already carry their final public kind before they are promoted.
|
|
2330
|
-
const rewritten = rewriteRoutedNamespaceToolsForUpstream(outBody, convertedRoutedCustomToolNames);
|
|
2331
|
-
outBody = rewritten.body;
|
|
2332
|
-
convertedRoutedNamespaceToolAliases = rewritten.aliases;
|
|
2333
|
-
// Preserve xAI's cached-only fail-closed semantics and image-search mapping before the
|
|
2334
|
-
// generic capability fallback removes the private OpenAI fields.
|
|
2335
|
-
outBody = normalizeXaiResponsesWebSearch(outBody, provider);
|
|
2336
|
-
outBody = injectXaiResponsesXSearch(outBody, provider, parsed._replayPrefixLen);
|
|
2337
|
-
// xAI and explicitly classified compatible gateways reject these OpenAI web_search
|
|
2338
|
-
// extensions. Keep them for OpenAI API-key traffic and unclassified gateways.
|
|
2339
|
-
if (provider.supportsOpenAiWebSearchToolFields === false) {
|
|
2340
|
-
outBody = stripOpenAiOnlyWebSearchFields(outBody);
|
|
2341
|
-
}
|
|
2342
|
-
outBody = stripMuseSparkUnsupportedWebSearchFields(outBody, parsed.modelId, url);
|
|
2343
|
-
// Host-only: api.meta.ai rejects function names over 64 chars on every Muse model,
|
|
2344
|
-
// including default muse-spark-1.3. Do not reuse the contributor/Zen web_search
|
|
2345
|
-
// predicates. Namespace flattening has already produced the public wire names.
|
|
2346
|
-
if (isMetaAiResponsesDestination(url)) {
|
|
2347
|
-
const rewritten = rewriteMuseToolNamesForUpstream(outBody);
|
|
2348
|
-
outBody = rewritten.body;
|
|
2349
|
-
convertedMuseToolNameAliases = rewritten.aliases;
|
|
2350
|
-
}
|
|
2351
|
-
// Last, so promoted namespace children are also cleared of Codex-private fields.
|
|
2352
|
-
outBody = stripCanonicalOnlyToolFields(outBody, provider.supportsOpenAiWebSearchToolFields === false);
|
|
2353
|
-
}
|
|
2354
|
-
if (!forward) outBody = normalizeOpenCodeGoAdditionalTools(outBody, url);
|
|
2355
|
-
// Same predicate as the routedCompaction gate in handleResponses(): an authMode check would
|
|
2356
|
-
// let a noncanonical custom forward provider skip this rewrite while the server still routes
|
|
2357
|
-
// it as a summarizer turn (#422). The compaction body build removes the tool surface and must
|
|
2358
|
-
// therefore be the last routed transform that may depend on those declarations. Structural
|
|
2359
|
-
// sanitizers below can still run after it.
|
|
2360
|
-
outBody = normalizeResponsesCodeMode(outBody, parsed, provider);
|
|
2361
|
-
if (parsed._compactionRequest === true && !isCanonicalOpenAiForwardProvider(provider)) {
|
|
2362
|
-
outBody = buildRoutedCompactionBody(outBody);
|
|
2363
|
-
}
|
|
2364
|
-
// Run after routed compaction so nested input_image parts are replaced before a malformed
|
|
2365
|
-
// tool output is flattened to text and can no longer be inspected structurally.
|
|
2366
|
-
outBody = repairUnidentifiedToolOutputItems(outBody);
|
|
2367
|
-
if (parsed._plaintextV2AgentMessages === true && isCanonicalOpenAiForwardProvider(provider)) {
|
|
2368
|
-
const prepared = preparePlaintextV2AgentMessages(outBody);
|
|
2369
|
-
outBody = prepared.body;
|
|
2370
|
-
if (prepared.namespaceAliased) {
|
|
2371
|
-
plaintextV2AgentMessageToolNames = prepared.toolNames;
|
|
2372
|
-
plaintextV2AgentMessageAliasedToolNames = prepared.aliasedAgentMessageToolNames;
|
|
2373
|
-
}
|
|
2374
|
-
}
|
|
2375
|
-
const threadServingIdentityChanged = parsed._stripReasoningEncryptedContent === true;
|
|
2376
|
-
const sanitizedBody = normalizeToolSchemas(
|
|
2377
|
-
stripItemIdsWhenUnstored(
|
|
2378
|
-
stripInvalidItemIds(
|
|
2379
|
-
stripUnsupportedHostedTools(
|
|
2380
|
-
sanitizeReasoningInputContent(
|
|
2381
|
-
scrubOcxCompactionItems(
|
|
2382
|
-
outBody,
|
|
2383
|
-
destinationDecodesNativeCompactionBlob(provider),
|
|
2384
|
-
threadServingIdentityChanged,
|
|
2385
|
-
),
|
|
2386
|
-
{
|
|
2387
|
-
preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true,
|
|
2388
|
-
dropNullContentChannel: !isOpenAiOperatedResponsesDestination(provider),
|
|
2389
|
-
stripEncryptedContent: threadServingIdentityChanged,
|
|
2390
|
-
},
|
|
2391
|
-
),
|
|
2392
|
-
provider,
|
|
2393
|
-
),
|
|
2394
|
-
),
|
|
2395
|
-
),
|
|
2396
|
-
isXaiSchemaTarget(provider),
|
|
2397
|
-
);
|
|
2398
|
-
const unnormalizedBody = stripDisabledVerbosity(
|
|
2399
|
-
stripDisabledReasoningSummaries(
|
|
2400
|
-
normalizeConfiguredReasoningSummaryDelivery(sanitizedBody, provider, parsed.modelId),
|
|
2401
|
-
provider,
|
|
2402
|
-
parsed.modelId,
|
|
2403
|
-
),
|
|
2404
|
-
provider,
|
|
2405
|
-
parsed.modelId,
|
|
2406
|
-
);
|
|
2407
|
-
// Normalize the wire model before deriving model-dependent transport metadata.
|
|
2408
|
-
const finalBody =
|
|
2409
|
-
provider.modelSuffixBracketStrip
|
|
2410
|
-
&& unnormalizedBody !== null
|
|
2411
|
-
&& typeof unnormalizedBody === "object"
|
|
2412
|
-
&& !Array.isArray(unnormalizedBody)
|
|
2413
|
-
&& typeof (unnormalizedBody as { model?: unknown }).model === "string"
|
|
2414
|
-
? { ...(unnormalizedBody as Record<string, unknown>), model: stripBracketedModelSuffix((unnormalizedBody as { model: string }).model) }
|
|
2415
|
-
: unnormalizedBody;
|
|
2416
|
-
if (isCanonicalOpenAiForwardProvider(provider)) {
|
|
2417
|
-
const routingHeaders = new Headers(headers);
|
|
2418
|
-
applyCodexRoutingHint(routingHeaders, finalBody);
|
|
2419
|
-
// Static headers may use mixed casing. Remove every stale spelling
|
|
2420
|
-
// without normalizing unrelated headers returned by this adapter.
|
|
2421
|
-
for (const name of Object.keys(headers)) {
|
|
2422
|
-
if (name.toLowerCase() === CODEX_ROUTING_HINT_HEADER) delete headers[name];
|
|
2423
|
-
}
|
|
2424
|
-
const hint = routingHeaders.get(CODEX_ROUTING_HINT_HEADER);
|
|
2425
|
-
if (hint !== null) headers[CODEX_ROUTING_HINT_HEADER] = hint;
|
|
2426
|
-
}
|
|
2427
|
-
const actualServiceTier = isPlainObject(finalBody) && typeof finalBody.service_tier === "string"
|
|
2428
|
-
? finalBody.service_tier
|
|
2429
|
-
: null;
|
|
2430
|
-
const tierLog = createAdapterTierMetadata(
|
|
2431
|
-
parsed.options?.tierObservation,
|
|
2432
|
-
parsed.options?.tierDecision,
|
|
2433
|
-
actualServiceTier === null ? null : "service-tier",
|
|
2434
|
-
actualServiceTier,
|
|
2435
|
-
);
|
|
2436
|
-
// The Responses adapter is passthrough: it forwards `parsed._rawBody` rather than
|
|
2437
|
-
// rebuilding the body from `parsed.modelId`, and the router writes the routed id into
|
|
2438
|
-
// that raw body. So a provider whose upstream rejects bracketed ids has to be honoured
|
|
2439
|
-
// here, on the serialized body, not on the parsed selector. One place covers both the
|
|
2440
|
-
// HTTP and the WebSocket outbound, because the WS path transports this same request
|
|
2441
|
-
// instead of rebuilding it.
|
|
2442
|
-
const body = JSON.stringify(finalBody);
|
|
2443
|
-
const releaseBodyObservation = translatorBudget.observeExternallyCapped(
|
|
2444
|
-
"passthrough_serialization",
|
|
2445
|
-
Buffer.byteLength(body, "utf8"),
|
|
2446
|
-
);
|
|
2447
|
-
return {
|
|
2448
|
-
url,
|
|
2449
|
-
method: "POST",
|
|
2450
|
-
headers,
|
|
2451
|
-
body,
|
|
2452
|
-
releaseBodyObservation,
|
|
2453
|
-
...(convertedRoutedCustomToolNames ? { convertedRoutedCustomToolNames } : {}),
|
|
2454
|
-
...(routedCustomToolRepairNames ? { routedCustomToolRepairNames } : {}),
|
|
2455
|
-
...(convertedRoutedToolSearchNames ? { convertedRoutedToolSearchNames } : {}),
|
|
2456
|
-
...(convertedRoutedNamespaceToolAliases ? { convertedRoutedNamespaceToolAliases } : {}),
|
|
2457
|
-
...(plaintextV2AgentMessageToolNames ? { plaintextV2AgentMessageToolNames } : {}),
|
|
2458
|
-
...(plaintextV2AgentMessageAliasedToolNames ? { plaintextV2AgentMessageAliasedToolNames } : {}),
|
|
2459
|
-
...(convertedMuseToolNameAliases ? { convertedMuseToolNameAliases } : {}),
|
|
2460
|
-
...(tierLog ? { tierLog } : {}),
|
|
2461
|
-
};
|
|
2462
|
-
},
|
|
2463
|
-
|
|
2464
|
-
// The passthrough normally relays the upstream stream verbatim and never parses.
|
|
2465
|
-
// The exception is a routed compaction turn: the server drives this adapter like
|
|
2466
|
-
// an ordinary one so the bridge can build the single compaction item (#422).
|
|
2467
|
-
async *parseStream(response: Response, budget: TranslatorBudget): AsyncGenerator<AdapterEvent> {
|
|
2468
|
-
if (!response.body) {
|
|
2469
|
-
yield { type: "error", message: "passthrough adapter received no response body" };
|
|
2470
|
-
return;
|
|
2471
|
-
}
|
|
2472
|
-
let deltas = "";
|
|
2473
|
-
let deltasBytes = 0;
|
|
2474
|
-
let deltasLastCodeUnit = 0;
|
|
2475
|
-
let doneText = "";
|
|
2476
|
-
let doneTextBytes = 0;
|
|
2477
|
-
let doneTextLastCodeUnit = 0;
|
|
2478
|
-
let snapshot = "";
|
|
2479
|
-
let snapshotBytes = 0;
|
|
2480
|
-
let usage: OcxUsage | undefined;
|
|
2481
|
-
let usageRawBytes = 0;
|
|
2482
|
-
let compactionEncryptedContent: string | undefined;
|
|
2483
|
-
let compactionEncryptedContentBytes = 0;
|
|
2484
|
-
let completedSeen = false;
|
|
2485
|
-
for await (const event of decodeServerSentEvents(response.body, { translatorBudget: budget })) {
|
|
2486
|
-
let payload: unknown;
|
|
2487
|
-
try { payload = JSON.parse(event.data); } catch { continue; }
|
|
2488
|
-
if (!isPlainObject(payload)) continue;
|
|
2489
|
-
switch (payload.type) {
|
|
2490
|
-
case "response.output_text.delta":
|
|
2491
|
-
if (typeof payload.delta === "string") {
|
|
2492
|
-
const next = deltas + payload.delta;
|
|
2493
|
-
const nextBytes = appendedUtf8Bytes(deltasBytes, deltasLastCodeUnit, payload.delta);
|
|
2494
|
-
const reservation = budget.reserveTransient(nextBytes, { kind: "retained_collectors" });
|
|
2495
|
-
deltas = next;
|
|
2496
|
-
reservation.commitRetained();
|
|
2497
|
-
budget.releaseRetained(deltasBytes, { kind: "retained_collectors" });
|
|
2498
|
-
deltasBytes = nextBytes;
|
|
2499
|
-
if (payload.delta.length > 0) deltasLastCodeUnit = payload.delta.charCodeAt(payload.delta.length - 1);
|
|
2500
|
-
}
|
|
2501
|
-
break;
|
|
2502
|
-
case "response.output_text.done":
|
|
2503
|
-
if (typeof payload.text === "string") {
|
|
2504
|
-
const next = doneText + payload.text;
|
|
2505
|
-
const nextBytes = appendedUtf8Bytes(doneTextBytes, doneTextLastCodeUnit, payload.text);
|
|
2506
|
-
const reservation = budget.reserveTransient(nextBytes, { kind: "retained_collectors" });
|
|
2507
|
-
doneText = next;
|
|
2508
|
-
reservation.commitRetained();
|
|
2509
|
-
budget.releaseRetained(doneTextBytes, { kind: "retained_collectors" });
|
|
2510
|
-
doneTextBytes = nextBytes;
|
|
2511
|
-
if (payload.text.length > 0) doneTextLastCodeUnit = payload.text.charCodeAt(payload.text.length - 1);
|
|
2512
|
-
}
|
|
2513
|
-
break;
|
|
2514
|
-
case "response.failed":
|
|
2515
|
-
case "error":
|
|
2516
|
-
yield { type: "error", message: responsesErrorMessage(payload.response ?? payload) };
|
|
2517
|
-
return;
|
|
2518
|
-
case "response.incomplete":
|
|
2519
|
-
yield { type: "incomplete", reason: responsesErrorMessage(payload.response ?? payload) };
|
|
2520
|
-
return;
|
|
2521
|
-
case "response.completed":
|
|
2522
|
-
{
|
|
2523
|
-
completedSeen = true;
|
|
2524
|
-
const responsePayload = isPlainObject(payload.response) ? payload.response : undefined;
|
|
2525
|
-
const output = Array.isArray(responsePayload?.output) ? responsePayload.output : [];
|
|
2526
|
-
const compaction = output.find(item => isPlainObject(item) && item.type === "compaction");
|
|
2527
|
-
if (isPlainObject(compaction) && typeof compaction.encrypted_content === "string") {
|
|
2528
|
-
const nextEncryptedContent = compaction.encrypted_content;
|
|
2529
|
-
const nextEncryptedContentBytes = Buffer.byteLength(nextEncryptedContent, "utf8");
|
|
2530
|
-
const reservation = budget.reserveTransient(nextEncryptedContentBytes, { kind: "retained_collectors" });
|
|
2531
|
-
compactionEncryptedContent = nextEncryptedContent;
|
|
2532
|
-
reservation.commitRetained();
|
|
2533
|
-
budget.releaseRetained(compactionEncryptedContentBytes, { kind: "retained_collectors" });
|
|
2534
|
-
compactionEncryptedContentBytes = nextEncryptedContentBytes;
|
|
2535
|
-
}
|
|
2536
|
-
const next = responsesPayloadText(payload.response);
|
|
2537
|
-
const nextBytes = Buffer.byteLength(next, "utf8");
|
|
2538
|
-
const reservation = budget.reserveTransient(nextBytes, { kind: "retained_collectors" });
|
|
2539
|
-
snapshot = next;
|
|
2540
|
-
reservation.commitRetained();
|
|
2541
|
-
budget.releaseRetained(snapshotBytes, { kind: "retained_collectors" });
|
|
2542
|
-
snapshotBytes = nextBytes;
|
|
2543
|
-
}
|
|
2544
|
-
{
|
|
2545
|
-
const nextUsage = usageFromResponsesPayload(payload.response);
|
|
2546
|
-
// The attached raw usage object can be event-sized (unknown keys carry arbitrary
|
|
2547
|
-
// values); it stays reachable until the terminal yields, so charge it like the
|
|
2548
|
-
// adjacent retained collectors or it would defeat the per-request memory cap.
|
|
2549
|
-
const nextRawBytes = nextUsage?.rawUsage === undefined ? 0
|
|
2550
|
-
: Buffer.byteLength(JSON.stringify(nextUsage.rawUsage), "utf8");
|
|
2551
|
-
if (nextRawBytes > 0) {
|
|
2552
|
-
const reservation = budget.reserveTransient(nextRawBytes, { kind: "retained_collectors" });
|
|
2553
|
-
usage = nextUsage;
|
|
2554
|
-
reservation.commitRetained();
|
|
2555
|
-
} else {
|
|
2556
|
-
usage = nextUsage;
|
|
2557
|
-
}
|
|
2558
|
-
if (usageRawBytes > 0) {
|
|
2559
|
-
budget.releaseRetained(usageRawBytes, { kind: "retained_collectors" });
|
|
2560
|
-
}
|
|
2561
|
-
usageRawBytes = nextRawBytes;
|
|
2562
|
-
}
|
|
2563
|
-
break;
|
|
2564
|
-
}
|
|
2565
|
-
// Buffered text is still upstream progress, but gateway keepalives are not.
|
|
2566
|
-
// Yield after accounting, directly to the consumer: no progress queue or content leak.
|
|
2567
|
-
if (
|
|
2568
|
-
!completedSeen
|
|
2569
|
-
&& (payload.type === "response.output_text.delta"
|
|
2570
|
-
|| payload.type === "response.reasoning_summary_text.delta"
|
|
2571
|
-
|| payload.type === "response.reasoning_text.delta")
|
|
2572
|
-
&& typeof payload.delta === "string"
|
|
2573
|
-
&& payload.delta.length > 0
|
|
2574
|
-
) {
|
|
2575
|
-
yield { type: "heartbeat" };
|
|
2576
|
-
}
|
|
2577
|
-
}
|
|
2578
|
-
// Gateways differ in which of these they emit; prefer the authoritative
|
|
2579
|
-
// completed snapshot so text is never double-counted.
|
|
2580
|
-
const text = snapshot || doneText || deltas;
|
|
2581
|
-
if (text) yield { type: "text_delta", text };
|
|
2582
|
-
budget.releaseRetained(
|
|
2583
|
-
deltasBytes + doneTextBytes + snapshotBytes + usageRawBytes,
|
|
2584
|
-
{ kind: "retained_collectors" },
|
|
2585
|
-
);
|
|
2586
|
-
yield {
|
|
2587
|
-
type: "done",
|
|
2588
|
-
...(usage ? { usage } : {}),
|
|
2589
|
-
...(compactionEncryptedContent ? { compactionEncryptedContent } : {}),
|
|
2590
|
-
};
|
|
2591
|
-
},
|
|
2592
|
-
|
|
2593
|
-
async parseResponse(response: Response, budget: TranslatorBudget): Promise<AdapterEvent[]> {
|
|
2594
|
-
let payload: unknown;
|
|
2595
|
-
try { payload = await response.json(); } catch {
|
|
2596
|
-
return [{ type: "error", message: "malformed upstream compaction response" }];
|
|
2597
|
-
}
|
|
2598
|
-
budget.chargeRetained(Buffer.byteLength(JSON.stringify(payload), "utf8"), { kind: "retained_collectors" });
|
|
2599
|
-
if (!isPlainObject(payload)) {
|
|
2600
|
-
return [{ type: "error", message: "malformed upstream compaction response" }];
|
|
2601
|
-
}
|
|
2602
|
-
if (payload.error || payload.status === "failed") {
|
|
2603
|
-
return [{ type: "error", message: responsesErrorMessage(payload) }];
|
|
2604
|
-
}
|
|
2605
|
-
if (payload.status === "incomplete") {
|
|
2606
|
-
return [{ type: "incomplete", reason: responsesErrorMessage(payload) }];
|
|
2607
|
-
}
|
|
2608
|
-
const usage = usageFromResponsesPayload(payload);
|
|
2609
|
-
const output = Array.isArray(payload.output) ? payload.output : [];
|
|
2610
|
-
const compaction = output.find(item => isPlainObject(item) && item.type === "compaction");
|
|
2611
|
-
const compactionEncryptedContent = isPlainObject(compaction) && typeof compaction.encrypted_content === "string"
|
|
2612
|
-
? compaction.encrypted_content
|
|
2613
|
-
: undefined;
|
|
2614
|
-
const text = responsesPayloadText(payload);
|
|
2615
|
-
if (!text && !compactionEncryptedContent) {
|
|
2616
|
-
// A completed turn with neither text nor a native compaction blob cannot become a
|
|
2617
|
-
// replacement-history item. A ciphertext-only native completion is valid, though.
|
|
2618
|
-
return [{ type: "error", message: "upstream compaction returned no summary text" }];
|
|
2619
|
-
}
|
|
2620
|
-
return [...(text ? [{ type: "text_delta" as const, text }] : []), {
|
|
2621
|
-
type: "done",
|
|
2622
|
-
...(usage ? { usage } : {}),
|
|
2623
|
-
...(compactionEncryptedContent ? { compactionEncryptedContent } : {}),
|
|
2624
|
-
}];
|
|
2625
|
-
},
|
|
2626
|
-
};
|
|
2627
|
-
}
|
|
3
|
+
export { stripCanonicalForwardSamplingParams } from "./openai-responses/canonical-forward";
|
|
4
|
+
export { FORWARD_HEADERS, createResponsesPassthroughAdapter } from "./openai-responses/passthrough";
|
|
5
|
+
export { sanitizeReasoningInputContent } from "./openai-responses/reasoning";
|
|
6
|
+
export { stripOpenAiOnlyWebSearchFields } from "./openai-responses/web-search";
|