@bitkyc08/opencodex 2.11.1 → 2.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/ocx.mjs +4 -0
- package/gui/dist/assets/index-BNVYzdn0.css +1 -0
- package/gui/dist/assets/index-Cw1_mxO8.js +70 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/command-code.ts +94 -5
- package/src/adapters/cursor/live-transport.ts +14 -1
- package/src/adapters/google-tool-schema.ts +80 -15
- package/src/adapters/google.ts +24 -7
- package/src/adapters/openai-chat.ts +233 -305
- package/src/adapters/openai-responses.ts +62 -0
- package/src/adapters/tool-catalog-nudge.ts +2 -11
- package/src/claude/outbound.ts +42 -14
- package/src/cli/claude.ts +61 -9
- package/src/cli/codex-shim-autorestore.ts +2 -0
- package/src/cli/codex-shim-readiness.ts +8 -1
- package/src/cli/config-command.ts +4 -1
- package/src/cli/doctor.ts +10 -0
- package/src/cli/help.ts +15 -0
- package/src/cli/index.ts +27 -11
- package/src/cli/lab.ts +367 -0
- package/src/cli/provider.ts +9 -1
- package/src/clients/config-export.ts +100 -3
- package/src/codex/app-server-processes.ts +11 -8
- package/src/codex/auth-api.ts +92 -28
- package/src/codex/auth-collision.ts +5 -3
- package/src/codex/catalog/metadata.ts +7 -3
- package/src/codex/catalog/provider-fetch.ts +191 -47
- package/src/codex/catalog.ts +2 -2
- package/src/codex/history-job.ts +83 -18
- package/src/codex/history-migration-guardian.ts +9 -16
- package/src/codex/history-provider.ts +178 -1
- package/src/codex/history-transition.ts +5 -5
- package/src/codex/history-worker.ts +22 -2
- package/src/codex/native-profile-store.ts +1 -1
- package/src/codex/plan.ts +15 -0
- package/src/codex/quota.ts +6 -6
- package/src/codex/routing.ts +3 -3
- package/src/codex/subagent-model-fallback.ts +246 -22
- package/src/codex/user-identity.ts +63 -12
- package/src/config.ts +265 -32
- package/src/images/loop.ts +3 -1
- package/src/images/plan.ts +23 -13
- package/src/integrations/omp-yaml-source.ts +225 -0
- package/src/integrations/ownership.ts +5 -5
- package/src/integrations/state.ts +45 -6
- package/src/integrations/writer.ts +38 -3
- package/src/lab/artifacts/sanitize.ts +586 -0
- package/src/lab/artifacts/secure-fs.ts +475 -0
- package/src/lab/artifacts/store.ts +310 -0
- package/src/lab/conformance/assertion.ts +325 -0
- package/src/lab/conformance/digest.ts +22 -0
- package/src/lab/conformance/executor.ts +741 -0
- package/src/lab/conformance/fixture-provider.ts +27 -0
- package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
- package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
- package/src/lab/conformance/harness-budget.ts +47 -0
- package/src/lab/conformance/index.ts +5 -0
- package/src/lab/conformance/jcs.ts +24 -0
- package/src/lab/conformance/json-pointer.ts +39 -0
- package/src/lab/conformance/manifest.ts +180 -0
- package/src/lab/conformance/mcp-stub.ts +179 -0
- package/src/lab/conformance/negative-controls.ts +160 -0
- package/src/lab/conformance/observation.ts +355 -0
- package/src/lab/conformance/runner.ts +57 -0
- package/src/lab/conformance/sse-normalize.ts +59 -0
- package/src/lab/conformance/suite-manifest.ts +78 -0
- package/src/lab/conformance/types.ts +191 -0
- package/src/lab/constants.ts +126 -0
- package/src/lab/digest.ts +64 -0
- package/src/lab/events/errors.ts +9 -0
- package/src/lab/events/limits.ts +117 -0
- package/src/lab/events/types.ts +228 -0
- package/src/lab/events/validate.ts +780 -0
- package/src/lab/index.ts +36 -0
- package/src/lab/ledger/artifact-refs.ts +127 -0
- package/src/lab/ledger/invalidation.ts +136 -0
- package/src/lab/ledger/purge.ts +241 -0
- package/src/lab/ledger/store.ts +263 -0
- package/src/lab/live/credential-lease.ts +53 -0
- package/src/lab/live/destination.ts +155 -0
- package/src/lab/live/executor.ts +311 -0
- package/src/lab/live/inert-tools.ts +56 -0
- package/src/lab/live/manifest.ts +85 -0
- package/src/lab/live/mcp-loopback.ts +57 -0
- package/src/lab/live/runner.ts +19 -0
- package/src/lab/live/sandbox.ts +61 -0
- package/src/lab/live/suite-manifest.ts +41 -0
- package/src/lab/live/transport.ts +116 -0
- package/src/lab/live/types.ts +197 -0
- package/src/lab/observe/from-conformance.ts +344 -0
- package/src/lab/observe/from-live.ts +103 -0
- package/src/lab/paths.ts +65 -0
- package/src/lab/projection/rebuild.ts +477 -0
- package/src/lab/projection/schema.ts +122 -0
- package/src/lab/projection/verdicts.ts +438 -0
- package/src/lab/projection/verification.ts +332 -0
- package/src/lab/query/catalog.ts +72 -0
- package/src/lab/query/connection.ts +107 -0
- package/src/lab/query/constants.ts +4 -0
- package/src/lab/query/cursor.ts +132 -0
- package/src/lab/query/dto-map.ts +277 -0
- package/src/lab/query/errors.ts +22 -0
- package/src/lab/query/index.ts +34 -0
- package/src/lab/query/queries.ts +444 -0
- package/src/lab/query/types.ts +266 -0
- package/src/lab/subject/behavior-fingerprint.ts +77 -0
- package/src/lab/subject/installation-salt.ts +81 -0
- package/src/lab/subject/route-subject.ts +73 -0
- package/src/lib/bounded-body.ts +104 -0
- package/src/lib/lab-live-execution-authority.ts +13 -0
- package/src/lib/lab-live-host.ts +30 -0
- package/src/lib/lab-live-pinned-sender.ts +45 -0
- package/src/lib/pinned-http.ts +36 -7
- package/src/oauth/index.ts +20 -1
- package/src/oauth/local-token-detect.ts +11 -2
- package/src/oauth/login-cli.ts +38 -4
- package/src/providers/alibaba-region-migration.ts +1 -1
- package/src/providers/codex-capacity.ts +7 -4
- package/src/providers/command-code-efforts.ts +5 -2
- package/src/providers/derive.ts +105 -0
- package/src/providers/label.ts +1 -1
- package/src/providers/openai-tiers.ts +59 -3
- package/src/providers/opencode-zen-rate-limit.ts +102 -0
- package/src/providers/quota.ts +67 -21
- package/src/providers/registry.ts +118 -5
- package/src/router.ts +15 -1
- package/src/routing/analytics.ts +8 -7
- package/src/routing/evaluator.ts +51 -5
- package/src/routing/profile.ts +10 -1
- package/src/routing/trace.ts +42 -6
- package/src/server/auth-cors.ts +15 -0
- package/src/server/background-lifecycle.ts +169 -0
- package/src/server/chat-completions.ts +18 -3
- package/src/server/claude-messages.ts +1 -0
- package/src/server/images.ts +78 -11
- package/src/server/index.ts +72 -18
- package/src/server/lifecycle.ts +6 -0
- package/src/server/live.ts +5 -1
- package/src/server/management/agent-settings-routes.ts +41 -24
- package/src/server/management/body.ts +6 -0
- package/src/server/management/config-routes.ts +51 -8
- package/src/server/management/lab-routes.ts +388 -0
- package/src/server/management/logs-usage-routes.ts +19 -1
- package/src/server/management/model-rows.ts +25 -11
- package/src/server/management/native-integration-routes.ts +3 -3
- package/src/server/management/provider-routes.ts +5 -0
- package/src/server/management/shared.ts +8 -1
- package/src/server/management/usage-summary-cache.ts +2 -0
- package/src/server/management/vision-sidecar-options.ts +125 -0
- package/src/server/management-api.ts +2 -0
- package/src/server/request-decompress.ts +106 -5
- package/src/server/responses/collaboration.ts +4 -1
- package/src/server/responses/core.ts +117 -31
- package/src/server/responses-json-events.ts +50 -12
- package/src/server/search.ts +23 -7
- package/src/server/ws-bridge.ts +3 -1
- package/src/service.ts +40 -20
- package/src/types.ts +74 -0
- package/src/update/job.ts +10 -2
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +9 -0
- package/src/usage/log.ts +9 -9
- package/src/usage/user-cost-overlay-reconciler.ts +313 -0
- package/src/usage/user-cost-overlays.ts +314 -0
- package/src/vision/describe.ts +2 -2
- package/src/vision/eligibility.ts +229 -0
- package/src/vision/index.ts +20 -2
- package/src/web-search/executor.ts +2 -2
- package/src/web-search/index.ts +4 -2
- package/src/web-search/loop.ts +3 -1
- package/src/web-search/parse.ts +72 -3
- package/gui/dist/assets/index-BA1dgo4q.css +0 -1
- package/gui/dist/assets/index-DDZpgzKk.js +0 -70
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { AdapterRequest, ProviderAdapter } from "./base";
|
|
2
2
|
import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxThinkingContent, OcxToolCall, OcxUsage } from "../types";
|
|
3
|
-
import { isAllowedToolChoice, modelInList, namespacedToolName, resolveToolChoiceWireName,
|
|
3
|
+
import { isAllowedToolChoice, modelInList, namespacedToolName, resolveToolChoiceWireName, toolChoiceToolPredicate } from "../types";
|
|
4
4
|
import { mapReasoningEffort, modelRecordValue } from "../reasoning-effort";
|
|
5
5
|
import { debugProviderDiagnostic } from "../lib/debug";
|
|
6
6
|
import { sseFieldValue } from "../lib/sse-decoder";
|
|
@@ -23,7 +23,14 @@ import {
|
|
|
23
23
|
// Z.AI needs this because its OpenAI path rejects glm-5.2[1m] with 400 code 1211;
|
|
24
24
|
// unflagged OpenAI-compatible providers and the Anthropic adapter keep ids verbatim.
|
|
25
25
|
export function stripBracketedModelSuffix(modelId: string): string {
|
|
26
|
-
|
|
26
|
+
const suffixEnd = modelId.trimEnd().length;
|
|
27
|
+
if (suffixEnd === 0 || modelId[suffixEnd - 1] !== "]") return modelId;
|
|
28
|
+
|
|
29
|
+
let suffixStart = -1;
|
|
30
|
+
for (let i = suffixEnd - 2; i >= 0 && modelId[i] !== "]"; i--) {
|
|
31
|
+
if (modelId[i] === "[") suffixStart = i;
|
|
32
|
+
}
|
|
33
|
+
return suffixStart === -1 ? modelId : modelId.slice(0, suffixStart);
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
// 260715 (issue #126): surface upstream error detail through the web-search sidecar loop.
|
|
@@ -48,14 +55,12 @@ function extractErrorDetail(parsed: unknown): string | undefined {
|
|
|
48
55
|
if (typeof parsed === "string") return parsed.trim() || undefined;
|
|
49
56
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
50
57
|
const obj = parsed as Record<string, unknown>;
|
|
51
|
-
// OpenAI shape: { error: { message } } or { error: "..." }
|
|
52
58
|
const err = obj.error;
|
|
53
59
|
if (typeof err === "string" && err.trim()) return err.trim();
|
|
54
60
|
if (err !== null && typeof err === "object" && !Array.isArray(err)) {
|
|
55
61
|
const msg = (err as Record<string, unknown>).message;
|
|
56
62
|
if (typeof msg === "string" && msg.trim()) return msg.trim();
|
|
57
63
|
}
|
|
58
|
-
// FastAPI/pydantic shape (NVIDIA NIM): { detail: "..." } or { detail: [{ msg, loc }, ...] }
|
|
59
64
|
const det = obj.detail;
|
|
60
65
|
if (typeof det === "string" && det.trim()) return det.trim();
|
|
61
66
|
if (Array.isArray(det)) {
|
|
@@ -66,15 +71,11 @@ function extractErrorDetail(parsed: unknown): string | undefined {
|
|
|
66
71
|
.filter(m => m.length > 0);
|
|
67
72
|
if (msgs.length > 0) return msgs.join("; ");
|
|
68
73
|
}
|
|
69
|
-
// Generic fallbacks: { message } / RFC7807 { title }
|
|
70
74
|
if (typeof obj.message === "string" && obj.message.trim()) return obj.message.trim();
|
|
71
75
|
if (typeof obj.title === "string" && obj.title.trim()) return obj.title.trim();
|
|
72
76
|
return undefined;
|
|
73
77
|
}
|
|
74
78
|
|
|
75
|
-
// ClinePass live responses observed 2026-08-02 wrap non-stream Chat Completions in
|
|
76
|
-
// `{ success, error, data }`; its public Chat Completions docs do not currently describe that
|
|
77
|
-
// envelope. Keep ordinary OpenAI-shaped responses on the direct path.
|
|
78
79
|
function unwrapChatCompletionPayload(json: Record<string, unknown>): Record<string, unknown> {
|
|
79
80
|
if ((json.error !== undefined && json.error !== null) || Array.isArray(json.choices)) return json;
|
|
80
81
|
const data = json.data;
|
|
@@ -169,6 +170,18 @@ function invalidChoicesEvent(usage?: OcxUsage): Extract<AdapterEvent, { type: "e
|
|
|
169
170
|
};
|
|
170
171
|
}
|
|
171
172
|
|
|
173
|
+
function invalidToolCallsEvent(usage?: OcxUsage): Extract<AdapterEvent, { type: "error" }> {
|
|
174
|
+
return {
|
|
175
|
+
type: "error",
|
|
176
|
+
message: "upstream response contained invalid tool calls",
|
|
177
|
+
...(usage !== undefined ? { usage } : {}),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
182
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
183
|
+
}
|
|
184
|
+
|
|
172
185
|
function developerSystemText(message: OcxMessage): string | undefined {
|
|
173
186
|
if (message.role !== "developer") return undefined;
|
|
174
187
|
if (typeof message.content === "string") return message.content;
|
|
@@ -176,18 +189,34 @@ function developerSystemText(message: OcxMessage): string | undefined {
|
|
|
176
189
|
return message.content.map(part => (part as OcxTextContent).text).join("");
|
|
177
190
|
}
|
|
178
191
|
|
|
192
|
+
function isNativeOpenAIChatTarget(provider: OcxProviderConfig): boolean {
|
|
193
|
+
try {
|
|
194
|
+
return new URL(provider.baseUrl).hostname === "api.openai.com";
|
|
195
|
+
} catch {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
179
200
|
/**
|
|
180
201
|
* Chat-completions image_url parts for images carried inside a tool result (issue #888). role:"tool"
|
|
181
202
|
* content is text-only on every chat provider, so these ride in a follow-up user message instead of
|
|
182
203
|
* being flattened to the "[image]" marker the model can't actually see. Data URLs and remote https
|
|
183
204
|
* URLs are both valid in image_url.url, unlike Gemini inline_data which needs base64.
|
|
184
205
|
*/
|
|
206
|
+
function toolResultTextForWire(content: string | OcxContentPart[]): string {
|
|
207
|
+
if (typeof content === "string") return content;
|
|
208
|
+
const text = content.filter((p) => p.type === "text").map((p) => (p as OcxTextContent).text).join("");
|
|
209
|
+
if (text) {
|
|
210
|
+
const untransportableImages = content.filter((p) => p.type === "image" && !p.imageUrl).length;
|
|
211
|
+
return `${text}${"[image]".repeat(untransportableImages)}`;
|
|
212
|
+
}
|
|
213
|
+
return contentPartsToText(content);
|
|
214
|
+
}
|
|
215
|
+
|
|
185
216
|
function toolResultImageChatParts(content: string | OcxContentPart[]): unknown[] {
|
|
186
217
|
if (typeof content === "string") return [];
|
|
187
218
|
const parts: unknown[] = [];
|
|
188
219
|
for (const p of content) {
|
|
189
|
-
// Skip parts without a usable URL (the tool-output parser accepts the empty file_id shape):
|
|
190
|
-
// a {"url":""} part would fail the whole request where the "[image]" marker degrades safely.
|
|
191
220
|
if (p.type !== "image" || !p.imageUrl) continue;
|
|
192
221
|
parts.push({ type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } });
|
|
193
222
|
}
|
|
@@ -197,17 +226,8 @@ function toolResultImageChatParts(content: string | OcxContentPart[]): unknown[]
|
|
|
197
226
|
function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] {
|
|
198
227
|
const out: unknown[] = [];
|
|
199
228
|
const { context, options } = parsed;
|
|
200
|
-
// Mirror the bridge's replay-cache scope (issue #950): provider call ids are
|
|
201
|
-
// not globally unique, so reasoning must not cross conversation boundaries.
|
|
202
229
|
const replayCacheScope = parsed._clientThreadId ?? "global";
|
|
203
230
|
|
|
204
|
-
// 260718 dangling tool_calls hardening (devlog/_plan/260718_dangling_toolcall_hardening):
|
|
205
|
-
// strict chat providers (Kimi/Moonshot) 400 when an assistant tool_call is not answered
|
|
206
|
-
// immediately by role:"tool" messages. Repair order: (1) reattach a real result to its
|
|
207
|
-
// original call (barrier messages are DEFERRED until the open tool round closes),
|
|
208
|
-
// (2) synthesize an explicit unavailable-result only when no real result exists,
|
|
209
|
-
// (3) manufacture an orphan assistant call only when no call occurrence matches at all.
|
|
210
|
-
// Occurrences are kept as an ordered list (never a Map) so duplicated ids survive.
|
|
211
231
|
interface PendingToolCall { id: string; name: string }
|
|
212
232
|
let pendingToolCalls: PendingToolCall[] = [];
|
|
213
233
|
let deferredBarrierMessages: unknown[] = [];
|
|
@@ -230,10 +250,6 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
|
|
|
230
250
|
deferredBarrierMessages = [];
|
|
231
251
|
};
|
|
232
252
|
|
|
233
|
-
// Tool-result images collected during the open round land in ONE user vision message once the
|
|
234
|
-
// round closes — never inside it, where strict providers (Kimi/Moonshot) 400 on interleaved
|
|
235
|
-
// user messages. Released before deferred barriers so the images stay adjacent to the results
|
|
236
|
-
// they came from (mirrors google.ts sibling inline_data parts and the Kiro carrier images).
|
|
237
253
|
const flushToolResultImages = (): void => {
|
|
238
254
|
if (pendingToolResultImageParts.length === 0) return;
|
|
239
255
|
out.push({
|
|
@@ -246,9 +262,6 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
|
|
|
246
262
|
pendingToolResultImageParts = [];
|
|
247
263
|
};
|
|
248
264
|
|
|
249
|
-
// Close an unresolved tool round with explicit unavailable-result messages. The wording
|
|
250
|
-
// must not claim interruption, success, failure, or user intent: execution status is
|
|
251
|
-
// UNKNOWN, and for user-input tools this must not read as an answer.
|
|
252
265
|
const flushPendingToolCalls = (): void => {
|
|
253
266
|
if (pendingToolCalls.length === 0) return;
|
|
254
267
|
for (const call of pendingToolCalls) {
|
|
@@ -263,28 +276,21 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
|
|
|
263
276
|
releaseDeferredBarriers();
|
|
264
277
|
};
|
|
265
278
|
|
|
279
|
+
const nativeOpenAI = isNativeOpenAIChatTarget(provider);
|
|
266
280
|
const toolCatalogNudge = shouldInjectNonOpenAIToolCatalogNudge(provider)
|
|
267
281
|
? buildNonOpenAIToolCatalogNudgeForTools(context.tools, options.toolChoice)
|
|
268
282
|
: undefined;
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
// vision messages at their original position below.
|
|
275
|
-
const developerSystemParts = context.messages
|
|
276
|
-
.map(developerSystemText)
|
|
277
|
-
.filter((part): part is string => part !== undefined && part.length > 0);
|
|
283
|
+
const developerSystemParts = nativeOpenAI
|
|
284
|
+
? []
|
|
285
|
+
: context.messages
|
|
286
|
+
.map(developerSystemText)
|
|
287
|
+
.filter((part): part is string => part !== undefined && part.length > 0);
|
|
278
288
|
const systemParts = [
|
|
279
289
|
...(context.systemPrompt ?? []),
|
|
280
290
|
...developerSystemParts,
|
|
281
291
|
...(toolCatalogNudge ? [toolCatalogNudge] : []),
|
|
282
292
|
];
|
|
283
293
|
if (systemParts.length > 0) {
|
|
284
|
-
// Codex sends its GPT-5 identity prompt for EVERY model (the per-model catalog
|
|
285
|
-
// base_instructions is ignored at request time). Neutralize that one identity line
|
|
286
|
-
// so routed, non-OpenAI models don't misreport themselves as GPT-5 / OpenAI — without
|
|
287
|
-
// leaking the proxy identity into the payload.
|
|
288
294
|
const wireModelId = provider.modelSuffixBracketStrip
|
|
289
295
|
? stripBracketedModelSuffix(parsed.modelId)
|
|
290
296
|
: parsed.modelId;
|
|
@@ -298,24 +304,23 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
|
|
|
298
304
|
case "developer": {
|
|
299
305
|
const parts = typeof msg.content === "string" ? undefined : msg.content as OcxContentPart[];
|
|
300
306
|
const hasImages = parts?.some(p => p.type === "image") ?? false;
|
|
301
|
-
if (msg.role === "developer" && !hasImages) break;
|
|
302
307
|
let chatMsg: Record<string, unknown>;
|
|
303
|
-
if (
|
|
308
|
+
if (msg.role === "developer" && !hasImages) {
|
|
309
|
+
if (!nativeOpenAI) break;
|
|
310
|
+
const text = typeof msg.content === "string"
|
|
311
|
+
? msg.content
|
|
312
|
+
: parts!.map(p => (p as OcxTextContent).text).join("");
|
|
313
|
+
chatMsg = { role: "developer", content: text };
|
|
314
|
+
} else if (typeof msg.content === "string") {
|
|
304
315
|
chatMsg = { role: "user", content: msg.content };
|
|
316
|
+
} else if (!hasImages) {
|
|
317
|
+
chatMsg = { role: "user", content: parts!.map(p => (p as OcxTextContent).text).join("") };
|
|
305
318
|
} else {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
// and the data URL goes straight into image_url.url (never the token-exploding text path).
|
|
311
|
-
const chatParts = parts!.map(p => p.type === "image"
|
|
312
|
-
? { type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } }
|
|
313
|
-
: { type: "text", text: (p as OcxTextContent).text });
|
|
314
|
-
chatMsg = { role: "user", content: chatParts };
|
|
315
|
-
}
|
|
319
|
+
const chatParts = parts!.map(p => p.type === "image"
|
|
320
|
+
? { type: "image_url", image_url: { url: p.imageUrl, ...(p.detail ? { detail: p.detail } : {}) } }
|
|
321
|
+
: { type: "text", text: (p as OcxTextContent).text });
|
|
322
|
+
chatMsg = { role: "user", content: chatParts };
|
|
316
323
|
}
|
|
317
|
-
// A barrier must not split an open tool round: defer it until the round closes
|
|
318
|
-
// (real result arrives) or the round is synthesized shut.
|
|
319
324
|
if (pendingToolCalls.length > 0) deferredBarrierMessages.push(chatMsg);
|
|
320
325
|
else out.push(chatMsg);
|
|
321
326
|
break;
|
|
@@ -326,15 +331,8 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
|
|
|
326
331
|
const thinkingParts = aMsg.content.filter(p => p.type === "thinking") as OcxThinkingContent[];
|
|
327
332
|
const toolCalls = aMsg.content.filter(p => p.type === "toolCall") as OcxToolCall[];
|
|
328
333
|
const chatMsg: Record<string, unknown> = { role: "assistant" };
|
|
329
|
-
if (textParts.length > 0)
|
|
330
|
-
chatMsg.content = textParts.map(p => p.text).join("");
|
|
331
|
-
}
|
|
334
|
+
if (textParts.length > 0) chatMsg.content = textParts.map(p => p.text).join("");
|
|
332
335
|
let reasoningContent = thinkingParts.map(p => p.thinking).join("");
|
|
333
|
-
// History transformations (compaction, lost assistant turn, resumed
|
|
334
|
-
// threads) can strip the reasoning item while the tool round survives.
|
|
335
|
-
// Re-attach the reasoning the bridge recorded for these call ids so
|
|
336
|
-
// preserveReasoningContentModels providers (DeepSeek thinking mode)
|
|
337
|
-
// never receive a bare tool-call continuation (issue #950).
|
|
338
336
|
if (
|
|
339
337
|
reasoningContent.length === 0
|
|
340
338
|
&& toolCalls.length > 0
|
|
@@ -347,16 +345,24 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
|
|
|
347
345
|
// recorded under every call id — join unique texts only.
|
|
348
346
|
if (cached.length > 0) {
|
|
349
347
|
reasoningContent = [...new Set(cached)].join("\n");
|
|
348
|
+
} else if (modelInList(provider.requiresReasoningPlaceholderModels ?? provider.preserveReasoningContentModels, parsed.modelId)) {
|
|
349
|
+
// Fallback (extends #950, closes #1193): the replay cache is
|
|
350
|
+
// bounded (64 entries / 256 KiB / 1 h TTL) and always misses on
|
|
351
|
+
// long sessions, and some tool rounds carry no recorded reasoning
|
|
352
|
+
// at all. DeepSeek thinking mode rejects ANY tool_call assistant
|
|
353
|
+
// message missing reasoning_content with HTTP 400, so inject a
|
|
354
|
+
// minimal placeholder rather than emit a bare continuation the
|
|
355
|
+
// upstream will reject. Scoped to requiresReasoningPlaceholderModels
|
|
356
|
+
// (defaulting to the preserve list): preserve-listed providers with
|
|
357
|
+
// toggleable thinking (MiniMax low effort) opt out with `[]` so
|
|
358
|
+
// non-thinking histories are never given a fabricated placeholder.
|
|
359
|
+
reasoningContent = " ";
|
|
350
360
|
}
|
|
351
361
|
}
|
|
352
362
|
if (reasoningContent.length > 0 && modelInList(provider.preserveReasoningContentModels, parsed.modelId)) {
|
|
353
363
|
chatMsg.reasoning_content = reasoningContent;
|
|
354
364
|
}
|
|
355
|
-
// Skip empty assistant messages: chat APIs like DeepSeek reject an assistant message
|
|
356
|
-
// with neither content, tool calls, nor a provider-supported reasoning_content field.
|
|
357
365
|
if (chatMsg.content === undefined && toolCalls.length === 0 && chatMsg.reasoning_content === undefined) break;
|
|
358
|
-
// A new assistant starts while a previous round is still open: close the previous
|
|
359
|
-
// round synthetically first so its tool_calls are never left dangling.
|
|
360
366
|
flushPendingToolCalls();
|
|
361
367
|
const wireToolCalls = toolCalls.map(tc => {
|
|
362
368
|
let id = tc.id;
|
|
@@ -370,8 +376,6 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
|
|
|
370
376
|
type: "function",
|
|
371
377
|
function: { name: namespacedToolName(tc.namespace, tc.name), arguments: JSON.stringify(tc.arguments) },
|
|
372
378
|
}));
|
|
373
|
-
// "" instead of null: strict validators (xAI: "Each message must have at least one
|
|
374
|
-
// content element", langchain#34140) reject content-less assistant history entries.
|
|
375
379
|
if (!chatMsg.content) chatMsg.content = emptyAssistantContent(provider);
|
|
376
380
|
}
|
|
377
381
|
if (chatMsg.reasoning_content !== undefined && chatMsg.content === undefined && chatMsg.tool_calls === undefined) {
|
|
@@ -385,12 +389,10 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
|
|
|
385
389
|
let toolCallId = msg.toolCallId;
|
|
386
390
|
const matchIdx = toolCallId ? pendingToolCalls.findIndex(c => c.id === toolCallId) : -1;
|
|
387
391
|
if (matchIdx >= 0 && toolCallId) {
|
|
388
|
-
// Real result reattached to its original call. Barriers were deferred, so the
|
|
389
|
-
// tool message lands immediately inside the open round.
|
|
390
392
|
out.push({
|
|
391
393
|
role: "tool",
|
|
392
394
|
tool_call_id: toolCallId,
|
|
393
|
-
content:
|
|
395
|
+
content: toolResultTextForWire(msg.content),
|
|
394
396
|
});
|
|
395
397
|
pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content));
|
|
396
398
|
pendingToolCalls.splice(matchIdx, 1);
|
|
@@ -400,23 +402,31 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
|
|
|
400
402
|
}
|
|
401
403
|
} else {
|
|
402
404
|
if (!toolCallId) toolCallId = `call_orphan_${out.length}`;
|
|
403
|
-
// No matching call in the open round. Close any unresolved round first so the
|
|
404
|
-
// synthesized orphan pair never splits it, then keep the historical repair:
|
|
405
|
-
// WS turns can arrive with only tool outputs; chat-completions providers reject a bare
|
|
406
|
-
// role:"tool" message unless an assistant tool_call with the same id immediately precedes it.
|
|
407
405
|
flushPendingToolCalls();
|
|
408
406
|
const name = safeToolName(msg.toolName);
|
|
409
|
-
// The orphan repair synthesizes an assistant tool call for a result
|
|
410
|
-
// whose assistant turn was lost; carry the recorded reasoning so the
|
|
411
|
-
// replayed round stays valid for thinking-mode providers (#950).
|
|
412
407
|
const cachedReasoning =
|
|
413
408
|
toolCallId && modelInList(provider.preserveReasoningContentModels, parsed.modelId)
|
|
414
409
|
? peekReasoningForCall(toolCallId, replayCacheScope)
|
|
415
410
|
: undefined;
|
|
411
|
+
// Same fallback as the main-assistant path: never emit a bare orphan
|
|
412
|
+
// tool_call continuation on a thinking-mode provider — inject a
|
|
413
|
+
// placeholder when the replay cache missed (the bounded cache can
|
|
414
|
+
// always miss on long sessions), or DeepSeek thinking mode 400s.
|
|
415
|
+
// Gate on the preserve list too: reasoning_content is only ever
|
|
416
|
+
// serialized for preserve-listed models, so a requires-only custom
|
|
417
|
+
// entry must not fabricate it on this path (P2 on #1205).
|
|
418
|
+
// `||` (not `??`): the cache never stores empty strings, but treat a
|
|
419
|
+
// falsy hit as a miss so the placeholder still fires.
|
|
420
|
+
const orphanReasoning =
|
|
421
|
+
cachedReasoning
|
|
422
|
+
|| (modelInList(provider.preserveReasoningContentModels, parsed.modelId)
|
|
423
|
+
&& modelInList(provider.requiresReasoningPlaceholderModels ?? provider.preserveReasoningContentModels, parsed.modelId)
|
|
424
|
+
? " "
|
|
425
|
+
: undefined);
|
|
416
426
|
out.push({
|
|
417
427
|
role: "assistant",
|
|
418
428
|
content: emptyAssistantContent(provider),
|
|
419
|
-
...(
|
|
429
|
+
...(orphanReasoning ? { reasoning_content: orphanReasoning } : {}),
|
|
420
430
|
tool_calls: [{
|
|
421
431
|
id: toolCallId,
|
|
422
432
|
type: "function",
|
|
@@ -427,7 +437,7 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
|
|
|
427
437
|
out.push({
|
|
428
438
|
role: "tool",
|
|
429
439
|
tool_call_id: toolCallId,
|
|
430
|
-
content:
|
|
440
|
+
content: toolResultTextForWire(msg.content),
|
|
431
441
|
});
|
|
432
442
|
pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content));
|
|
433
443
|
flushToolResultImages();
|
|
@@ -437,8 +447,6 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
|
|
|
437
447
|
}
|
|
438
448
|
}
|
|
439
449
|
|
|
440
|
-
// Trailing dangle: a turn interrupted after the assistant requested tools leaves the
|
|
441
|
-
// round open; close it synthetically (then release any deferred barriers in order).
|
|
442
450
|
flushPendingToolCalls();
|
|
443
451
|
releaseDeferredBarriers();
|
|
444
452
|
return out;
|
|
@@ -544,11 +552,6 @@ function isXaiSchemaTarget(provider: OcxProviderConfig): boolean {
|
|
|
544
552
|
}
|
|
545
553
|
}
|
|
546
554
|
|
|
547
|
-
// Volcengine Ark regional endpoints. Ark validates an assistant message's text field as a
|
|
548
|
-
// REQUIRED parameter and treats "" as absent, so a tool-call-only assistant in history 400s with
|
|
549
|
-
// `MissingParameter: input.content.text` (#796). Every other OpenAI-compatible provider accepts
|
|
550
|
-
// "", and xAI actively requires it ("Each message must have at least one content element"), so
|
|
551
|
-
// the two contracts are in direct conflict and this cannot be a global change.
|
|
552
555
|
const VOLCENGINE_ARK_HOSTNAMES = new Set([
|
|
553
556
|
"ark.cn-beijing.volces.com",
|
|
554
557
|
"ark.ap-southeast.volces.com",
|
|
@@ -562,35 +565,10 @@ function isVolcengineArkTarget(provider: OcxProviderConfig): boolean {
|
|
|
562
565
|
}
|
|
563
566
|
}
|
|
564
567
|
|
|
565
|
-
/**
|
|
566
|
-
* Placeholder content for an assistant history entry carrying only tool calls or reasoning.
|
|
567
|
-
*
|
|
568
|
-
* UNVERIFIED HYPOTHESIS for Ark. The reported error names `input.content.text`, a nested path,
|
|
569
|
-
* which suggests Ark wants the structured content form `[{type:"text",text:""}]` rather than a
|
|
570
|
-
* bare string — no string value, `""` or `" "`, exposes a `content.text` path at all. But Ark's
|
|
571
|
-
* published examples only show array content for MULTIMODAL USER input, never for an assistant
|
|
572
|
-
* history entry, so this shape is inferred from the error message and not confirmed by the docs
|
|
573
|
-
* or by a live request. The empty inner text at least adds no tokens either way.
|
|
574
|
-
*
|
|
575
|
-
* Confirm against a real Ark endpoint before relying on this; #796 records what is still missing.
|
|
576
|
-
*
|
|
577
|
-
* Every other provider keeps the bare `""`, which xAI's validator specifically requires ("Each
|
|
578
|
-
* message must have at least one content element"), so this cannot be applied globally.
|
|
579
|
-
*/
|
|
580
568
|
function emptyAssistantContent(provider: OcxProviderConfig): string | { type: "text"; text: string }[] {
|
|
581
569
|
return isVolcengineArkTarget(provider) ? [{ type: "text", text: "" }] : "";
|
|
582
570
|
}
|
|
583
571
|
|
|
584
|
-
/**
|
|
585
|
-
* Providers like Kimi and DeepSeek reject function parameter schemas whose root
|
|
586
|
-
* `type` is missing or `null` — JSON Schema requires `"object"` at the root of
|
|
587
|
-
* function parameters. Add `type: "object"` at the root while preserving
|
|
588
|
-
* `oneOf`, `$defs`, and every other schema key.
|
|
589
|
-
*
|
|
590
|
-
* This mirrors `normalizeFunctionToolSchema` in openai-responses.ts, which
|
|
591
|
-
* applies the same root-only normalization unconditionally on the responses
|
|
592
|
-
* path. Nested schema content is intentionally left untouched.
|
|
593
|
-
*/
|
|
594
572
|
function ensureRootObjectType(parameters: unknown): Record<string, unknown> {
|
|
595
573
|
if (!parameters || typeof parameters !== "object" || Array.isArray(parameters)) {
|
|
596
574
|
return { type: "object", properties: {} };
|
|
@@ -634,12 +612,7 @@ function normalizeXaiToolParameters(parameters: unknown): Record<string, unknown
|
|
|
634
612
|
|
|
635
613
|
function toolsToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] | undefined {
|
|
636
614
|
if (!parsed.context.tools || parsed.context.tools.length === 0) return undefined;
|
|
637
|
-
const
|
|
638
|
-
? new Set(parsed.options.toolChoice.allowedTools)
|
|
639
|
-
: undefined;
|
|
640
|
-
const tools = allowed
|
|
641
|
-
? parsed.context.tools.filter(t => toolAllowedByChoice(t, allowed))
|
|
642
|
-
: parsed.context.tools;
|
|
615
|
+
const tools = parsed.context.tools.filter(toolChoiceToolPredicate(parsed.options.toolChoice));
|
|
643
616
|
if (tools.length === 0) return undefined;
|
|
644
617
|
const xaiTarget = isXaiSchemaTarget(provider);
|
|
645
618
|
const formatted = tools.flatMap(t => {
|
|
@@ -649,13 +622,13 @@ function toolsToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig
|
|
|
649
622
|
|
|
650
623
|
if (parameters === undefined) return [];
|
|
651
624
|
return [{
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
625
|
+
type: "function",
|
|
626
|
+
function: {
|
|
627
|
+
name: namespacedToolName(t.namespace, t.name),
|
|
628
|
+
...(t.description ? { description: t.description } : {}),
|
|
629
|
+
parameters,
|
|
630
|
+
...(t.strict !== undefined ? { strict: t.strict } : {}),
|
|
631
|
+
},
|
|
659
632
|
}];
|
|
660
633
|
});
|
|
661
634
|
return formatted.length > 0 ? formatted : undefined;
|
|
@@ -678,9 +651,18 @@ function toolsToChatFormatForProvider(parsed: OcxParsedRequest, provider: OcxPro
|
|
|
678
651
|
});
|
|
679
652
|
}
|
|
680
653
|
|
|
681
|
-
function toolChoiceToChatFormat(
|
|
654
|
+
function toolChoiceToChatFormat(
|
|
655
|
+
tc: OcxParsedRequest["options"]["toolChoice"],
|
|
656
|
+
tools: OcxParsedRequest["context"]["tools"],
|
|
657
|
+
provider: OcxProviderConfig,
|
|
658
|
+
): unknown {
|
|
682
659
|
if (!tc) return undefined;
|
|
683
|
-
if (isAllowedToolChoice(tc))
|
|
660
|
+
if (isAllowedToolChoice(tc)) {
|
|
661
|
+
if (tc.mode === "required" && tc.allowedTools.length === 1 && isNativeOpenAIChatTarget(provider)) {
|
|
662
|
+
return { type: "function", function: { name: resolveToolChoiceWireName(tools, tc.allowedTools[0]) } };
|
|
663
|
+
}
|
|
664
|
+
return tc.mode === "required" ? "required" : "auto";
|
|
665
|
+
}
|
|
684
666
|
if (tc === "auto" || tc === "none" || tc === "required") return tc;
|
|
685
667
|
if ("name" in tc) return { type: "function", function: { name: resolveToolChoiceWireName(tools, tc.name) } };
|
|
686
668
|
return undefined;
|
|
@@ -732,7 +714,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
732
714
|
|
|
733
715
|
const messages = messagesToChatFormat(parsed, provider);
|
|
734
716
|
const tools = toolsToChatFormatForProvider(parsed, provider);
|
|
735
|
-
const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools);
|
|
717
|
+
const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools, provider);
|
|
736
718
|
|
|
737
719
|
const body: Record<string, unknown> = {
|
|
738
720
|
model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(parsed.modelId) : parsed.modelId,
|
|
@@ -759,24 +741,41 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
759
741
|
if (parsed.options.stopSequences !== undefined) body.stop = parsed.options.stopSequences;
|
|
760
742
|
const reasoningDisabled = modelInList(provider.noReasoningModels, parsed.modelId);
|
|
761
743
|
const reasoningEffort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning);
|
|
744
|
+
const nativeOpenAI = isNativeOpenAIChatTarget(provider);
|
|
762
745
|
let reasoningLog: AdapterRequest["reasoningLog"];
|
|
763
|
-
// ClinePass live requests observed 2026-08-02 require this gateway-specific object; the
|
|
764
|
-
// public API docs do not currently specify its request shape.
|
|
765
746
|
if (!reasoningDisabled && provider.reasoningWireFormat === "gateway-object" && parsed.options.reasoning === "none") {
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
effectiveEffort: "none",
|
|
769
|
-
wireField: "reasoning.enabled",
|
|
770
|
-
wireValue: false,
|
|
771
|
-
};
|
|
772
|
-
} else if (reasoningEffort !== undefined) {
|
|
773
|
-
if (provider.reasoningWireFormat === "gateway-object") {
|
|
774
|
-
body.reasoning = { enabled: true, effort: reasoningEffort };
|
|
747
|
+
if (nativeOpenAI) {
|
|
748
|
+
body.reasoning_effort = "none";
|
|
775
749
|
reasoningLog = {
|
|
776
|
-
effectiveEffort:
|
|
777
|
-
wireField: "
|
|
778
|
-
wireValue:
|
|
750
|
+
effectiveEffort: "none",
|
|
751
|
+
wireField: "reasoning_effort",
|
|
752
|
+
wireValue: "none",
|
|
779
753
|
};
|
|
754
|
+
} else {
|
|
755
|
+
body.reasoning = { enabled: false };
|
|
756
|
+
reasoningLog = {
|
|
757
|
+
effectiveEffort: "none",
|
|
758
|
+
wireField: "reasoning.enabled",
|
|
759
|
+
wireValue: false,
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
} else if (reasoningEffort !== undefined) {
|
|
763
|
+
if (provider.reasoningWireFormat === "gateway-object") {
|
|
764
|
+
if (nativeOpenAI) {
|
|
765
|
+
body.reasoning_effort = reasoningEffort;
|
|
766
|
+
reasoningLog = {
|
|
767
|
+
effectiveEffort: reasoningEffort,
|
|
768
|
+
wireField: "reasoning_effort",
|
|
769
|
+
wireValue: reasoningEffort,
|
|
770
|
+
};
|
|
771
|
+
} else {
|
|
772
|
+
body.reasoning = { enabled: true, effort: reasoningEffort };
|
|
773
|
+
reasoningLog = {
|
|
774
|
+
effectiveEffort: reasoningEffort,
|
|
775
|
+
wireField: "reasoning.effort",
|
|
776
|
+
wireValue: reasoningEffort,
|
|
777
|
+
};
|
|
778
|
+
}
|
|
780
779
|
} else if (modelInList(provider.thinkingBudgetModels, parsed.modelId)) {
|
|
781
780
|
const budget = thinkingBudgetForEffort(parsed, reasoningEffort, maxTokens);
|
|
782
781
|
if (budget !== undefined) {
|
|
@@ -788,9 +787,6 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
788
787
|
};
|
|
789
788
|
}
|
|
790
789
|
} else if (modelInList(provider.thinkingToggleModels, parsed.modelId)) {
|
|
791
|
-
// Vendor thinking-toggle wire: the mapped value is sent as `thinking: {type}` because
|
|
792
|
-
// these models ignore/reject reasoning_effort. Most use enabled/disabled; MiniMax-M3
|
|
793
|
-
// uses adaptive/disabled.
|
|
794
790
|
if (reasoningEffort === "enabled" || reasoningEffort === "disabled" || reasoningEffort === "adaptive") {
|
|
795
791
|
body.thinking = { type: reasoningEffort };
|
|
796
792
|
reasoningLog = {
|
|
@@ -814,17 +810,9 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
814
810
|
if (parsed.options.frequencyPenalty !== undefined && !modelInList(provider.noPenaltyModels, parsed.modelId)) {
|
|
815
811
|
body.frequency_penalty = parsed.options.frequencyPenalty;
|
|
816
812
|
}
|
|
817
|
-
// prompt_cache_key is an OpenAI-specific chat extension; strict backends (Groq,
|
|
818
|
-
// Cerebras, etc.) reject unknown fields. Only forward when the provider opts in.
|
|
819
813
|
if (provider.promptCacheKey && parsed.options.promptCacheKey !== undefined) {
|
|
820
814
|
body.prompt_cache_key = parsed.options.promptCacheKey;
|
|
821
815
|
}
|
|
822
|
-
// Responses `text.format` -> chat `response_format`. json_object maps 1:1; json_schema
|
|
823
|
-
// re-nests the flattened Responses fields under `json_schema` — the exact inverse of
|
|
824
|
-
// responseFormatToText in src/chat/inbound.ts. Forwarded unconditionally (like `stop`):
|
|
825
|
-
// response_format is a first-class Chat Completions field, it is only present when the
|
|
826
|
-
// caller explicitly asked for structured output, and a backend that rejects it should
|
|
827
|
-
// fail loud rather than silently return prose the caller will try to JSON.parse.
|
|
828
816
|
const textFormat = parsed.options.textFormat;
|
|
829
817
|
if (textFormat?.type === "json_object") {
|
|
830
818
|
body.response_format = { type: "json_object" };
|
|
@@ -841,30 +829,25 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
841
829
|
}
|
|
842
830
|
|
|
843
831
|
if (tools) {
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
832
|
+
if (provider.parallelToolCalls === false) {
|
|
833
|
+
// NIM documents the Boolean defaulting to false and kimi rejects true; pin the
|
|
834
|
+
// wire bit so Codex cannot opt in via request.options. Other opted-out providers
|
|
835
|
+
// omit the field so strict OpenAI-compatible hosts never see an unsupported knob.
|
|
836
|
+
if (provider.baseUrl === "https://integrate.api.nvidia.com/v1") {
|
|
837
|
+
body.parallel_tool_calls = false;
|
|
838
|
+
}
|
|
839
|
+
} else if (provider.parallelToolCalls === true) {
|
|
840
|
+
body.parallel_tool_calls = parsed.options.parallelToolCalls !== false;
|
|
841
|
+
}
|
|
854
842
|
}
|
|
843
|
+
if (parsed.stream) body.stream_options = { include_usage: true };
|
|
855
844
|
|
|
856
845
|
const url = `${provider.baseUrl}/chat/completions`;
|
|
857
846
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
858
|
-
// Precedence preserved from pre-#128 behavior: apiKey Authorization first, then
|
|
859
|
-
// provider.headers may override (user/registry-configured headers win). Registry
|
|
860
|
-
// staticHeaders (e.g. opencode-free x-opencode-client) flow in via derive.ts and
|
|
861
|
-
// never carry Authorization, so keyless providers are unaffected.
|
|
862
847
|
if (hasCredential) headers["Authorization"] = `Bearer ${provider.apiKey}`;
|
|
863
848
|
if (provider.headers) Object.assign(headers, provider.headers);
|
|
864
849
|
|
|
865
850
|
const bodyJson = JSON.stringify(body);
|
|
866
|
-
// Never log pathname/query — tenant-scoped hosts (e.g. Cloudflare
|
|
867
|
-
// /accounts/<account_id>/ai/v1) would otherwise leak account identifiers (#452).
|
|
868
851
|
if (isDebugEnabled()) {
|
|
869
852
|
let host = "upstream";
|
|
870
853
|
try { host = new URL(url).host; } catch { /* keep fallback */ }
|
|
@@ -899,14 +882,6 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
899
882
|
const budgetEncoder = new TextEncoder();
|
|
900
883
|
let buffer = "";
|
|
901
884
|
let bufferBytes = 0;
|
|
902
|
-
// Streamed tool calls are BUFFERED until a terminal signal, then flushed as atomic
|
|
903
|
-
// start/delta/end sequences. The bridge treats text/reasoning deltas as barriers that
|
|
904
|
-
// close an open tool-call item (bridge.ts closeCurrentToolCall on text_delta), so
|
|
905
|
-
// emitting calls incrementally would orphan later argument deltas whenever a provider
|
|
906
|
-
// interleaves content — and parallel tool calls (multiple ids, index-keyed continuation
|
|
907
|
-
// chunks, whole-chunk calls) cannot be represented live without overlapping sequences.
|
|
908
|
-
// Keyed by `index` (OpenAI wire standard), falling back to `id`, falling back to the
|
|
909
|
-
// last-seen call for providers that omit both on continuation chunks.
|
|
910
885
|
interface PendingToolCall { key: string; id: string; name: string; args: string; argsBytes: number }
|
|
911
886
|
const pendingToolCalls: PendingToolCall[] = [];
|
|
912
887
|
let toolCallSeq = 0;
|
|
@@ -917,8 +892,6 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
917
892
|
return calls;
|
|
918
893
|
};
|
|
919
894
|
const flushToolCalls = function* (): Generator<AdapterEvent> {
|
|
920
|
-
// Do not treat flushed tool calls as user-facing output for the finish-less EOF
|
|
921
|
-
// fallback — incomplete tool args must stay on the truncation path.
|
|
922
895
|
for (const call of closeToolCalls()) {
|
|
923
896
|
if (!call.id) call.id = `call_${++toolCallSeq}`;
|
|
924
897
|
yield { type: "tool_call_start", id: call.id, name: call.name };
|
|
@@ -934,25 +907,13 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
934
907
|
return "terminate";
|
|
935
908
|
};
|
|
936
909
|
let pendingUsage: OcxUsage | undefined;
|
|
937
|
-
// Track terminal signals so a socket EOF without any terminator can fail closed instead of
|
|
938
|
-
// being reported as a clean completion (silent truncation). A graceful close is either an
|
|
939
|
-
// explicit `[DONE]` sentinel OR a chunk carrying a non-null `finish_reason` (some
|
|
940
|
-
// OpenAI-compatible providers omit `[DONE]` but do send finish_reason).
|
|
941
910
|
let finishReason: string | undefined;
|
|
942
|
-
// Only answer text enables the finish-less EOF fallback. Reasoning-only streams can be
|
|
943
|
-
// suppressed by hideThinkingSummary and must not complete as empty successful turns.
|
|
944
911
|
let sawUserFacingOutput = false;
|
|
945
912
|
|
|
946
|
-
// Single per-line handler shared by the streaming loop and the EOF residual-frame flush, so
|
|
947
|
-
// a final frame is parsed identically wherever it lands (no duplicated, drift-prone parsing).
|
|
948
|
-
// Yields adapter events and returns "terminate" for a terminal frame ([DONE] / error) that
|
|
949
|
-
// must end the stream, or "continue" otherwise. Mutates the closure's terminal-signal state.
|
|
950
913
|
const handleDataLine = function* (line: string): Generator<AdapterEvent, "continue" | "terminate"> {
|
|
951
914
|
const rawPayload = sseFieldValue(line, "data");
|
|
952
915
|
if (rawPayload === null) return "continue";
|
|
953
916
|
const payload = rawPayload.trim();
|
|
954
|
-
// A bare `data:` line carries nothing (heartbeat-style keep-alive on some gateways);
|
|
955
|
-
// it is not a malformed frame, just nothing to parse.
|
|
956
917
|
if (payload.length === 0) return "continue";
|
|
957
918
|
if (payload === "[DONE]") {
|
|
958
919
|
yield* flushToolCalls();
|
|
@@ -968,42 +929,20 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
968
929
|
yield { type: "error", message: "malformed upstream SSE data frame" };
|
|
969
930
|
return "terminate";
|
|
970
931
|
}
|
|
971
|
-
|
|
972
|
-
// an object — `JSON.parse("null")` returns null without throwing, so the catch above never
|
|
973
|
-
// sees it and the `chunk.error` read below crashed the stream mid-flight.
|
|
974
|
-
//
|
|
975
|
-
// Skip rather than terminate: `data: null` is emitted as a benign padding frame BETWEEN
|
|
976
|
-
// content deltas by real OpenAI-compatible routes (issue #1219), so failing here would
|
|
977
|
-
// discard the finish_reason chunk and [DONE] still in flight and turn a healthy response
|
|
978
|
-
// into a failed turn. Skipping cannot mask a genuinely broken stream — a stream carrying
|
|
979
|
-
// only such frames still sets neither finishReason nor sawUserFacingOutput and so trips
|
|
980
|
-
// the EOF truncation guard below. An unparseable frame stays terminal.
|
|
981
|
-
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
982
|
-
return "continue";
|
|
983
|
-
}
|
|
932
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return "continue";
|
|
984
933
|
const chunk = parsed as Record<string, unknown>;
|
|
985
934
|
|
|
986
|
-
// A 200/OK chat-completions stream may carry an inline provider error envelope
|
|
987
|
-
// instead of a clean [DONE]. Surface it as a terminal error so the bridge emits a
|
|
988
|
-
// classified response.failed (bridge case "error") — never a truncated completion.
|
|
989
935
|
if (chunk.error !== undefined && chunk.error !== null) {
|
|
990
936
|
const event = upstreamErrorEvent(chunk.error, pendingUsage);
|
|
991
937
|
debugProviderDiagnostic("openai-chat", "stream-error", { message: event.message });
|
|
992
938
|
return yield* terminateWithError(event);
|
|
993
939
|
}
|
|
994
940
|
|
|
995
|
-
if (chunk.usage)
|
|
996
|
-
// Record usage but keep parsing: some providers send usage and the final content
|
|
997
|
-
// delta in the SAME chunk; a bail here would drop that content. The choices
|
|
998
|
-
// guard below no-ops a usage-only chunk.
|
|
999
|
-
pendingUsage = usageFromOpenAIChat(chunk.usage as Record<string, unknown>);
|
|
1000
|
-
}
|
|
941
|
+
if (chunk.usage) pendingUsage = usageFromOpenAIChat(chunk.usage as Record<string, unknown>);
|
|
1001
942
|
|
|
1002
943
|
const choices = chunk.choices;
|
|
1003
944
|
if (choices === undefined) return "continue";
|
|
1004
|
-
if (!Array.isArray(choices))
|
|
1005
|
-
return yield* terminateWithError(invalidChoicesEvent(pendingUsage));
|
|
1006
|
-
}
|
|
945
|
+
if (!Array.isArray(choices)) return yield* terminateWithError(invalidChoicesEvent(pendingUsage));
|
|
1007
946
|
if (choices.length === 0) return "continue";
|
|
1008
947
|
const rawChoice = choices[0];
|
|
1009
948
|
if (rawChoice === null || typeof rawChoice !== "object" || Array.isArray(rawChoice)) {
|
|
@@ -1019,34 +958,39 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
1019
958
|
debugProviderDiagnostic("openai-chat", "stream-error", { message: event.message });
|
|
1020
959
|
return yield* terminateWithError(event);
|
|
1021
960
|
}
|
|
1022
|
-
|
|
1023
|
-
// no delta) is a graceful close and must record finishReason even though we skip it below.
|
|
1024
|
-
if (typeof choice.finish_reason === "string" && choice.finish_reason) {
|
|
1025
|
-
finishReason = choice.finish_reason;
|
|
1026
|
-
}
|
|
961
|
+
if (typeof choice.finish_reason === "string" && choice.finish_reason) finishReason = choice.finish_reason;
|
|
1027
962
|
const delta = choice.delta;
|
|
1028
963
|
if (delta) {
|
|
1029
964
|
const reasoningText = reasoningTextFrom(delta);
|
|
1030
|
-
if (reasoningText !== undefined) {
|
|
1031
|
-
yield { type: "reasoning_raw_delta", text: reasoningText };
|
|
1032
|
-
}
|
|
965
|
+
if (reasoningText !== undefined) yield { type: "reasoning_raw_delta", text: reasoningText };
|
|
1033
966
|
if (typeof delta.content === "string" && delta.content.length > 0) {
|
|
1034
967
|
sawUserFacingOutput = true;
|
|
1035
968
|
yield { type: "text_delta", text: delta.content };
|
|
1036
969
|
}
|
|
1037
970
|
|
|
1038
|
-
const
|
|
1039
|
-
if (
|
|
1040
|
-
|
|
971
|
+
const rawToolCalls = delta.tool_calls;
|
|
972
|
+
if (rawToolCalls !== undefined) {
|
|
973
|
+
// A claimed tool-call payload is not benign padding. Dropping it can leave the
|
|
974
|
+
// matching result permanently orphaned, so malformed nested shapes fail closed
|
|
975
|
+
// through the adapter error channel instead of escaping as TypeError (#1325).
|
|
976
|
+
if (!Array.isArray(rawToolCalls)) {
|
|
977
|
+
return yield* terminateWithError(invalidToolCallsEvent(pendingUsage));
|
|
978
|
+
}
|
|
979
|
+
for (const rawToolCall of rawToolCalls) {
|
|
980
|
+
if (!isRecord(rawToolCall)) {
|
|
981
|
+
return yield* terminateWithError(invalidToolCallsEvent(pendingUsage));
|
|
982
|
+
}
|
|
983
|
+
const tc = rawToolCall as {
|
|
984
|
+
index?: number;
|
|
985
|
+
id?: string;
|
|
986
|
+
function?: { name?: string; arguments?: string };
|
|
987
|
+
};
|
|
1041
988
|
const key = typeof tc.index === "number"
|
|
1042
989
|
? `i:${tc.index}`
|
|
1043
990
|
: tc.id
|
|
1044
|
-
|
|
1045
|
-
|
|
991
|
+
? `id:${tc.id}`
|
|
992
|
+
: pendingToolCalls[pendingToolCalls.length - 1]?.key;
|
|
1046
993
|
let call = key !== undefined ? pendingToolCalls.find(c => c.key === key) : undefined;
|
|
1047
|
-
// Mixed keying rescue: a call opened under an index key must still absorb an
|
|
1048
|
-
// id-only continuation for the same provider id (and vice versa) instead of
|
|
1049
|
-
// splitting into two calls that share one call_id downstream.
|
|
1050
994
|
if (!call && tc.id) call = pendingToolCalls.find(c => c.id === tc.id);
|
|
1051
995
|
if (!call) {
|
|
1052
996
|
call = { key: key ?? `seq:${pendingToolCalls.length}`, id: "", name: "", args: "", argsBytes: 0 };
|
|
@@ -1074,11 +1018,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
1074
1018
|
}
|
|
1075
1019
|
}
|
|
1076
1020
|
|
|
1077
|
-
|
|
1078
|
-
// atomic sequences (covers "tool_calls" AND providers that close tool turns with "stop").
|
|
1079
|
-
if (typeof choice.finish_reason === "string" && choice.finish_reason) {
|
|
1080
|
-
yield* flushToolCalls();
|
|
1081
|
-
}
|
|
1021
|
+
if (typeof choice.finish_reason === "string" && choice.finish_reason) yield* flushToolCalls();
|
|
1082
1022
|
return "continue";
|
|
1083
1023
|
};
|
|
1084
1024
|
|
|
@@ -1117,21 +1057,9 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
1117
1057
|
}
|
|
1118
1058
|
}
|
|
1119
1059
|
|
|
1120
|
-
// Some providers send the terminal `data:` frame (carrying the final delta, finish_reason,
|
|
1121
|
-
// and/or usage) WITHOUT a trailing newline before closing the socket, so it never crosses
|
|
1122
|
-
// the split("\n") boundary and stays in `buffer`. Run it through the SAME handler so its
|
|
1123
|
-
// content/tool-calls are emitted and its terminal signal observed — otherwise a genuinely
|
|
1124
|
-
// complete stream loses its last frame and may be falsely failed below.
|
|
1125
1060
|
if (buffer.length > 0) {
|
|
1126
1061
|
if ((yield* handleDataLine(buffer)) === "terminate") return;
|
|
1127
1062
|
}
|
|
1128
|
-
// Reader EOF. Prefer failing closed before flushing pending tool calls so the bridge
|
|
1129
|
-
// never sees a fabricated tool_call_end on a truncated mid-assembly stream.
|
|
1130
|
-
//
|
|
1131
|
-
// Checked BEFORE flushToolCalls(), because that helper emits tool_call_end and there is no
|
|
1132
|
-
// taking it back: a half-assembled argument string would reach the client as a completed
|
|
1133
|
-
// call. Tool calls are buffered here (unlike the Anthropic adapter, which forwards
|
|
1134
|
-
// fragments live), so this adapter can still decide.
|
|
1135
1063
|
const sawFinish = finishReason !== undefined;
|
|
1136
1064
|
if (!sawFinish && pendingToolCalls.length > 0) {
|
|
1137
1065
|
debugProviderDiagnostic("openai-chat", "stream-truncated", {
|
|
@@ -1142,9 +1070,6 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
1142
1070
|
yield { type: "error", message: "upstream stream ended mid tool call without a terminal signal — possible truncation" };
|
|
1143
1071
|
return;
|
|
1144
1072
|
}
|
|
1145
|
-
// Finish-less EOF is only safe when answer text was emitted. Reasoning-only / usage-only
|
|
1146
|
-
// truncations must stay on the error path (hideThinkingSummary can suppress reasoning).
|
|
1147
|
-
// Trailing usage alone is not a terminal signal for this adapter (#735 / restore #773).
|
|
1148
1073
|
if (!sawFinish && !sawUserFacingOutput) {
|
|
1149
1074
|
debugProviderDiagnostic("openai-chat", "stream-truncated", {
|
|
1150
1075
|
finishReason: finishReason ?? null,
|
|
@@ -1154,7 +1079,6 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
1154
1079
|
return;
|
|
1155
1080
|
}
|
|
1156
1081
|
yield* flushToolCalls();
|
|
1157
|
-
// Graceful close that omitted [DONE] but delivered finish_reason and/or answer text.
|
|
1158
1082
|
const stopReason = stopReasonFor(finishReason);
|
|
1159
1083
|
yield { type: "done", usage: pendingUsage, ...(stopReason ? { stopReason } : {}) };
|
|
1160
1084
|
} catch (error) {
|
|
@@ -1183,60 +1107,64 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
1183
1107
|
const responseBytes = new TextEncoder().encode(JSON.stringify(json)).byteLength;
|
|
1184
1108
|
budget.chargeRetained(responseBytes, { kind: "retained_collectors" });
|
|
1185
1109
|
try {
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
}
|
|
1195
|
-
if (payload.error !== undefined && payload.error !== null) {
|
|
1196
|
-
return [upstreamErrorEvent(payload.error, usage)];
|
|
1197
|
-
}
|
|
1198
|
-
|
|
1199
|
-
const events: AdapterEvent[] = [];
|
|
1200
|
-
const choices = payload.choices as {
|
|
1201
|
-
message?: Record<string, unknown>;
|
|
1202
|
-
finish_reason?: unknown;
|
|
1203
|
-
error?: OpenAIChatError;
|
|
1204
|
-
}[] | undefined;
|
|
1205
|
-
if (!Array.isArray(choices) || choices.length === 0) {
|
|
1206
|
-
return [{ type: "error", message: "upstream response contained no choices", ...(usage ? { usage } : {}) }];
|
|
1207
|
-
}
|
|
1208
|
-
const rawChoice = choices[0];
|
|
1209
|
-
if (rawChoice === null || typeof rawChoice !== "object" || Array.isArray(rawChoice)) {
|
|
1210
|
-
return [invalidChoicesEvent(usage)];
|
|
1211
|
-
}
|
|
1212
|
-
const choice = rawChoice;
|
|
1213
|
-
if (choice.finish_reason === "error") return [upstreamErrorEvent(choice.error, usage)];
|
|
1214
|
-
if (!choice.message) return [{ type: "error", message: "upstream response contained no choices", ...(usage ? { usage } : {}) }];
|
|
1215
|
-
|
|
1216
|
-
const msg = choice.message;
|
|
1217
|
-
const reasoningText = reasoningTextFrom(msg);
|
|
1218
|
-
if (reasoningText !== undefined) {
|
|
1219
|
-
events.push({ type: "reasoning_raw_delta", text: reasoningText });
|
|
1220
|
-
}
|
|
1221
|
-
if (typeof msg.content === "string") {
|
|
1222
|
-
events.push({ type: "text_delta", text: msg.content });
|
|
1223
|
-
}
|
|
1224
|
-
const toolCalls = msg.tool_calls as { id: string; function: { name: string; arguments: string } }[] | undefined;
|
|
1225
|
-
if (toolCalls) {
|
|
1226
|
-
for (const tc of toolCalls) {
|
|
1227
|
-
events.push({ type: "tool_call_start", id: tc.id, name: tc.function.name });
|
|
1228
|
-
events.push({ type: "tool_call_delta", arguments: tc.function.arguments });
|
|
1229
|
-
events.push({ type: "tool_call_end" });
|
|
1110
|
+
const payload = unwrapChatCompletionPayload(json);
|
|
1111
|
+
const usage = usageFromOpenAIChat(payload.usage as Record<string, unknown> | undefined);
|
|
1112
|
+
if (json.success === false && payload.error === undefined) {
|
|
1113
|
+
return [{
|
|
1114
|
+
type: "error",
|
|
1115
|
+
message: "upstream reported failure without an error payload",
|
|
1116
|
+
...(usage ? { usage } : {}),
|
|
1117
|
+
}];
|
|
1230
1118
|
}
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1119
|
+
if (payload.error !== undefined && payload.error !== null) return [upstreamErrorEvent(payload.error, usage)];
|
|
1120
|
+
|
|
1121
|
+
const events: AdapterEvent[] = [];
|
|
1122
|
+
const choices = payload.choices as {
|
|
1123
|
+
message?: Record<string, unknown>;
|
|
1124
|
+
finish_reason?: unknown;
|
|
1125
|
+
error?: OpenAIChatError;
|
|
1126
|
+
}[] | undefined;
|
|
1127
|
+
if (!Array.isArray(choices) || choices.length === 0) {
|
|
1128
|
+
return [{ type: "error", message: "upstream response contained no choices", ...(usage ? { usage } : {}) }];
|
|
1129
|
+
}
|
|
1130
|
+
const rawChoice = choices[0];
|
|
1131
|
+
if (rawChoice === null || typeof rawChoice !== "object" || Array.isArray(rawChoice)) {
|
|
1132
|
+
return [invalidChoicesEvent(usage)];
|
|
1133
|
+
}
|
|
1134
|
+
const choice = rawChoice;
|
|
1135
|
+
if (choice.finish_reason === "error") return [upstreamErrorEvent(choice.error, usage)];
|
|
1136
|
+
if (!choice.message) return [{ type: "error", message: "upstream response contained no choices", ...(usage ? { usage } : {}) }];
|
|
1137
|
+
|
|
1138
|
+
const msg = choice.message;
|
|
1139
|
+
const reasoningText = reasoningTextFrom(msg);
|
|
1140
|
+
if (reasoningText !== undefined) events.push({ type: "reasoning_raw_delta", text: reasoningText });
|
|
1141
|
+
if (typeof msg.content === "string") events.push({ type: "text_delta", text: msg.content });
|
|
1142
|
+
const rawToolCalls = msg.tool_calls;
|
|
1143
|
+
if (rawToolCalls !== undefined) {
|
|
1144
|
+
if (!Array.isArray(rawToolCalls)) return [invalidToolCallsEvent(usage)];
|
|
1145
|
+
for (const rawToolCall of rawToolCalls) {
|
|
1146
|
+
if (!isRecord(rawToolCall) || !isRecord(rawToolCall.function)) {
|
|
1147
|
+
return [invalidToolCallsEvent(usage)];
|
|
1148
|
+
}
|
|
1149
|
+
const id = rawToolCall.id;
|
|
1150
|
+
const name = rawToolCall.function.name;
|
|
1151
|
+
const args = rawToolCall.function.arguments;
|
|
1152
|
+
if (typeof id !== "string" || typeof name !== "string" || typeof args !== "string") {
|
|
1153
|
+
return [invalidToolCallsEvent(usage)];
|
|
1154
|
+
}
|
|
1155
|
+
events.push({ type: "tool_call_start", id, name });
|
|
1156
|
+
events.push({ type: "tool_call_delta", arguments: args });
|
|
1157
|
+
events.push({ type: "tool_call_end" });
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
const stopReason = stopReasonFor(choice.finish_reason);
|
|
1161
|
+
events.push({
|
|
1162
|
+
type: "done",
|
|
1163
|
+
usage,
|
|
1164
|
+
...(stopReason ? { stopReason } : {}),
|
|
1165
|
+
});
|
|
1166
|
+
retainTranslatedEventBatch(events, budget);
|
|
1167
|
+
return events;
|
|
1240
1168
|
} finally {
|
|
1241
1169
|
budget.releaseRetained(responseBytes, { kind: "retained_collectors" });
|
|
1242
1170
|
}
|