@bitkyc08/opencodex 2.55.0 → 2.56.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/gui/dist/assets/{index-VuoiWj9J.js → index-D4zuyIxQ.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +2 -1
- package/src/adapters/base.ts +21 -0
- package/src/adapters/cursor/transport-retry.ts +46 -1
- package/src/adapters/cursor.ts +4 -0
- package/src/adapters/kiro/adapter.ts +42 -1
- 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-schema.ts +477 -0
- package/src/adapters/openai-chat/wire.ts +50 -0
- package/src/adapters/openai-chat.ts +33 -1445
- 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 +611 -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 +34 -0
- package/src/bridge/internal.ts +174 -0
- package/src/bridge/response-json.ts +624 -0
- package/src/bridge/sse.ts +1444 -0
- package/src/bridge.ts +5 -2204
- package/src/chat/inbound.ts +12 -1
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-store.ts +71 -9
- 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 +554 -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 +422 -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 +95 -28
- 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 +304 -0
- package/src/codex/catalog/provider-fetch.ts +52 -2942
- package/src/codex/catalog/provider-models.ts +685 -0
- package/src/codex/catalog/restore.ts +132 -0
- package/src/codex/catalog/retained-sync.ts +706 -0
- package/src/codex/catalog/routed-gather.ts +858 -0
- package/src/codex/catalog/subagent-roster.ts +176 -0
- package/src/codex/catalog/sync.ts +52 -2698
- package/src/codex/inject/config-toml.ts +563 -0
- package/src/codex/inject/remove.ts +192 -0
- package/src/codex/inject/restore.ts +540 -0
- package/src/codex/inject/routing-classify.ts +109 -0
- package/src/codex/inject/routing-target.ts +125 -0
- package/src/codex/inject.ts +81 -1436
- package/src/codex/lineage.ts +458 -0
- package/src/codex/pool-refresh-backoff.ts +152 -0
- package/src/codex/routing/active-account.ts +194 -0
- package/src/codex/routing/cooldown-math.ts +275 -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 +703 -0
- package/src/codex/routing/thread-affinity.ts +538 -0
- package/src/codex/routing.ts +353 -2234
- 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/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/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 +539 -39
- package/src/lib/request-execution-budget.ts +69 -20
- package/src/lib/spend-reservation-ledger.ts +940 -0
- package/src/lib/upstream-retry.ts +55 -11
- package/src/lib/workflow-budget.ts +553 -30
- 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 +1221 -0
- package/src/providers/registry/entries-extended.ts +1204 -0
- package/src/providers/registry/model-seeds.ts +908 -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/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 +449 -0
- package/src/routing/probe-lease.ts +511 -0
- package/src/server/index/bounded-request.ts +88 -0
- package/src/server/index/live-sideband.ts +565 -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 +335 -0
- package/src/server/index.ts +40 -2547
- 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/request-log-conversation.ts +9 -7
- package/src/server/request-log.ts +245 -1
- package/src/server/responses/account-change-state.ts +233 -0
- package/src/server/responses/adapter-continuation.ts +514 -0
- package/src/server/responses/adapter-delivery.ts +214 -0
- package/src/server/responses/adapter-dispatch.ts +971 -0
- package/src/server/responses/compact.ts +59 -4
- package/src/server/responses/completion-policy.ts +33 -0
- package/src/server/responses/core-auth.ts +527 -0
- package/src/server/responses/core-codex-account.ts +859 -0
- package/src/server/responses/core-combo-failure.ts +210 -0
- package/src/server/responses/core-combo.ts +707 -0
- package/src/server/responses/core-errors.ts +152 -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 +225 -0
- package/src/server/responses/core.ts +192 -8893
- package/src/server/responses/passthrough-delivery.ts +856 -0
- package/src/server/responses/passthrough-dispatch.ts +1476 -0
- package/src/server/responses/passthrough-execution.ts +54 -0
- package/src/server/responses/request-prepare.ts +970 -0
- package/src/server/responses/request-send-budget.ts +164 -0
- package/src/server/responses/request-sidecar-auth.ts +149 -0
- package/src/server/responses/request-transport.ts +744 -0
- package/src/server/responses/response-effects.ts +157 -0
- package/src/server/responses/run-turn-execution.ts +448 -0
- package/src/server/responses/sidecar-execution.ts +469 -0
- package/src/server/responses-image-gen-repair.ts +1 -1
- package/src/server/workflow-refusal.ts +84 -0
- package/src/types/config.ts +30 -0
- package/src/usage/log.ts +146 -0
- package/src/usage/summary.ts +171 -21
|
@@ -1,1462 +1,50 @@
|
|
|
1
1
|
import { hasShrinkableOpenAIChatImages, normalizeOpenAIChatImages } from "./openai-chat-images";
|
|
2
2
|
import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "./base";
|
|
3
|
-
import type { AdapterEvent,
|
|
4
|
-
import {
|
|
3
|
+
import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxUsage } from "../types";
|
|
4
|
+
import { modelInList } from "../types";
|
|
5
5
|
import { mapReasoningEffort, modelRecordValue } from "../reasoning-effort";
|
|
6
|
-
import { registryEntryForProviderDestination } from "../providers/registry";
|
|
7
6
|
import { debugProviderDiagnostic } from "../lib/debug";
|
|
8
7
|
import { sseFieldValue } from "../lib/sse-decoder";
|
|
9
8
|
import { isDebugEnabled } from "../lib/debug-settings";
|
|
10
|
-
import {
|
|
11
|
-
import { redactSecretString } from "../lib/redact";
|
|
12
|
-
import { contentPartsToText } from "./image";
|
|
13
|
-
import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "./empty-tool-output-annotation";
|
|
14
|
-
import { identifyRoutedModel } from "./identity";
|
|
15
|
-
import { peekReasoningForCall } from "../responses/reasoning-replay-cache";
|
|
16
|
-
import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "./tool-catalog-nudge";
|
|
9
|
+
import { frameAgentRouterMessages } from "./agentrouter";
|
|
17
10
|
import { openRouterProviderPayload, resolveOpenRouterRouting } from "../providers/openrouter-routing";
|
|
18
11
|
import { resolveVercelGatewayRouting, vercelGatewayProviderPayload } from "../providers/vercel-gateway-routing";
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
fastPolicyForModel,
|
|
22
|
-
supportsServiceTierForModel,
|
|
23
|
-
} from "../providers/service-tier";
|
|
24
|
-
import {
|
|
25
|
-
canonicalFastTierMarker,
|
|
26
|
-
createAdapterTierMetadata,
|
|
27
|
-
decideTier,
|
|
28
|
-
type AdapterTierMetadata,
|
|
29
|
-
type ResolvedFastPolicy,
|
|
30
|
-
} from "../providers/fastwire";
|
|
31
|
-
import { openaiChatCompletionsUrl } from "./openai-chat-url";
|
|
32
|
-
import { stripResponsesOnlyEncryptedMarker, stripUnicodePropertyPatterns } from "./responses-tool-schema";
|
|
33
|
-
import { agentRouterDefaultHeaders, frameAgentRouterMessages } from "./agentrouter";
|
|
34
|
-
import {
|
|
35
|
-
isXaiSchemaTarget,
|
|
36
|
-
lookupLocalJsonPointer,
|
|
37
|
-
normalizeXaiToolParameters,
|
|
38
|
-
} from "./xai-tool-schema";
|
|
12
|
+
import { fastPolicyForModel } from "../providers/service-tier";
|
|
13
|
+
import { createAdapterTierMetadata, decideTier, type AdapterTierMetadata } from "../providers/fastwire";
|
|
39
14
|
import {
|
|
40
15
|
isTranslatorBudgetExceededError,
|
|
41
16
|
retainTranslatedEventBatch,
|
|
42
17
|
TRANSLATOR_MAX_SSE_EVENT_BYTES,
|
|
43
18
|
type TranslatorBudget,
|
|
44
19
|
} from "../lib/translator-budget";
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
"response_format",
|
|
74
|
-
"seed",
|
|
75
|
-
"stop",
|
|
76
|
-
"store",
|
|
77
|
-
"temperature",
|
|
78
|
-
"tool_choice",
|
|
79
|
-
"tools",
|
|
80
|
-
"top_logprobs",
|
|
81
|
-
"top_p",
|
|
82
|
-
"user",
|
|
83
|
-
"web_search_options",
|
|
84
|
-
] as const;
|
|
85
|
-
|
|
86
|
-
function openAIChatTransport(provider: OcxProviderConfig): {
|
|
87
|
-
url: string;
|
|
88
|
-
headers: Record<string, string>;
|
|
89
|
-
hasCredential: boolean;
|
|
90
|
-
} {
|
|
91
|
-
const hasCredential = typeof provider.apiKey === "string" && provider.apiKey.trim().length > 0;
|
|
92
|
-
if ((provider.authMode === "key" || provider.authMode === "oauth") && !provider.keyOptional && !hasCredential) {
|
|
93
|
-
throw new Error(`${provider.adapter} requires a non-empty credential (authMode: ${provider.authMode})`);
|
|
94
|
-
}
|
|
95
|
-
const headers: Record<string, string> = {
|
|
96
|
-
"Content-Type": "application/json",
|
|
97
|
-
...agentRouterDefaultHeaders(provider.baseUrl, provider.headers),
|
|
98
|
-
};
|
|
99
|
-
if (hasCredential) headers.Authorization = `Bearer ${provider.apiKey}`;
|
|
100
|
-
if (provider.headers) Object.assign(headers, provider.headers);
|
|
101
|
-
// A configured relative path wins, mirroring how the Responses adapter honours
|
|
102
|
-
// `responsesPath`. An upstream can serve both wires under different prefixes, and a
|
|
103
|
-
// per-model wire override only swaps the adapter, so without this the opted-in Chat
|
|
104
|
-
// request would be sent to the Responses base with `/chat/completions` appended.
|
|
105
|
-
const url = provider.chatCompletionsPath === undefined
|
|
106
|
-
? openaiChatCompletionsUrl(provider.baseUrl)
|
|
107
|
-
: `${provider.baseUrl.replace(/\/$/, "")}${provider.chatCompletionsPath}`;
|
|
108
|
-
return { url, headers, hasCredential };
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* The translated Chat route has no video mapping: this adapter does not implement one,
|
|
113
|
-
* and the marker records that fact so the payload is not dropped in silence.
|
|
114
|
-
*
|
|
115
|
-
* The wording is deliberately about opencodex's own translation, not the provider or
|
|
116
|
-
* model. An earlier revision said "unsupported by this provider", which attributed an
|
|
117
|
-
* opencodex mapping limit to upstream capability the proxy has not established. Native
|
|
118
|
-
* Chat passthrough and Google inline video are unaffected by this route.
|
|
119
|
-
*/
|
|
120
|
-
const VIDEO_UNSUPPORTED_MARKER = "[video omitted: the translated Chat route has no video mapping]";
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Build a provider request from an inbound Chat Completions body without translating it
|
|
124
|
-
* through the Responses contract. This is deliberately a whitelist: Chat-only caller
|
|
125
|
-
* fields retain their exact wire representation, while provider capability gates remain
|
|
126
|
-
* centralized beside the ordinary openai-chat adapter.
|
|
127
|
-
*/
|
|
128
|
-
export function buildOpenAIChatPassthroughRequest(
|
|
129
|
-
provider: OcxProviderConfig,
|
|
130
|
-
rawBody: Record<string, unknown>,
|
|
131
|
-
modelId: string,
|
|
132
|
-
stream: boolean,
|
|
133
|
-
fastPolicy: ResolvedFastPolicy = fastPolicyForModel(provider, modelId, undefined, "chat"),
|
|
134
|
-
fastMode?: boolean,
|
|
135
|
-
): AdapterRequest {
|
|
136
|
-
const { url, headers, hasCredential } = openAIChatTransport(provider);
|
|
137
|
-
|
|
138
|
-
const body: Record<string, unknown> = {
|
|
139
|
-
model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(modelId) : modelId,
|
|
140
|
-
messages: frameAgentRouterMessages(provider.baseUrl, rawBody.messages),
|
|
141
|
-
stream,
|
|
142
|
-
};
|
|
143
|
-
for (const field of CHAT_PASSTHROUGH_FIELDS) {
|
|
144
|
-
if (rawBody[field] !== undefined) body[field] = rawBody[field];
|
|
145
|
-
}
|
|
146
|
-
const rawEfforts = modelRecordValue(provider.modelReasoningEfforts, modelId) ?? provider.reasoningEfforts;
|
|
147
|
-
if (modelInList(provider.noReasoningModels, modelId) || rawEfforts?.length === 0) {
|
|
148
|
-
delete body.reasoning_effort;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const openRouterRouting = resolveOpenRouterRouting(provider, modelId);
|
|
152
|
-
if (openRouterRouting) body.provider = openRouterProviderPayload(openRouterRouting);
|
|
153
|
-
const vercelRouting = resolveVercelGatewayRouting(provider, modelId);
|
|
154
|
-
if (vercelRouting) body.provider = vercelGatewayProviderPayload(vercelRouting);
|
|
155
|
-
|
|
156
|
-
if (modelInList(provider.noTemperatureModels, modelId)) delete body.temperature;
|
|
157
|
-
if (modelInList(provider.noTopPModels, modelId)) delete body.top_p;
|
|
158
|
-
if (modelInList(provider.noPenaltyModels, modelId)) {
|
|
159
|
-
delete body.presence_penalty;
|
|
160
|
-
delete body.frequency_penalty;
|
|
161
|
-
}
|
|
162
|
-
// Exact match, unlike the gates above: `noStructuredOutputModels` is documented as
|
|
163
|
-
// "only an exact requested-model match omits the field" (#1424), and the Responses
|
|
164
|
-
// ingress enforces exactly that. A prefix match here would strip response_format from
|
|
165
|
-
// `<listed>:<tag>` siblings the operator never opted out, silently returning prose.
|
|
166
|
-
if (provider.noStructuredOutputModels?.includes(modelId)) delete body.response_format;
|
|
167
|
-
// Narrower neighbour: the model takes `json_object` but rejects `json_schema`. Downgrade
|
|
168
|
-
// rather than drop, so a caller that asked for JSON still gets JSON. The type check also
|
|
169
|
-
// makes the kill switch above win without an else — after its `delete` there is no type
|
|
170
|
-
// left to match.
|
|
171
|
-
const passthroughFormat = body.response_format;
|
|
172
|
-
if (provider.noJsonSchemaModels?.includes(modelId)
|
|
173
|
-
&& typeof passthroughFormat === "object" && passthroughFormat !== null
|
|
174
|
-
&& (passthroughFormat as { type?: unknown }).type === "json_schema") {
|
|
175
|
-
body.response_format = { type: "json_object" };
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// Run the same complete Fast policy as the translated Chat path, including explicit
|
|
179
|
-
// fastMode and foreign-tier handling. On inherited canonical Fast, the passthrough still
|
|
180
|
-
// retains the caller's exact spelling; forced Fast uses the policy-owned wire value.
|
|
181
|
-
const callerTier = typeof rawBody.service_tier === "string" ? rawBody.service_tier : undefined;
|
|
182
|
-
const tierDecision = decideTier(fastPolicy, fastMode, callerTier);
|
|
183
|
-
if (tierDecision.kind === "set") {
|
|
184
|
-
body.service_tier = fastMode === undefined && canonicalFastTierMarker(callerTier) !== undefined
|
|
185
|
-
? callerTier
|
|
186
|
-
: tierDecision.value;
|
|
187
|
-
} else if (tierDecision.kind === "forward-caller" && rawBody.service_tier !== undefined) {
|
|
188
|
-
body.service_tier = rawBody.service_tier;
|
|
189
|
-
}
|
|
190
|
-
if (provider.promptCacheKey && rawBody.prompt_cache_key !== undefined) {
|
|
191
|
-
body.prompt_cache_key = rawBody.prompt_cache_key;
|
|
192
|
-
}
|
|
193
|
-
if (Array.isArray(rawBody.tools) && rawBody.tools.length > 0) {
|
|
194
|
-
if (provider.parallelToolCalls === true) {
|
|
195
|
-
body.parallel_tool_calls = rawBody.parallel_tool_calls !== false;
|
|
196
|
-
} else if (provider.parallelToolCalls === false
|
|
197
|
-
&& (provider.baseUrl === "https://integrate.api.nvidia.com/v1" || provider.pinParallelToolCallsFalse === true)) {
|
|
198
|
-
body.parallel_tool_calls = false;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
if (stream) {
|
|
202
|
-
const callerOptions = rawBody.stream_options !== null
|
|
203
|
-
&& typeof rawBody.stream_options === "object"
|
|
204
|
-
&& !Array.isArray(rawBody.stream_options)
|
|
205
|
-
? rawBody.stream_options as Record<string, unknown>
|
|
206
|
-
: {};
|
|
207
|
-
body.stream_options = { ...callerOptions, include_usage: true };
|
|
208
|
-
} else if (rawBody.stream_options !== undefined) {
|
|
209
|
-
body.stream_options = rawBody.stream_options;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
const bodyJson = JSON.stringify(body);
|
|
213
|
-
|
|
214
|
-
if (isDebugEnabled()) {
|
|
215
|
-
let host = "upstream";
|
|
216
|
-
try { host = new URL(url).host; } catch { /* keep fallback */ }
|
|
217
|
-
debugProviderDiagnostic("openai-chat", "passthrough-request", {
|
|
218
|
-
host,
|
|
219
|
-
model: body.model,
|
|
220
|
-
stream,
|
|
221
|
-
messageCount: Array.isArray(body.messages) ? body.messages.length : 0,
|
|
222
|
-
toolCount: Array.isArray(body.tools) ? body.tools.length : 0,
|
|
223
|
-
hasCredential,
|
|
224
|
-
bodyBytes: Buffer.byteLength(bodyJson, "utf8"),
|
|
225
|
-
});
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
return { url, method: "POST", headers, body: bodyJson };
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
// 260715 (issue #126): surface upstream error detail through the web-search sidecar loop.
|
|
232
|
-
// loop.ts only appends a suffix to "Provider error N" when the adapter exposes
|
|
233
|
-
// formatErrorBody; without it, strict OpenAI-compatible backends (NVIDIA NIM pydantic
|
|
234
|
-
// validation, "This model only supports single tool-calls at once!", etc.) were reduced
|
|
235
|
-
// to a bare status code. JSON-only extraction: recognized string fields are returned,
|
|
236
|
-
// HTML/non-JSON bodies yield "" so raw markup is never echoed to the client.
|
|
237
|
-
export function formatOpenAIChatErrorBody(status: number, _headers: Headers, payloadText: string): string {
|
|
238
|
-
let parsed: unknown;
|
|
239
|
-
try {
|
|
240
|
-
parsed = JSON.parse(payloadText);
|
|
241
|
-
} catch {
|
|
242
|
-
return "";
|
|
243
|
-
}
|
|
244
|
-
const detail = extractErrorDetail(parsed);
|
|
245
|
-
if (!detail) return "";
|
|
246
|
-
return redactSecretString(detail).slice(0, 400);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function extractErrorDetail(parsed: unknown): string | undefined {
|
|
250
|
-
if (typeof parsed === "string") return parsed.trim() || undefined;
|
|
251
|
-
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
252
|
-
const obj = parsed as Record<string, unknown>;
|
|
253
|
-
const err = obj.error;
|
|
254
|
-
if (typeof err === "string" && err.trim()) return err.trim();
|
|
255
|
-
if (err !== null && typeof err === "object" && !Array.isArray(err)) {
|
|
256
|
-
const msg = (err as Record<string, unknown>).message;
|
|
257
|
-
if (typeof msg === "string" && msg.trim()) return msg.trim();
|
|
258
|
-
}
|
|
259
|
-
const det = obj.detail;
|
|
260
|
-
if (typeof det === "string" && det.trim()) return det.trim();
|
|
261
|
-
if (Array.isArray(det)) {
|
|
262
|
-
const msgs = det
|
|
263
|
-
.map(item => (item !== null && typeof item === "object" && typeof (item as Record<string, unknown>).msg === "string"
|
|
264
|
-
? ((item as Record<string, unknown>).msg as string).trim()
|
|
265
|
-
: ""))
|
|
266
|
-
.filter(m => m.length > 0);
|
|
267
|
-
if (msgs.length > 0) return msgs.join("; ");
|
|
268
|
-
}
|
|
269
|
-
if (typeof obj.message === "string" && obj.message.trim()) return obj.message.trim();
|
|
270
|
-
if (typeof obj.title === "string" && obj.title.trim()) return obj.title.trim();
|
|
271
|
-
return undefined;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
function unwrapChatCompletionPayload(json: Record<string, unknown>): Record<string, unknown> {
|
|
275
|
-
if ((json.error !== undefined && json.error !== null) || Array.isArray(json.choices)) return json;
|
|
276
|
-
const data = json.data;
|
|
277
|
-
return data !== null && typeof data === "object" && !Array.isArray(data)
|
|
278
|
-
? data as Record<string, unknown>
|
|
279
|
-
: json;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
interface OpenAIChatError {
|
|
283
|
-
message?: unknown;
|
|
284
|
-
code?: unknown;
|
|
285
|
-
type?: unknown;
|
|
286
|
-
status?: unknown;
|
|
287
|
-
metadata?: unknown;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function safeUpstreamRequestId(metadata: unknown): string | undefined {
|
|
291
|
-
if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) return undefined;
|
|
292
|
-
const record = metadata as Record<string, unknown>;
|
|
293
|
-
const value = record.request_id ?? record.requestId;
|
|
294
|
-
if (typeof value !== "string") return undefined;
|
|
295
|
-
const requestId = value.trim();
|
|
296
|
-
return /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(requestId)
|
|
297
|
-
&& redactSecretString(requestId) === requestId
|
|
298
|
-
? requestId
|
|
299
|
-
: undefined;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
function upstreamErrorEvent(
|
|
303
|
-
error: unknown,
|
|
304
|
-
usage?: OcxUsage,
|
|
305
|
-
): Extract<AdapterEvent, { type: "error" }> {
|
|
306
|
-
const details = error !== null && typeof error === "object" && !Array.isArray(error)
|
|
307
|
-
? error as OpenAIChatError
|
|
308
|
-
: undefined;
|
|
309
|
-
const rawMessage = typeof error === "string"
|
|
310
|
-
? error.trim() || "upstream error"
|
|
311
|
-
: typeof details?.message === "string" ? details.message : "upstream error";
|
|
312
|
-
const safeMessage = redactSecretString(rawMessage);
|
|
313
|
-
const requestId = safeUpstreamRequestId(details?.metadata);
|
|
314
|
-
const message = requestId !== undefined && !safeMessage.includes(requestId)
|
|
315
|
-
? `${safeMessage} (request ID: ${requestId})`
|
|
316
|
-
: safeMessage;
|
|
317
|
-
const code = typeof details?.code === "string"
|
|
318
|
-
? details.code
|
|
319
|
-
: typeof details?.code === "number" && Number.isFinite(details.code) && Number.isInteger(details.code)
|
|
320
|
-
? String(details.code)
|
|
321
|
-
: undefined;
|
|
322
|
-
const errorType = typeof details?.type === "string" ? details.type : undefined;
|
|
323
|
-
const codeStatus = typeof details?.code === "number"
|
|
324
|
-
&& Number.isInteger(details.code)
|
|
325
|
-
&& details.code >= 100
|
|
326
|
-
&& details.code <= 599
|
|
327
|
-
? details.code
|
|
328
|
-
: undefined;
|
|
329
|
-
const status = isCyberPolicyCode(code)
|
|
330
|
-
? 400
|
|
331
|
-
: typeof details?.status === "number" && Number.isInteger(details.status)
|
|
332
|
-
? details.status
|
|
333
|
-
: codeStatus;
|
|
334
|
-
return {
|
|
335
|
-
type: "error",
|
|
336
|
-
message,
|
|
337
|
-
...(usage !== undefined ? { usage } : {}),
|
|
338
|
-
...(code !== undefined ? { code } : {}),
|
|
339
|
-
...(errorType !== undefined ? { errorType } : {}),
|
|
340
|
-
...(status !== undefined ? { status } : {}),
|
|
341
|
-
};
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
function stopReasonFor(finishReason: unknown): "max_tokens" | "content_filter" | undefined {
|
|
345
|
-
return finishReason === "length"
|
|
346
|
-
? "max_tokens"
|
|
347
|
-
: finishReason === "content_filter"
|
|
348
|
-
? "content_filter"
|
|
349
|
-
: undefined;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
function reasoningTextFrom(record: Record<string, unknown>): string | undefined {
|
|
353
|
-
return typeof record.reasoning_content === "string" && record.reasoning_content.length > 0
|
|
354
|
-
? record.reasoning_content
|
|
355
|
-
: typeof record.reasoning === "string" && record.reasoning.length > 0
|
|
356
|
-
? record.reasoning
|
|
357
|
-
: undefined;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
interface ReasoningDetailSegment {
|
|
361
|
-
key: string;
|
|
362
|
-
text: string;
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* Structured `reasoning_details` array (MiniMax M-series with `reasoning_split`).
|
|
367
|
-
* Each segment's key scopes cumulative-snapshot tracking: upstream repeats the
|
|
368
|
-
* full text-so-far under a stable `id`/`index` instead of sending increments.
|
|
369
|
-
*/
|
|
370
|
-
function reasoningDetailSegmentsFrom(record: Record<string, unknown>): ReasoningDetailSegment[] {
|
|
371
|
-
const raw = record.reasoning_details;
|
|
372
|
-
if (!Array.isArray(raw)) return [];
|
|
373
|
-
const segments: ReasoningDetailSegment[] = [];
|
|
374
|
-
for (let i = 0; i < raw.length; i++) {
|
|
375
|
-
const item: unknown = raw[i];
|
|
376
|
-
if (!isRecord(item)) continue;
|
|
377
|
-
if (typeof item.text !== "string" || item.text.length === 0) continue;
|
|
378
|
-
const key = typeof item.id === "string" && item.id.length > 0
|
|
379
|
-
? `id:${item.id}`
|
|
380
|
-
: typeof item.index === "number"
|
|
381
|
-
? `i:${item.index}`
|
|
382
|
-
: `n:${i}`;
|
|
383
|
-
segments.push({ key, text: item.text });
|
|
384
|
-
}
|
|
385
|
-
return segments;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
/** Single-segment `reasoning_details` entry for replaying preserved reasoning (MiniMax wire shape). */
|
|
389
|
-
function reasoningDetailSegmentForWire(text: string): Record<string, unknown> {
|
|
390
|
-
return { type: "reasoning.text", id: "reasoning-text-1", format: "MiniMax-response-v1", index: 0, text };
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
function invalidChoicesEvent(usage?: OcxUsage): Extract<AdapterEvent, { type: "error" }> {
|
|
394
|
-
return {
|
|
395
|
-
type: "error",
|
|
396
|
-
message: "upstream response contained invalid choices",
|
|
397
|
-
...(usage !== undefined ? { usage } : {}),
|
|
398
|
-
};
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
function invalidToolCallsEvent(
|
|
402
|
-
rawToolCalls: unknown,
|
|
403
|
-
mode: "stream" | "response",
|
|
404
|
-
usage?: OcxUsage,
|
|
405
|
-
diagnosticOverride?: InvalidToolCallDiagnostic,
|
|
406
|
-
): Extract<AdapterEvent, { type: "error" }> {
|
|
407
|
-
// The streamed accumulator knows things a rescan cannot: which field on which pending call
|
|
408
|
-
// was actually rejected. Without the override, a stream carrying accepted padding on call 0
|
|
409
|
-
// and a real defect on call 1 blames call 0, because the stateless scan stops at the first
|
|
410
|
-
// structurally odd value it sees.
|
|
411
|
-
const diagnostic = diagnosticOverride ?? diagnoseInvalidToolCalls(rawToolCalls, mode);
|
|
412
|
-
const detail = diagnostic
|
|
413
|
-
? ` (${diagnostic.reason}${diagnostic.callIndex !== undefined ? `; callIndex=${diagnostic.callIndex}` : ""}; valueType=${diagnostic.valueType})`
|
|
414
|
-
: "";
|
|
415
|
-
return {
|
|
416
|
-
type: "error",
|
|
417
|
-
status: 502,
|
|
418
|
-
errorType: "upstream_error",
|
|
419
|
-
message: `upstream response contained invalid tool calls${detail}`,
|
|
420
|
-
...(usage !== undefined ? { usage } : {}),
|
|
421
|
-
};
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
/**
|
|
425
|
-
* A streamed tool call is only dispatchable once the upstream has named the function.
|
|
426
|
-
*
|
|
427
|
-
* The OpenAI streaming convention puts `function.name` in the first chunk for a tool-call
|
|
428
|
-
* index and leaves later chunks carrying only `arguments` deltas, so a stream that never
|
|
429
|
-
* sends a name is non-conforming for every provider rather than quirky for one. The
|
|
430
|
-
* reference implementations accumulate such a call with an empty name and let the caller
|
|
431
|
-
* fail; we sit at the boundary where it would become a Codex tool-call contract event, so
|
|
432
|
-
* the equivalent is to refuse to emit it.
|
|
433
|
-
*
|
|
434
|
-
* Failing closed rather than dropping is deliberate, and matches #1325: a claimed tool call
|
|
435
|
-
* that silently disappears can leave the matching result orphaned on the next turn. Naming
|
|
436
|
-
* it ourselves is worse still — the id is synthesizable because it is an opaque correlation
|
|
437
|
-
* handle, but a function name is a guess at intent.
|
|
438
|
-
*/
|
|
439
|
-
function unnamedToolCallEvent(usage?: OcxUsage): Extract<AdapterEvent, { type: "error" }> {
|
|
440
|
-
return {
|
|
441
|
-
type: "error",
|
|
442
|
-
message: "upstream streamed a tool call without a function name — cannot dispatch",
|
|
443
|
-
...(usage !== undefined ? { usage } : {}),
|
|
444
|
-
};
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
448
|
-
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
type InvalidToolCallReason =
|
|
452
|
-
| "tool_calls_not_array"
|
|
453
|
-
| "tool_call_not_object"
|
|
454
|
-
| "tool_call_id_invalid"
|
|
455
|
-
| "tool_call_function_not_object"
|
|
456
|
-
| "tool_call_function_name_invalid"
|
|
457
|
-
| "tool_call_function_name_blank"
|
|
458
|
-
| "tool_call_function_arguments_invalid";
|
|
459
|
-
|
|
460
|
-
type InvalidToolCallDiagnostic = {
|
|
461
|
-
reason: InvalidToolCallReason;
|
|
462
|
-
callIndex?: number;
|
|
463
|
-
valueType: string;
|
|
464
|
-
};
|
|
465
|
-
|
|
466
|
-
type InvalidFieldShape =
|
|
467
|
-
| {
|
|
468
|
-
kind: "object";
|
|
469
|
-
knownKeys: string[];
|
|
470
|
-
knownFieldTypes: Record<string, string>;
|
|
471
|
-
hasUnknownKeys: boolean;
|
|
472
|
-
}
|
|
473
|
-
| {
|
|
474
|
-
kind: "array";
|
|
475
|
-
length: number;
|
|
476
|
-
};
|
|
477
|
-
|
|
478
|
-
const SAFE_TOOL_CALL_SHAPE_KEYS = [
|
|
479
|
-
"name",
|
|
480
|
-
"type",
|
|
481
|
-
"value",
|
|
482
|
-
"function",
|
|
483
|
-
"arguments",
|
|
484
|
-
"id",
|
|
485
|
-
"index",
|
|
486
|
-
] as const;
|
|
487
|
-
const SAFE_TOOL_CALL_SHAPE_KEY_SET = new Set<string>(SAFE_TOOL_CALL_SHAPE_KEYS);
|
|
488
|
-
|
|
489
|
-
function structuralValueType(value: unknown): string {
|
|
490
|
-
return value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
function invalidToolCallField(rawToolCalls: unknown, diagnostic: InvalidToolCallDiagnostic): unknown {
|
|
494
|
-
if (diagnostic.reason === "tool_calls_not_array") return rawToolCalls;
|
|
495
|
-
if (!Array.isArray(rawToolCalls) || diagnostic.callIndex === undefined) return undefined;
|
|
496
|
-
|
|
497
|
-
const rawToolCall = rawToolCalls[diagnostic.callIndex];
|
|
498
|
-
if (diagnostic.reason === "tool_call_not_object") return rawToolCall;
|
|
499
|
-
if (!isRecord(rawToolCall)) return undefined;
|
|
500
|
-
if (diagnostic.reason === "tool_call_function_not_object") return rawToolCall.function;
|
|
501
|
-
|
|
502
|
-
const rawFunction = rawToolCall.function;
|
|
503
|
-
switch (diagnostic.reason) {
|
|
504
|
-
case "tool_call_id_invalid":
|
|
505
|
-
return rawToolCall.id;
|
|
506
|
-
case "tool_call_function_name_invalid":
|
|
507
|
-
return isRecord(rawFunction) ? rawFunction.name : undefined;
|
|
508
|
-
case "tool_call_function_arguments_invalid":
|
|
509
|
-
return isRecord(rawFunction) ? rawFunction.arguments : undefined;
|
|
510
|
-
default:
|
|
511
|
-
return undefined;
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
function fingerprintInvalidField(value: unknown): InvalidFieldShape | undefined {
|
|
516
|
-
if (Array.isArray(value)) return { kind: "array", length: value.length };
|
|
517
|
-
if (!isRecord(value)) return undefined;
|
|
518
|
-
|
|
519
|
-
const knownKeys: string[] = [];
|
|
520
|
-
const knownFieldTypes: Record<string, string> = {};
|
|
521
|
-
for (const key of SAFE_TOOL_CALL_SHAPE_KEYS) {
|
|
522
|
-
if (!Object.hasOwn(value, key)) continue;
|
|
523
|
-
knownKeys.push(key);
|
|
524
|
-
knownFieldTypes[key] = structuralValueType(value[key]);
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
let hasUnknownKeys = false;
|
|
528
|
-
for (const key of Object.keys(value)) {
|
|
529
|
-
if (!SAFE_TOOL_CALL_SHAPE_KEY_SET.has(key)) {
|
|
530
|
-
hasUnknownKeys = true;
|
|
531
|
-
break;
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
return { kind: "object", knownKeys, knownFieldTypes, hasUnknownKeys };
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
/**
|
|
538
|
-
* Streamed string fields are absent when null or undefined (#1731): OpenAI-compatible
|
|
539
|
-
* streamers repeat already-sent `id`/`name`/`arguments` as null on continuation deltas.
|
|
540
|
-
* The accumulator and this diagnostic share this predicate so they cannot disagree about
|
|
541
|
-
* which delta was the invalid one.
|
|
542
|
-
*/
|
|
543
|
-
function isInvalidStreamStringField(value: unknown): boolean {
|
|
544
|
-
return value != null && typeof value !== "string";
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
/**
|
|
548
|
-
* Explain only the rejected wire shape, never its values. This diagnostic exists so provider
|
|
549
|
-
* compatibility can be tightened from evidence without retaining tool arguments or credentials.
|
|
550
|
-
*/
|
|
551
|
-
function diagnoseInvalidToolCalls(
|
|
552
|
-
rawToolCalls: unknown,
|
|
553
|
-
mode: "stream" | "response",
|
|
554
|
-
): InvalidToolCallDiagnostic | undefined {
|
|
555
|
-
if (!Array.isArray(rawToolCalls)) {
|
|
556
|
-
return { reason: "tool_calls_not_array", valueType: rawToolCalls === null ? "null" : typeof rawToolCalls };
|
|
557
|
-
}
|
|
558
|
-
for (let callIndex = 0; callIndex < rawToolCalls.length; callIndex++) {
|
|
559
|
-
const rawToolCall = rawToolCalls[callIndex];
|
|
560
|
-
if (!isRecord(rawToolCall)) {
|
|
561
|
-
return {
|
|
562
|
-
reason: "tool_call_not_object",
|
|
563
|
-
callIndex,
|
|
564
|
-
valueType: rawToolCall === null ? "null" : Array.isArray(rawToolCall) ? "array" : typeof rawToolCall,
|
|
565
|
-
};
|
|
566
|
-
}
|
|
567
|
-
if (mode === "stream") {
|
|
568
|
-
// The streamed path validates the pieces it is about to store (#1531): a present
|
|
569
|
-
// `function` must be a record, and a present `name`/`arguments`/`id` must be a string.
|
|
570
|
-
// Blank names are caught later at flush, not here, so they are not diagnosed on this
|
|
571
|
-
// branch. Describe exactly that boundary rather than tightening compatibility in a
|
|
572
|
-
// diagnostic change.
|
|
573
|
-
// #1731: "present" means the same thing here as in the accumulator — null and undefined
|
|
574
|
-
// are both absent, because some OpenAI-compatible streamers repeat already-sent fields
|
|
575
|
-
// as null on continuation deltas. A separate predicate here would diagnose accepted
|
|
576
|
-
// padding as the failure and point compatibility work at the wrong delta.
|
|
577
|
-
const streamFunction = (rawToolCall as { function?: unknown }).function;
|
|
578
|
-
if (streamFunction !== undefined && streamFunction !== null) {
|
|
579
|
-
if (!isRecord(streamFunction)) {
|
|
580
|
-
return {
|
|
581
|
-
reason: "tool_call_function_not_object",
|
|
582
|
-
callIndex,
|
|
583
|
-
valueType: Array.isArray(streamFunction) ? "array" : typeof streamFunction,
|
|
584
|
-
};
|
|
585
|
-
}
|
|
586
|
-
if (isInvalidStreamStringField(streamFunction.name)) {
|
|
587
|
-
return { reason: "tool_call_function_name_invalid", callIndex, valueType: typeof streamFunction.name };
|
|
588
|
-
}
|
|
589
|
-
if (isInvalidStreamStringField(streamFunction.arguments)) {
|
|
590
|
-
return { reason: "tool_call_function_arguments_invalid", callIndex, valueType: typeof streamFunction.arguments };
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
if (isInvalidStreamStringField(rawToolCall.id)) {
|
|
594
|
-
return { reason: "tool_call_id_invalid", callIndex, valueType: typeof rawToolCall.id };
|
|
595
|
-
}
|
|
596
|
-
continue;
|
|
597
|
-
}
|
|
598
|
-
// Precedence must mirror the buffered validator below, or a payload with more than one
|
|
599
|
-
// problem is reported under the wrong reason and sends compatibility work after the wrong
|
|
600
|
-
// shape. That validator checks the `function` container first (`!isRecord(rawToolCall) ||
|
|
601
|
-
// !isRecord(rawToolCall.function)`), then id/name/arguments types together, and only then
|
|
602
|
-
// the blank name.
|
|
603
|
-
if (!isRecord(rawToolCall.function)) {
|
|
604
|
-
return {
|
|
605
|
-
reason: "tool_call_function_not_object",
|
|
606
|
-
callIndex,
|
|
607
|
-
valueType: rawToolCall.function === null ? "null" : Array.isArray(rawToolCall.function) ? "array" : typeof rawToolCall.function,
|
|
608
|
-
};
|
|
609
|
-
}
|
|
610
|
-
if (typeof rawToolCall.id !== "string") {
|
|
611
|
-
return { reason: "tool_call_id_invalid", callIndex, valueType: typeof rawToolCall.id };
|
|
612
|
-
}
|
|
613
|
-
if (typeof rawToolCall.function.name !== "string") {
|
|
614
|
-
return { reason: "tool_call_function_name_invalid", callIndex, valueType: typeof rawToolCall.function.name };
|
|
615
|
-
}
|
|
616
|
-
if (typeof rawToolCall.function.arguments !== "string") {
|
|
617
|
-
return { reason: "tool_call_function_arguments_invalid", callIndex, valueType: typeof rawToolCall.function.arguments };
|
|
618
|
-
}
|
|
619
|
-
// Last, matching the validator: #1531 also rejects a blank or whitespace-only name here,
|
|
620
|
-
// because such a call cannot select a dispatch target. Reporting it as `name_invalid`
|
|
621
|
-
// would claim a type problem for a correctly-typed value, so it gets its own code.
|
|
622
|
-
if (rawToolCall.function.name.trim().length === 0) {
|
|
623
|
-
return { reason: "tool_call_function_name_blank", callIndex, valueType: "string" };
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
return undefined;
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
function logInvalidToolCalls(
|
|
630
|
-
mode: "stream" | "response",
|
|
631
|
-
rawToolCalls: unknown,
|
|
632
|
-
diagnosticOverride?: InvalidToolCallDiagnostic,
|
|
633
|
-
): void {
|
|
634
|
-
if (!isDebugEnabled()) return;
|
|
635
|
-
const diagnostic = diagnosticOverride ?? diagnoseInvalidToolCalls(rawToolCalls, mode);
|
|
636
|
-
if (!diagnostic) return;
|
|
637
|
-
const fieldShape = fingerprintInvalidField(invalidToolCallField(rawToolCalls, diagnostic));
|
|
638
|
-
debugProviderDiagnostic("openai-chat", "invalid-tool-calls", {
|
|
639
|
-
mode,
|
|
640
|
-
...diagnostic,
|
|
641
|
-
...(fieldShape ? { fieldShape } : {}),
|
|
642
|
-
});
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
function developerSystemText(message: OcxMessage): string | undefined {
|
|
646
|
-
if (message.role !== "developer") return undefined;
|
|
647
|
-
if (typeof message.content === "string") return message.content;
|
|
648
|
-
if (message.content.some(part => part.type === "image")) return undefined;
|
|
649
|
-
return message.content.map(part => (part as OcxTextContent).text).join("");
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
function isNativeOpenAIChatTarget(provider: OcxProviderConfig): boolean {
|
|
653
|
-
try {
|
|
654
|
-
return new URL(provider.baseUrl).hostname === "api.openai.com";
|
|
655
|
-
} catch {
|
|
656
|
-
return false;
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
/**
|
|
661
|
-
* Chat-completions image_url parts for images carried inside a tool result (issue #888). role:"tool"
|
|
662
|
-
* content is text-only on every chat provider, so these ride in a follow-up user message instead of
|
|
663
|
-
* being flattened to the "[image]" marker the model can't actually see. Data URLs and remote https
|
|
664
|
-
* URLs are both valid in image_url.url, unlike Gemini inline_data which needs base64.
|
|
665
|
-
*/
|
|
666
|
-
function toolResultTextForWire(content: string | OcxContentPart[], annotateEmpty = false): string {
|
|
667
|
-
// An empty content array is a present-but-empty result; `contentPartsToText` would
|
|
668
|
-
// otherwise fall back to the "[image]" marker and hide the emptiness from the model.
|
|
669
|
-
if (annotateEmpty && Array.isArray(content) && content.length === 0) return EMPTY_TOOL_OUTPUT_ANNOTATION;
|
|
670
|
-
if (typeof content === "string") {
|
|
671
|
-
if (annotateEmpty && content.trim() === "") return EMPTY_TOOL_OUTPUT_ANNOTATION;
|
|
672
|
-
return content;
|
|
673
|
-
}
|
|
674
|
-
const text = content.filter((p) => p.type === "text").map((p) => (p as OcxTextContent).text).join("");
|
|
675
|
-
// A whitespace-only text-part array is the array twin of a blank string; the
|
|
676
|
-
// shared emptiness contract (same module as the Responses adapter) annotates it
|
|
677
|
-
// instead of forwarding whitespace the model silently accepts. Image parts and
|
|
678
|
-
// any other non-text part keep the array non-empty.
|
|
679
|
-
if (annotateEmpty && isWhitespaceOnlyTextPartArray(content)) {
|
|
680
|
-
return EMPTY_TOOL_OUTPUT_ANNOTATION;
|
|
681
|
-
}
|
|
682
|
-
if (text) {
|
|
683
|
-
const untransportableImages = content.filter((p) => p.type === "image" && !p.imageUrl).length;
|
|
684
|
-
return `${text}${"[image]".repeat(untransportableImages)}`;
|
|
685
|
-
}
|
|
686
|
-
return contentPartsToText(content);
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
function toolResultImageChatParts(content: string | OcxContentPart[]): unknown[] {
|
|
690
|
-
if (typeof content === "string") return [];
|
|
691
|
-
const parts: unknown[] = [];
|
|
692
|
-
for (const p of content) {
|
|
693
|
-
if (p.type !== "image" || !p.imageUrl) continue;
|
|
694
|
-
parts.push({ type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } });
|
|
695
|
-
}
|
|
696
|
-
return parts;
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] {
|
|
700
|
-
const out: unknown[] = [];
|
|
701
|
-
const { context, options } = parsed;
|
|
702
|
-
const replayCacheScope = parsed._reasoningReplayScope;
|
|
703
|
-
|
|
704
|
-
interface PendingToolCall { id: string; name: string }
|
|
705
|
-
let pendingToolCalls: PendingToolCall[] = [];
|
|
706
|
-
let deferredBarrierMessages: unknown[] = [];
|
|
707
|
-
let pendingToolResultImageParts: unknown[] = [];
|
|
708
|
-
let mintedIdSeq = 0;
|
|
709
|
-
const seenWireCallIds = new Set<string>();
|
|
710
|
-
|
|
711
|
-
const mintCallId = (): string => {
|
|
712
|
-
let id = "";
|
|
713
|
-
do {
|
|
714
|
-
id = `call_ocx_minted_${++mintedIdSeq}`;
|
|
715
|
-
} while (seenWireCallIds.has(id));
|
|
716
|
-
seenWireCallIds.add(id);
|
|
717
|
-
return id;
|
|
718
|
-
};
|
|
719
|
-
|
|
720
|
-
const releaseDeferredBarriers = (): void => {
|
|
721
|
-
if (deferredBarrierMessages.length === 0) return;
|
|
722
|
-
out.push(...deferredBarrierMessages);
|
|
723
|
-
deferredBarrierMessages = [];
|
|
724
|
-
};
|
|
725
|
-
|
|
726
|
-
const flushToolResultImages = (): void => {
|
|
727
|
-
if (pendingToolResultImageParts.length === 0) return;
|
|
728
|
-
out.push({
|
|
729
|
-
role: "user",
|
|
730
|
-
content: [
|
|
731
|
-
{ type: "text", text: "[ocx] image output from the preceding tool result(s):" },
|
|
732
|
-
...pendingToolResultImageParts,
|
|
733
|
-
],
|
|
734
|
-
});
|
|
735
|
-
pendingToolResultImageParts = [];
|
|
736
|
-
};
|
|
737
|
-
|
|
738
|
-
const flushPendingToolCalls = (): void => {
|
|
739
|
-
if (pendingToolCalls.length === 0) return;
|
|
740
|
-
for (const call of pendingToolCalls) {
|
|
741
|
-
out.push({
|
|
742
|
-
role: "tool",
|
|
743
|
-
tool_call_id: call.id,
|
|
744
|
-
content: `[ocx] no tool result was recorded for "${call.name}"; execution status unknown — do not treat this as success, failure, or user-provided input.`,
|
|
745
|
-
});
|
|
746
|
-
}
|
|
747
|
-
pendingToolCalls = [];
|
|
748
|
-
flushToolResultImages();
|
|
749
|
-
releaseDeferredBarriers();
|
|
750
|
-
};
|
|
751
|
-
|
|
752
|
-
const nativeOpenAI = isNativeOpenAIChatTarget(provider);
|
|
753
|
-
// Hoisting a newly appended reminder rewrites the reusable prompt prefix.
|
|
754
|
-
// Keep this compatibility exception on the destination/model tested with OCG.
|
|
755
|
-
const chronologicalSystem = parsed.modelId === "deepseek-v4.1-flash"
|
|
756
|
-
&& registryEntryForProviderDestination(provider)?.id === "opencode-go";
|
|
757
|
-
const toolCatalogNudge = shouldInjectNonOpenAIToolCatalogNudge(provider)
|
|
758
|
-
? buildNonOpenAIToolCatalogNudgeForTools(context.tools, options.toolChoice)
|
|
759
|
-
: undefined;
|
|
760
|
-
const developerSystemParts = nativeOpenAI || chronologicalSystem
|
|
761
|
-
? []
|
|
762
|
-
: context.messages
|
|
763
|
-
.map(developerSystemText)
|
|
764
|
-
.filter((part): part is string => part !== undefined && part.length > 0);
|
|
765
|
-
const systemParts = [
|
|
766
|
-
...(context.systemPrompt ?? []),
|
|
767
|
-
...developerSystemParts,
|
|
768
|
-
...(toolCatalogNudge ? [toolCatalogNudge] : []),
|
|
769
|
-
];
|
|
770
|
-
if (systemParts.length > 0) {
|
|
771
|
-
const wireModelId = provider.modelSuffixBracketStrip
|
|
772
|
-
? stripBracketedModelSuffix(parsed.modelId)
|
|
773
|
-
: parsed.modelId;
|
|
774
|
-
const sys = identifyRoutedModel(systemParts.join("\n\n"), wireModelId);
|
|
775
|
-
out.push({ role: "system", content: sys });
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
for (const msg of context.messages) {
|
|
779
|
-
switch (msg.role) {
|
|
780
|
-
case "user":
|
|
781
|
-
case "developer": {
|
|
782
|
-
const parts = typeof msg.content === "string" ? undefined : msg.content as OcxContentPart[];
|
|
783
|
-
const hasImages = parts?.some(p => p.type === "image") ?? false;
|
|
784
|
-
let chatMsg: Record<string, unknown>;
|
|
785
|
-
if (msg.role === "developer" && !hasImages) {
|
|
786
|
-
if (!nativeOpenAI && !chronologicalSystem) break;
|
|
787
|
-
const text = typeof msg.content === "string"
|
|
788
|
-
? msg.content
|
|
789
|
-
: parts!.map(p => (p as OcxTextContent).text).join("");
|
|
790
|
-
// A non-text timeline part (video, for example) serializes to nothing here.
|
|
791
|
-
// The generic path drops such a message; the chronological exception must not
|
|
792
|
-
// turn it into an empty system message that some upstreams reject.
|
|
793
|
-
if (!nativeOpenAI && text.length === 0) break;
|
|
794
|
-
chatMsg = { role: nativeOpenAI ? "developer" : "system", content: text };
|
|
795
|
-
} else if (typeof msg.content === "string") {
|
|
796
|
-
chatMsg = { role: "user", content: msg.content };
|
|
797
|
-
} else if (!hasImages) {
|
|
798
|
-
// A video part has no `text`, so joining it produced "" and the whole message
|
|
799
|
-
// was dropped: a video-only or text-plus-video turn vanished silently. OpenAI's
|
|
800
|
-
// Chat Completions wire has no video content part, so state the omission
|
|
801
|
-
// instead of losing it. Scoped to this adapter's wire, not a claim about video
|
|
802
|
-
// support in general — native Chat passthrough and Google inline video are
|
|
803
|
-
// unaffected.
|
|
804
|
-
chatMsg = {
|
|
805
|
-
role: "user",
|
|
806
|
-
content: parts!.map(p => (p.type === "video"
|
|
807
|
-
? VIDEO_UNSUPPORTED_MARKER
|
|
808
|
-
: (p as OcxTextContent).text)).join(""),
|
|
809
|
-
};
|
|
810
|
-
} else {
|
|
811
|
-
const chatParts = parts!.map(p => {
|
|
812
|
-
if (p.type === "image") {
|
|
813
|
-
return { type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } };
|
|
814
|
-
}
|
|
815
|
-
// Previously this produced { type: "text", text: undefined } for a video
|
|
816
|
-
// part — a malformed part, worse than a drop because it can fail upstream
|
|
817
|
-
// schema validation.
|
|
818
|
-
if (p.type === "video") return { type: "text", text: VIDEO_UNSUPPORTED_MARKER };
|
|
819
|
-
return { type: "text", text: (p as OcxTextContent).text };
|
|
820
|
-
});
|
|
821
|
-
chatMsg = { role: "user", content: chatParts };
|
|
822
|
-
}
|
|
823
|
-
if (pendingToolCalls.length > 0) deferredBarrierMessages.push(chatMsg);
|
|
824
|
-
else out.push(chatMsg);
|
|
825
|
-
break;
|
|
826
|
-
}
|
|
827
|
-
case "assistant": {
|
|
828
|
-
const aMsg = msg as OcxAssistantMessage;
|
|
829
|
-
const textParts = aMsg.content.filter(p => p.type === "text") as OcxTextContent[];
|
|
830
|
-
const thinkingParts = aMsg.content.filter(p => p.type === "thinking") as OcxThinkingContent[];
|
|
831
|
-
const toolCalls = aMsg.content.filter(p => p.type === "toolCall") as OcxToolCall[];
|
|
832
|
-
const chatMsg: Record<string, unknown> = { role: "assistant" };
|
|
833
|
-
if (textParts.length > 0) chatMsg.content = textParts.map(p => p.text).join("");
|
|
834
|
-
let reasoningContent = thinkingParts.map(p => p.thinking).join("");
|
|
835
|
-
if (
|
|
836
|
-
reasoningContent.length === 0
|
|
837
|
-
&& toolCalls.length > 0
|
|
838
|
-
&& modelInList(provider.preserveReasoningContentModels, parsed.modelId)
|
|
839
|
-
) {
|
|
840
|
-
const cached = toolCalls
|
|
841
|
-
.map(tc => (tc.id ? peekReasoningForCall(tc.id, replayCacheScope) : undefined))
|
|
842
|
-
.filter((text): text is string => typeof text === "string" && text.length > 0);
|
|
843
|
-
// Parallel calls share one preceding reasoning block, which is
|
|
844
|
-
// recorded under every call id — join unique texts only.
|
|
845
|
-
if (cached.length > 0) {
|
|
846
|
-
reasoningContent = [...new Set(cached)].join("\n");
|
|
847
|
-
} else if (modelInList(provider.requiresReasoningPlaceholderModels ?? provider.preserveReasoningContentModels, parsed.modelId)) {
|
|
848
|
-
// Fallback (extends #950, closes #1193): the replay cache is
|
|
849
|
-
// bounded (64 entries / 256 KiB / 1 h TTL) and always misses on
|
|
850
|
-
// long sessions, and some tool rounds carry no recorded reasoning
|
|
851
|
-
// at all. DeepSeek thinking mode rejects ANY tool_call assistant
|
|
852
|
-
// message missing reasoning_content with HTTP 400, so inject a
|
|
853
|
-
// minimal placeholder rather than emit a bare continuation the
|
|
854
|
-
// upstream will reject. Scoped to requiresReasoningPlaceholderModels
|
|
855
|
-
// (defaulting to the preserve list): preserve-listed providers with
|
|
856
|
-
// toggleable thinking (MiniMax low effort) opt out with `[]` so
|
|
857
|
-
// non-thinking histories are never given a fabricated placeholder.
|
|
858
|
-
reasoningContent = " ";
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
if (reasoningContent.length > 0 && modelInList(provider.preserveReasoningContentModels, parsed.modelId)) {
|
|
862
|
-
// MiniMax's interleaved-thinking contract requires the structured
|
|
863
|
-
// reasoning_details array back on the next turn; a reasoning_content
|
|
864
|
-
// string is the native-format pass-back the docs mark unsupported.
|
|
865
|
-
if (modelInList(provider.reasoningDetailsModels, parsed.modelId)) {
|
|
866
|
-
chatMsg.reasoning_details = [reasoningDetailSegmentForWire(reasoningContent)];
|
|
867
|
-
} else {
|
|
868
|
-
chatMsg.reasoning_content = reasoningContent;
|
|
869
|
-
}
|
|
870
|
-
}
|
|
871
|
-
const hasReplayedReasoning = chatMsg.reasoning_content !== undefined || chatMsg.reasoning_details !== undefined;
|
|
872
|
-
if (chatMsg.content === undefined && toolCalls.length === 0 && !hasReplayedReasoning) break;
|
|
873
|
-
flushPendingToolCalls();
|
|
874
|
-
const wireToolCalls = toolCalls.map(tc => {
|
|
875
|
-
let id = tc.id;
|
|
876
|
-
if (!id) id = mintCallId();
|
|
877
|
-
else seenWireCallIds.add(id);
|
|
878
|
-
return { tc, id };
|
|
879
|
-
});
|
|
880
|
-
if (wireToolCalls.length > 0) {
|
|
881
|
-
chatMsg.tool_calls = wireToolCalls.map(({ tc, id }) => ({
|
|
882
|
-
id,
|
|
883
|
-
type: "function",
|
|
884
|
-
function: { name: namespacedToolName(tc.namespace, tc.name), arguments: JSON.stringify(tc.arguments) },
|
|
885
|
-
}));
|
|
886
|
-
if (!chatMsg.content) chatMsg.content = emptyAssistantContent(provider);
|
|
887
|
-
}
|
|
888
|
-
if (hasReplayedReasoning && chatMsg.content === undefined && chatMsg.tool_calls === undefined) {
|
|
889
|
-
chatMsg.content = emptyAssistantContent(provider);
|
|
890
|
-
}
|
|
891
|
-
out.push(chatMsg);
|
|
892
|
-
pendingToolCalls = wireToolCalls.map(({ tc, id }) => ({ id, name: namespacedToolName(tc.namespace, tc.name) }));
|
|
893
|
-
break;
|
|
894
|
-
}
|
|
895
|
-
case "toolResult": {
|
|
896
|
-
let toolCallId = msg.toolCallId;
|
|
897
|
-
const matchIdx = toolCallId ? pendingToolCalls.findIndex(c => c.id === toolCallId) : -1;
|
|
898
|
-
if (matchIdx >= 0 && toolCallId) {
|
|
899
|
-
out.push({
|
|
900
|
-
role: "tool",
|
|
901
|
-
tool_call_id: toolCallId,
|
|
902
|
-
content: toolResultTextForWire(msg.content, provider.annotateEmptyToolOutputs === true),
|
|
903
|
-
});
|
|
904
|
-
pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content));
|
|
905
|
-
pendingToolCalls.splice(matchIdx, 1);
|
|
906
|
-
if (pendingToolCalls.length === 0) {
|
|
907
|
-
flushToolResultImages();
|
|
908
|
-
releaseDeferredBarriers();
|
|
909
|
-
}
|
|
910
|
-
} else {
|
|
911
|
-
if (!toolCallId) toolCallId = `call_orphan_${out.length}`;
|
|
912
|
-
flushPendingToolCalls();
|
|
913
|
-
const name = safeToolName(msg.toolName);
|
|
914
|
-
const cachedReasoning =
|
|
915
|
-
toolCallId && modelInList(provider.preserveReasoningContentModels, parsed.modelId)
|
|
916
|
-
? peekReasoningForCall(toolCallId, replayCacheScope)
|
|
917
|
-
: undefined;
|
|
918
|
-
// Same fallback as the main-assistant path: never emit a bare orphan
|
|
919
|
-
// tool_call continuation on a thinking-mode provider — inject a
|
|
920
|
-
// placeholder when the replay cache missed (the bounded cache can
|
|
921
|
-
// always miss on long sessions), or DeepSeek thinking mode 400s.
|
|
922
|
-
// Gate on the preserve list too: reasoning_content is only ever
|
|
923
|
-
// serialized for preserve-listed models, so a requires-only custom
|
|
924
|
-
// entry must not fabricate it on this path (P2 on #1205).
|
|
925
|
-
// `||` (not `??`): the cache never stores empty strings, but treat a
|
|
926
|
-
// falsy hit as a miss so the placeholder still fires.
|
|
927
|
-
const orphanReasoning =
|
|
928
|
-
cachedReasoning
|
|
929
|
-
|| (modelInList(provider.preserveReasoningContentModels, parsed.modelId)
|
|
930
|
-
&& modelInList(provider.requiresReasoningPlaceholderModels ?? provider.preserveReasoningContentModels, parsed.modelId)
|
|
931
|
-
? " "
|
|
932
|
-
: undefined);
|
|
933
|
-
const orphanReasoningFields: Record<string, unknown> = !orphanReasoning
|
|
934
|
-
? {}
|
|
935
|
-
: modelInList(provider.reasoningDetailsModels, parsed.modelId)
|
|
936
|
-
? { reasoning_details: [reasoningDetailSegmentForWire(orphanReasoning)] }
|
|
937
|
-
: { reasoning_content: orphanReasoning };
|
|
938
|
-
out.push({
|
|
939
|
-
role: "assistant",
|
|
940
|
-
content: emptyAssistantContent(provider),
|
|
941
|
-
...orphanReasoningFields,
|
|
942
|
-
tool_calls: [{
|
|
943
|
-
id: toolCallId,
|
|
944
|
-
type: "function",
|
|
945
|
-
function: { name, arguments: "{}" },
|
|
946
|
-
}],
|
|
947
|
-
});
|
|
948
|
-
seenWireCallIds.add(toolCallId);
|
|
949
|
-
out.push({
|
|
950
|
-
role: "tool",
|
|
951
|
-
tool_call_id: toolCallId,
|
|
952
|
-
content: toolResultTextForWire(msg.content, provider.annotateEmptyToolOutputs === true),
|
|
953
|
-
});
|
|
954
|
-
pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content));
|
|
955
|
-
flushToolResultImages();
|
|
956
|
-
}
|
|
957
|
-
break;
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
flushPendingToolCalls();
|
|
963
|
-
releaseDeferredBarriers();
|
|
964
|
-
return out;
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
function safeToolName(name: string | undefined): string {
|
|
968
|
-
const raw = name && name.trim().length > 0 ? name : "tool_result";
|
|
969
|
-
const sanitized = raw.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
970
|
-
return sanitized;
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
const ZEN_SCHEMA_MAP_KEYS = new Set(["properties", "$defs", "definitions"]);
|
|
974
|
-
const ZEN_DROPPED_SCHEMA_KEYS = new Set(["encrypted"]);
|
|
975
|
-
|
|
976
|
-
function sanitizeZenSchemaMap(value: unknown): unknown {
|
|
977
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return sanitizeZenToolParameters(value);
|
|
978
|
-
const out: Record<string, unknown> = {};
|
|
979
|
-
for (const [name, child] of Object.entries(value as Record<string, unknown>)) {
|
|
980
|
-
out[name] = sanitizeZenToolParameters(child);
|
|
981
|
-
}
|
|
982
|
-
return out;
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
function sanitizeZenToolParameters(value: unknown): unknown {
|
|
986
|
-
if (Array.isArray(value)) return value.map(sanitizeZenToolParameters);
|
|
987
|
-
if (!value || typeof value !== "object") return value;
|
|
988
|
-
const input = value as Record<string, unknown>;
|
|
989
|
-
const out: Record<string, unknown> = {};
|
|
990
|
-
for (const [key, child] of Object.entries(input)) {
|
|
991
|
-
if (ZEN_DROPPED_SCHEMA_KEYS.has(key)) continue;
|
|
992
|
-
if (key === "required" && Array.isArray(child) && child.length === 0) continue;
|
|
993
|
-
if (key === "type" && Array.isArray(child)) {
|
|
994
|
-
const nonNull = child.filter(entry => entry !== "null");
|
|
995
|
-
if (child.includes("null")) out.nullable = true;
|
|
996
|
-
if (nonNull.length > 0) out.type = nonNull[0];
|
|
997
|
-
continue;
|
|
998
|
-
}
|
|
999
|
-
out[key] = ZEN_SCHEMA_MAP_KEYS.has(key) ? sanitizeZenSchemaMap(child) : sanitizeZenToolParameters(child);
|
|
1000
|
-
}
|
|
1001
|
-
return out;
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
function ensureZenRootObjectSchema(schema: unknown): Record<string, unknown> {
|
|
1005
|
-
const obj = schema && typeof schema === "object" && !Array.isArray(schema)
|
|
1006
|
-
? schema as Record<string, unknown>
|
|
1007
|
-
: {};
|
|
1008
|
-
const compositionKeys = ["oneOf", "anyOf", "allOf"] as const;
|
|
1009
|
-
const hasComposition = compositionKeys.some(key => Array.isArray(obj[key]));
|
|
1010
|
-
const rootType = obj.type;
|
|
1011
|
-
const rootObjectType = rootType === "object" || (Array.isArray(rootType) && rootType.includes("object"));
|
|
1012
|
-
if (!hasComposition) {
|
|
1013
|
-
const base = sanitizeZenToolParameters(obj) as Record<string, unknown>;
|
|
1014
|
-
return rootObjectType && base.type === "object" ? base : { ...base, type: "object" };
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
const props: Record<string, unknown> = {};
|
|
1018
|
-
const required = new Set<string>();
|
|
1019
|
-
if (obj.properties && typeof obj.properties === "object") {
|
|
1020
|
-
Object.assign(props, sanitizeZenSchemaMap(obj.properties) as Record<string, unknown>);
|
|
1021
|
-
}
|
|
1022
|
-
if (Array.isArray(obj.required)) {
|
|
1023
|
-
for (const entry of obj.required) if (typeof entry === "string") required.add(entry);
|
|
1024
|
-
}
|
|
1025
|
-
for (const key of compositionKeys) {
|
|
1026
|
-
const variants = obj[key];
|
|
1027
|
-
if (!Array.isArray(variants)) continue;
|
|
1028
|
-
const mergeRequired = key === "allOf";
|
|
1029
|
-
for (const variant of variants) {
|
|
1030
|
-
if (!variant || typeof variant !== "object" || Array.isArray(variant)) continue;
|
|
1031
|
-
const rec = variant as Record<string, unknown>;
|
|
1032
|
-
if (rec.properties && typeof rec.properties === "object") {
|
|
1033
|
-
Object.assign(props, sanitizeZenSchemaMap(rec.properties) as Record<string, unknown>);
|
|
1034
|
-
}
|
|
1035
|
-
if (mergeRequired && Array.isArray(rec.required)) {
|
|
1036
|
-
for (const entry of rec.required) if (typeof entry === "string") required.add(entry);
|
|
1037
|
-
}
|
|
1038
|
-
}
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
const merged = sanitizeZenToolParameters(obj) as Record<string, unknown>;
|
|
1042
|
-
delete merged.oneOf;
|
|
1043
|
-
delete merged.anyOf;
|
|
1044
|
-
delete merged.allOf;
|
|
1045
|
-
merged.type = "object";
|
|
1046
|
-
if (Object.keys(props).length > 0) merged.properties = props;
|
|
1047
|
-
if (required.size > 0) merged.required = [...required];
|
|
1048
|
-
return merged;
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
function shouldSanitizeZenToolParameters(provider: OcxProviderConfig): boolean {
|
|
1052
|
-
const baseUrl = provider.baseUrl.replace(/\/+$/, "");
|
|
1053
|
-
return baseUrl === "https://opencode.ai/zen/v1"
|
|
1054
|
-
|| baseUrl === "https://opencode.ai/zen/go/v1";
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
/** Azure Model Router (and Gemini-in-the-pool) 400s Codex MCP schemas whose root is a union. */
|
|
1058
|
-
const AZURE_CHAT_FORBIDDEN_ROOT_KEYS = ["oneOf", "anyOf", "allOf", "enum", "const", "not"] as const;
|
|
1059
|
-
|
|
1060
|
-
function isAzureOpenAiChatTarget(provider: OcxProviderConfig): boolean {
|
|
1061
|
-
try {
|
|
1062
|
-
const host = new URL(provider.baseUrl).hostname.toLowerCase();
|
|
1063
|
-
return host.endsWith(".openai.azure.com")
|
|
1064
|
-
|| host.endsWith(".cognitiveservices.azure.com")
|
|
1065
|
-
|| host.endsWith(".services.ai.azure.com")
|
|
1066
|
-
|| host.endsWith(".ai.azure.com");
|
|
1067
|
-
} catch {
|
|
1068
|
-
return false;
|
|
1069
|
-
}
|
|
1070
|
-
}
|
|
1071
|
-
|
|
1072
|
-
/**
|
|
1073
|
-
* Azure Foundry Model Router validates every function schema against the strictest model in
|
|
1074
|
-
* the pool (Gemini-shaped): root must be {type:"object"} with no oneOf/anyOf/allOf/enum/
|
|
1075
|
-
* const/not. Codex App MCP tools such as mcp__codex_app__automation_update ship a root
|
|
1076
|
-
* union, which 400s the whole turn. Flatten like Zen, then strip leftover forbidden keys.
|
|
1077
|
-
*/
|
|
1078
|
-
function sanitizeAzureChatToolParameters(parameters: unknown): Record<string, unknown> {
|
|
1079
|
-
const root = ensureZenRootObjectSchema(parameters);
|
|
1080
|
-
for (const key of AZURE_CHAT_FORBIDDEN_ROOT_KEYS) delete root[key];
|
|
1081
|
-
root.type = "object";
|
|
1082
|
-
if (!root.properties || typeof root.properties !== "object" || Array.isArray(root.properties)) {
|
|
1083
|
-
root.properties = {};
|
|
1084
|
-
}
|
|
1085
|
-
return root;
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
// Moonshot validates function schemas against a draft-07 reading of `$ref`, where the
|
|
1089
|
-
// keyword stands alone and siblings are ignored. It rejects the whole request rather
|
|
1090
|
-
// than ignoring them: "not a valid moonshot flavored json schema ... when using $ref,
|
|
1091
|
-
// type should be defined in the referenced schema instead of the parent schema".
|
|
1092
|
-
const MOONSHOT_SCHEMA_HOSTNAMES = new Set([
|
|
1093
|
-
"api.kimi.com",
|
|
1094
|
-
"api.moonshot.ai",
|
|
1095
|
-
"api.moonshot.cn",
|
|
1096
|
-
]);
|
|
1097
|
-
|
|
1098
|
-
function isMoonshotSchemaTarget(provider: OcxProviderConfig): boolean {
|
|
1099
|
-
try {
|
|
1100
|
-
return MOONSHOT_SCHEMA_HOSTNAMES.has(new URL(provider.baseUrl).hostname);
|
|
1101
|
-
} catch {
|
|
1102
|
-
return false;
|
|
1103
|
-
}
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
const VOLCENGINE_ARK_HOSTNAMES = new Set([
|
|
1107
|
-
"ark.cn-beijing.volces.com",
|
|
1108
|
-
"ark.ap-southeast.volces.com",
|
|
1109
|
-
]);
|
|
1110
|
-
|
|
1111
|
-
function isVolcengineArkPaygChatTarget(provider: OcxProviderConfig): boolean {
|
|
1112
|
-
try {
|
|
1113
|
-
const url = new URL(provider.baseUrl);
|
|
1114
|
-
const pathname = url.pathname.replace(/\/+$/, "") || "/";
|
|
1115
|
-
return VOLCENGINE_ARK_HOSTNAMES.has(url.hostname) && pathname === "/api/v3";
|
|
1116
|
-
} catch {
|
|
1117
|
-
return false;
|
|
1118
|
-
}
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
function emptyAssistantContent(provider: OcxProviderConfig): string | { type: "text"; text: string }[] {
|
|
1122
|
-
return isVolcengineArkPaygChatTarget(provider) ? [{ type: "text", text: "" }] : "";
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
function ensureRootObjectType(parameters: unknown): Record<string, unknown> {
|
|
1126
|
-
if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) {
|
|
1127
|
-
return { type: "object", properties: {} };
|
|
1128
|
-
}
|
|
1129
|
-
const obj = parameters as Record<string, unknown>;
|
|
1130
|
-
if (obj.type === "object") return obj;
|
|
1131
|
-
return { ...obj, type: "object" };
|
|
1132
|
-
}
|
|
1133
|
-
|
|
1134
|
-
function isXaiObjectSchema(value: unknown): value is Record<string, unknown> {
|
|
1135
|
-
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
/**
|
|
1139
|
-
* JSON Schema 2020-12 makes `$ref` an in-place applicator: siblings stay in force and are
|
|
1140
|
-
* combined with the referenced schema. Moonshot enforces the older draft-07 reading where
|
|
1141
|
-
* `$ref` must stand alone, and 400s the entire request when a node carries both. Codex's own
|
|
1142
|
-
* deferred tool catalog emits exactly that shape (zod-to-json-schema deduplicates into
|
|
1143
|
-
* `$defs.__schema*` nodes that keep `type`/`minLength`/`format` beside the `$ref`), so the
|
|
1144
|
-
* schema is not something a user can fix from configuration — see issue #2673.
|
|
1145
|
-
*
|
|
1146
|
-
* Inline the referenced schema underneath the node's own keywords, which is what 2020-12 says
|
|
1147
|
-
* the node means, then drop `$ref`. Constraints reach the model instead of being stripped.
|
|
1148
|
-
* The `$defs` bag is preserved: a bare `$ref` (no siblings) is already legal for Moonshot and
|
|
1149
|
-
* is left pointing at its definition rather than expanded, which keeps recursive schemas finite.
|
|
1150
|
-
*/
|
|
1151
|
-
function moonshotRefTargetKeys(node: Record<string, unknown>): string[] {
|
|
1152
|
-
return Object.keys(node).filter(key => key !== "$ref");
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
|
-
/**
|
|
1156
|
-
* Inlining duplicates the target, so a schema referencing one large definition from many
|
|
1157
|
-
* sibling-carrying nodes can multiply. Bound the total expansions and fall back to a bare
|
|
1158
|
-
* `$ref` once the budget is spent: still valid for Moonshot, just without the node's own
|
|
1159
|
-
* narrowing keywords. Mirrors the node budget in google-tool-schema.ts.
|
|
1160
|
-
*/
|
|
1161
|
-
const MOONSHOT_MAX_REF_EXPANSIONS = 512;
|
|
1162
|
-
|
|
1163
|
-
/**
|
|
1164
|
-
* Expansion count alone does not bound the walk: a deeply nested ref-free schema, or one
|
|
1165
|
-
* large definition repeated across many nodes, still recurses to exhaustion or amplifies the
|
|
1166
|
-
* emitted output. Depth and node budgets close both, and mirror google-tool-schema.ts.
|
|
1167
|
-
*/
|
|
1168
|
-
const MOONSHOT_MAX_SCHEMA_DEPTH = 64;
|
|
1169
|
-
const MOONSHOT_MAX_SCHEMA_NODES = 4_096;
|
|
1170
|
-
|
|
1171
|
-
/**
|
|
1172
|
-
* Assertion keywords whose meaning under a `$ref` is CONJUNCTION, not replacement. A node
|
|
1173
|
-
* carrying `required: ["b"]` beside a target requiring `["a"]` means both are required;
|
|
1174
|
-
* letting the sibling win emitted a schema that no longer described the tool.
|
|
1175
|
-
*/
|
|
1176
|
-
function unionRequired(target: unknown, sibling: unknown): unknown {
|
|
1177
|
-
if (!Array.isArray(target) || !Array.isArray(sibling)) return sibling;
|
|
1178
|
-
const seen = new Set<unknown>();
|
|
1179
|
-
const out: unknown[] = [];
|
|
1180
|
-
for (const name of [...target, ...sibling]) {
|
|
1181
|
-
if (seen.has(name)) continue;
|
|
1182
|
-
seen.add(name);
|
|
1183
|
-
out.push(name);
|
|
1184
|
-
}
|
|
1185
|
-
return out;
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
/**
|
|
1189
|
-
* Keywords whose values are DATA, not schemas.
|
|
1190
|
-
*
|
|
1191
|
-
* Recursing into them rewrote user data: an `enum` listing a literal object that happens
|
|
1192
|
-
* to carry a `"$ref"` string had that key stripped as if it were a schema reference, so a
|
|
1193
|
-
* value the tool declared as legal silently changed shape. These are copied through.
|
|
1194
|
-
*/
|
|
1195
|
-
const MOONSHOT_DATA_VALUED_KEYWORDS = new Set(["enum", "const", "default", "examples"]);
|
|
1196
|
-
|
|
1197
|
-
/**
|
|
1198
|
-
* Numeric assertions whose intersection is a bound, and which direction tightens.
|
|
1199
|
-
*
|
|
1200
|
-
* `$ref` under 2020-12 is an in-place applicator: the node and its target BOTH apply, so
|
|
1201
|
-
* the emitted schema must be their INTERSECTION. The previous code overwrote the target
|
|
1202
|
-
* with the node and called that "the narrower reading", which holds only when the node
|
|
1203
|
-
* happens to be narrower. A node declaring `minLength: 1` beside a target declaring
|
|
1204
|
-
* `minLength: 5` shipped `minLength: 1` - a contract weaker than either side asked for,
|
|
1205
|
-
* emitted silently, which is the same failure mode the `required` composition fixed for
|
|
1206
|
-
* set-valued keywords.
|
|
1207
|
-
*
|
|
1208
|
-
* "max" means the surviving value is the larger of the two (lower bounds), "min" the
|
|
1209
|
-
* smaller (upper bounds). A keyword absent from this table keeps the overwrite: for
|
|
1210
|
-
* `type`, `format`, `description` and friends there is no ordering to intersect along,
|
|
1211
|
-
* and the node is the more specific statement.
|
|
1212
|
-
*/
|
|
1213
|
-
const MOONSHOT_BOUND_KEYWORDS: Record<string, "max" | "min"> = {
|
|
1214
|
-
minLength: "max",
|
|
1215
|
-
minItems: "max",
|
|
1216
|
-
minProperties: "max",
|
|
1217
|
-
minimum: "max",
|
|
1218
|
-
exclusiveMinimum: "max",
|
|
1219
|
-
maxLength: "min",
|
|
1220
|
-
maxItems: "min",
|
|
1221
|
-
maxProperties: "min",
|
|
1222
|
-
maximum: "min",
|
|
1223
|
-
exclusiveMaximum: "min",
|
|
1224
|
-
};
|
|
1225
|
-
|
|
1226
|
-
/**
|
|
1227
|
-
* Intersect one numeric bound. Either side being absent or non-finite yields the other,
|
|
1228
|
-
* because an unstated bound constrains nothing - returning `undefined` there would drop
|
|
1229
|
-
* a constraint the remaining side genuinely made.
|
|
1230
|
-
*/
|
|
1231
|
-
function intersectBound(target: unknown, sibling: unknown, direction: "max" | "min"): unknown {
|
|
1232
|
-
const a = typeof target === "number" && Number.isFinite(target) ? target : null;
|
|
1233
|
-
const b = typeof sibling === "number" && Number.isFinite(sibling) ? sibling : null;
|
|
1234
|
-
if (a === null) return b === null ? sibling : sibling;
|
|
1235
|
-
if (b === null) return target;
|
|
1236
|
-
return direction === "max" ? Math.max(a, b) : Math.min(a, b);
|
|
1237
|
-
}
|
|
1238
|
-
|
|
1239
|
-
/**
|
|
1240
|
-
* Compose two `properties` maps. A property named in BOTH the referenced target and the
|
|
1241
|
-
* node is the same conjunction problem `required` had: letting the sibling win discards
|
|
1242
|
-
* the target's constraints for that member. Merge the two member schemas so neither side
|
|
1243
|
-
* loses its keywords. Shared member bounds are the same conjunction one level down,
|
|
1244
|
-
* and nested object members recurse through this helper instead of replacing the target.
|
|
1245
|
-
*/
|
|
1246
|
-
function composeProperties(
|
|
1247
|
-
target: Record<string, unknown>,
|
|
1248
|
-
sibling: Record<string, unknown>,
|
|
1249
|
-
): Record<string, unknown> {
|
|
1250
|
-
const combined: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
|
1251
|
-
for (const [name, sub] of Object.entries(target)) combined[name] = sub;
|
|
1252
|
-
for (const [name, sub] of Object.entries(sibling)) {
|
|
1253
|
-
const existing = combined[name];
|
|
1254
|
-
if (isXaiObjectSchema(existing) && isXaiObjectSchema(sub)) {
|
|
1255
|
-
const member: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
|
1256
|
-
for (const [k, v] of Object.entries(existing)) member[k] = v;
|
|
1257
|
-
for (const [k, v] of Object.entries(sub)) {
|
|
1258
|
-
if (k === "required") {
|
|
1259
|
-
member[k] = unionRequired(member[k], v);
|
|
1260
|
-
continue;
|
|
1261
|
-
}
|
|
1262
|
-
if (k === "properties" && isXaiObjectSchema(member[k]) && isXaiObjectSchema(v)) {
|
|
1263
|
-
member[k] = composeProperties(member[k] as Record<string, unknown>, v);
|
|
1264
|
-
continue;
|
|
1265
|
-
}
|
|
1266
|
-
const boundDirection = MOONSHOT_BOUND_KEYWORDS[k];
|
|
1267
|
-
if (boundDirection && k in member) {
|
|
1268
|
-
member[k] = intersectBound(member[k], v, boundDirection);
|
|
1269
|
-
continue;
|
|
1270
|
-
}
|
|
1271
|
-
member[k] = v;
|
|
1272
|
-
}
|
|
1273
|
-
combined[name] = member;
|
|
1274
|
-
continue;
|
|
1275
|
-
}
|
|
1276
|
-
combined[name] = sub;
|
|
1277
|
-
}
|
|
1278
|
-
return combined;
|
|
1279
|
-
}
|
|
1280
|
-
|
|
1281
|
-
interface MoonshotNormalizeState {
|
|
1282
|
-
activeRefs: Set<string>;
|
|
1283
|
-
remainingExpansions: number;
|
|
1284
|
-
remainingNodes: number;
|
|
1285
|
-
}
|
|
1286
|
-
|
|
1287
|
-
function normalizeMoonshotSchemaNode(
|
|
1288
|
-
node: unknown,
|
|
1289
|
-
root: Record<string, unknown>,
|
|
1290
|
-
state: MoonshotNormalizeState,
|
|
1291
|
-
depth = 0,
|
|
1292
|
-
): unknown {
|
|
1293
|
-
if (Array.isArray(node)) {
|
|
1294
|
-
if (depth >= MOONSHOT_MAX_SCHEMA_DEPTH) return [];
|
|
1295
|
-
return node.map(item => normalizeMoonshotSchemaNode(item, root, state, depth + 1));
|
|
1296
|
-
}
|
|
1297
|
-
if (!isXaiObjectSchema(node)) return node;
|
|
1298
|
-
|
|
1299
|
-
// Fail closed for this node rather than emitting a partially weakened schema: an empty
|
|
1300
|
-
// object is the one shape that asserts nothing it cannot back up.
|
|
1301
|
-
if (depth >= MOONSHOT_MAX_SCHEMA_DEPTH || state.remainingNodes <= 0) return {};
|
|
1302
|
-
state.remainingNodes -= 1;
|
|
1303
|
-
|
|
1304
|
-
const ref = node.$ref;
|
|
1305
|
-
const hasSiblings = moonshotRefTargetKeys(node).length > 0;
|
|
1306
|
-
|
|
1307
|
-
if (typeof ref === "string" && hasSiblings) {
|
|
1308
|
-
// A cycle cannot be inlined. Keeping the bare `$ref` is the lossy-but-valid fallback:
|
|
1309
|
-
// Moonshot accepts it, and the alternative (dropping the ref) would erase the recursion.
|
|
1310
|
-
if (state.activeRefs.has(ref) || state.remainingExpansions <= 0) return { $ref: ref };
|
|
1311
|
-
|
|
1312
|
-
const target = lookupLocalJsonPointer(root, ref);
|
|
1313
|
-
if (isXaiObjectSchema(target)) {
|
|
1314
|
-
state.remainingExpansions -= 1;
|
|
1315
|
-
state.activeRefs.add(ref);
|
|
1316
|
-
const resolvedTarget = normalizeMoonshotSchemaNode(target, root, state, depth + 1);
|
|
1317
|
-
state.activeRefs.delete(ref);
|
|
1318
|
-
const merged: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
|
1319
|
-
if (isXaiObjectSchema(resolvedTarget)) {
|
|
1320
|
-
for (const [key, value] of Object.entries(resolvedTarget)) merged[key] = value;
|
|
1321
|
-
}
|
|
1322
|
-
// "Alongside the target" is conjunction, not replacement. For most keywords the node
|
|
1323
|
-
// narrows the target and overwriting is the narrower reading, but `required` and
|
|
1324
|
-
// `properties` are set-valued: letting the sibling win DROPPED the target's own
|
|
1325
|
-
// members, so a tool requiring `a` beside a node requiring `b` shipped requiring only
|
|
1326
|
-
// `b`. Those two compose; everything else keeps the narrowing overwrite.
|
|
1327
|
-
for (const [key, value] of Object.entries(node)) {
|
|
1328
|
-
if (key === "$ref") continue;
|
|
1329
|
-
if (MOONSHOT_DATA_VALUED_KEYWORDS.has(key)) {
|
|
1330
|
-
merged[key] = value;
|
|
1331
|
-
continue;
|
|
1332
|
-
}
|
|
1333
|
-
const normalized = normalizeMoonshotSchemaNode(value, root, state, depth + 1);
|
|
1334
|
-
if (key === "required") {
|
|
1335
|
-
merged[key] = unionRequired(merged[key], normalized);
|
|
1336
|
-
continue;
|
|
1337
|
-
}
|
|
1338
|
-
if (key === "properties" && isXaiObjectSchema(merged[key]) && isXaiObjectSchema(normalized)) {
|
|
1339
|
-
merged[key] = composeProperties(merged[key] as Record<string, unknown>, normalized);
|
|
1340
|
-
continue;
|
|
1341
|
-
}
|
|
1342
|
-
// Numeric bounds intersect rather than overwrite: both the node and its target
|
|
1343
|
-
// apply, so the surviving bound is the stricter of the two in whichever direction
|
|
1344
|
-
// that keyword tightens.
|
|
1345
|
-
const boundDirection = MOONSHOT_BOUND_KEYWORDS[key];
|
|
1346
|
-
if (boundDirection && key in merged) {
|
|
1347
|
-
merged[key] = intersectBound(merged[key], normalized, boundDirection);
|
|
1348
|
-
continue;
|
|
1349
|
-
}
|
|
1350
|
-
merged[key] = normalized;
|
|
1351
|
-
}
|
|
1352
|
-
return merged;
|
|
1353
|
-
}
|
|
1354
|
-
|
|
1355
|
-
// Unresolvable pointer: a remote ref, a malformed path, or a non-object target. Dropping
|
|
1356
|
-
// the ref and keeping the siblings silently discards whatever the reference constrained,
|
|
1357
|
-
// which is the one outcome we cannot detect downstream. A bare `$ref` is lossy in the
|
|
1358
|
-
// other direction - it loses the node's own keywords - but it preserves the identity of
|
|
1359
|
-
// what was asked for, and Moonshot accepts it.
|
|
1360
|
-
return { $ref: ref };
|
|
1361
|
-
}
|
|
1362
|
-
|
|
1363
|
-
const out: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
|
1364
|
-
for (const [key, value] of Object.entries(node)) {
|
|
1365
|
-
out[key] = key === "$ref" || MOONSHOT_DATA_VALUED_KEYWORDS.has(key)
|
|
1366
|
-
? value
|
|
1367
|
-
: normalizeMoonshotSchemaNode(value, root, state, depth + 1);
|
|
1368
|
-
}
|
|
1369
|
-
return out;
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
function normalizeMoonshotToolParameters(parameters: unknown): Record<string, unknown> {
|
|
1373
|
-
const rooted = ensureRootObjectType(parameters);
|
|
1374
|
-
const normalized = normalizeMoonshotSchemaNode(rooted, rooted, {
|
|
1375
|
-
activeRefs: new Set<string>(),
|
|
1376
|
-
remainingExpansions: MOONSHOT_MAX_REF_EXPANSIONS,
|
|
1377
|
-
remainingNodes: MOONSHOT_MAX_SCHEMA_NODES,
|
|
1378
|
-
});
|
|
1379
|
-
return isXaiObjectSchema(normalized) ? normalized : rooted;
|
|
1380
|
-
}
|
|
1381
|
-
|
|
1382
|
-
function toolsToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] | undefined {
|
|
1383
|
-
if (!parsed.context.tools || parsed.context.tools.length === 0) return undefined;
|
|
1384
|
-
const tools = parsed.context.tools.filter(toolChoiceToolPredicate(parsed.options.toolChoice, parsed.context.tools));
|
|
1385
|
-
if (tools.length === 0) return undefined;
|
|
1386
|
-
const xaiTarget = isXaiSchemaTarget(provider);
|
|
1387
|
-
const moonshotTarget = !xaiTarget && isMoonshotSchemaTarget(provider);
|
|
1388
|
-
const formatted = tools.flatMap(t => {
|
|
1389
|
-
const normalized = xaiTarget
|
|
1390
|
-
? normalizeXaiToolParameters(t.parameters)
|
|
1391
|
-
: moonshotTarget
|
|
1392
|
-
? normalizeMoonshotToolParameters(t.parameters)
|
|
1393
|
-
: ensureRootObjectType(t.parameters);
|
|
1394
|
-
const parameters = stripUnicodePropertyPatterns(stripResponsesOnlyEncryptedMarker(normalized));
|
|
1395
|
-
|
|
1396
|
-
if (parameters === undefined) return [];
|
|
1397
|
-
return [{
|
|
1398
|
-
type: "function",
|
|
1399
|
-
function: {
|
|
1400
|
-
name: namespacedToolName(t.namespace, t.name),
|
|
1401
|
-
...(t.description ? { description: t.description } : {}),
|
|
1402
|
-
parameters,
|
|
1403
|
-
...(t.strict !== undefined ? { strict: t.strict } : {}),
|
|
1404
|
-
},
|
|
1405
|
-
}];
|
|
1406
|
-
});
|
|
1407
|
-
return formatted.length > 0 ? formatted : undefined;
|
|
1408
|
-
}
|
|
1409
|
-
|
|
1410
|
-
function toolsToChatFormatForProvider(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] | undefined {
|
|
1411
|
-
const base = toolsToChatFormat(parsed, provider);
|
|
1412
|
-
const azureChat = isAzureOpenAiChatTarget(provider);
|
|
1413
|
-
const zenChat = shouldSanitizeZenToolParameters(provider);
|
|
1414
|
-
if (!base || (!zenChat && !azureChat)) return base;
|
|
1415
|
-
return base.map(tool => {
|
|
1416
|
-
if (!tool || typeof tool !== "object") return tool;
|
|
1417
|
-
const functionDef = (tool as { function?: Record<string, unknown> }).function;
|
|
1418
|
-
if (!functionDef || typeof functionDef !== "object") return tool;
|
|
1419
|
-
const parameters = azureChat
|
|
1420
|
-
? sanitizeAzureChatToolParameters(functionDef.parameters ?? {})
|
|
1421
|
-
: ensureZenRootObjectSchema(functionDef.parameters ?? {});
|
|
1422
|
-
const nextFunction: Record<string, unknown> = { ...functionDef, parameters };
|
|
1423
|
-
// strict: true plus a flattened schema is rejected by Gemini-in-the-pool routers.
|
|
1424
|
-
if (azureChat) delete nextFunction.strict;
|
|
1425
|
-
return {
|
|
1426
|
-
...tool,
|
|
1427
|
-
function: nextFunction,
|
|
1428
|
-
};
|
|
1429
|
-
});
|
|
1430
|
-
}
|
|
1431
|
-
|
|
1432
|
-
function toolChoiceToChatFormat(
|
|
1433
|
-
tc: OcxParsedRequest["options"]["toolChoice"],
|
|
1434
|
-
tools: OcxParsedRequest["context"]["tools"],
|
|
1435
|
-
provider: OcxProviderConfig,
|
|
1436
|
-
): unknown {
|
|
1437
|
-
if (!tc) return undefined;
|
|
1438
|
-
if (isAllowedToolChoice(tc)) {
|
|
1439
|
-
if (tc.mode === "required" && tc.allowedTools.length === 1 && isNativeOpenAIChatTarget(provider)) {
|
|
1440
|
-
return { type: "function", function: { name: resolveToolChoiceWireName(tools, tc.allowedTools[0]) } };
|
|
1441
|
-
}
|
|
1442
|
-
return tc.mode === "required" ? "required" : "auto";
|
|
1443
|
-
}
|
|
1444
|
-
if (tc === "auto" || tc === "none" || tc === "required") return tc;
|
|
1445
|
-
if ("name" in tc) return { type: "function", function: { name: resolveToolChoiceWireName(tools, tc.name) } };
|
|
1446
|
-
return undefined;
|
|
1447
|
-
}
|
|
1448
|
-
|
|
1449
|
-
function usageFromOpenAIChat(usage: Record<string, unknown> | undefined): OcxUsage | undefined {
|
|
1450
|
-
if (!usage) return undefined;
|
|
1451
|
-
const promptDetails = usage.prompt_tokens_details as Record<string, number> | undefined;
|
|
1452
|
-
const completionDetails = usage.completion_tokens_details as Record<string, number> | undefined;
|
|
1453
|
-
return {
|
|
1454
|
-
inputTokens: typeof usage.prompt_tokens === "number" ? usage.prompt_tokens : 0,
|
|
1455
|
-
outputTokens: typeof usage.completion_tokens === "number" ? usage.completion_tokens : 0,
|
|
1456
|
-
...(promptDetails?.cached_tokens !== undefined ? { cachedInputTokens: promptDetails.cached_tokens } : {}),
|
|
1457
|
-
...(completionDetails?.reasoning_tokens !== undefined ? { reasoningOutputTokens: completionDetails.reasoning_tokens } : {}),
|
|
1458
|
-
};
|
|
1459
|
-
}
|
|
20
|
+
import {
|
|
21
|
+
isInvalidStreamStringField,
|
|
22
|
+
isRecord,
|
|
23
|
+
logInvalidToolCalls,
|
|
24
|
+
type InvalidToolCallDiagnostic,
|
|
25
|
+
} from "./openai-chat/tool-call-validation";
|
|
26
|
+
import {
|
|
27
|
+
invalidChoicesEvent,
|
|
28
|
+
invalidToolCallsEvent,
|
|
29
|
+
reasoningDetailSegmentsFrom,
|
|
30
|
+
reasoningTextFrom,
|
|
31
|
+
stopReasonFor,
|
|
32
|
+
unnamedToolCallEvent,
|
|
33
|
+
usageFromOpenAIChat,
|
|
34
|
+
} from "./openai-chat/response-events";
|
|
35
|
+
import {
|
|
36
|
+
formatOpenAIChatErrorBody,
|
|
37
|
+
OpenAIChatError,
|
|
38
|
+
unwrapChatCompletionPayload,
|
|
39
|
+
upstreamErrorEvent,
|
|
40
|
+
} from "./openai-chat/errors";
|
|
41
|
+
import { messagesToChatFormat } from "./openai-chat/messages";
|
|
42
|
+
import { isNativeOpenAIChatTarget, openAIChatTransport, stripBracketedModelSuffix } from "./openai-chat/wire";
|
|
43
|
+
import { toolChoiceToChatFormat, toolsToChatFormatForProvider } from "./openai-chat/tool-schema";
|
|
44
|
+
|
|
45
|
+
export { stripBracketedModelSuffix } from "./openai-chat/wire";
|
|
46
|
+
export { buildOpenAIChatPassthroughRequest } from "./openai-chat/passthrough";
|
|
47
|
+
export { formatOpenAIChatErrorBody } from "./openai-chat/errors";
|
|
1460
48
|
|
|
1461
49
|
function resolveMaxTokens(provider: OcxProviderConfig, parsed: OcxParsedRequest): number | undefined {
|
|
1462
50
|
return parsed.options.maxOutputTokens
|