@bitkyc08/opencodex 2.48.0 → 2.50.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.
Files changed (141) hide show
  1. package/AGENTS_INSTALL.md +9 -1
  2. package/README.md +11 -5
  3. package/SPONSORS.md +1 -1
  4. package/assets/sponsors/orcarouter.png +0 -0
  5. package/assets/sponsors/packycode.png +0 -0
  6. package/gui/dist/assets/index-BoBRSehJ.css +1 -0
  7. package/gui/dist/assets/index-C39tnjXO.js +115 -0
  8. package/gui/dist/index.html +2 -2
  9. package/gui/dist/provider-icons/packycode.svg +19 -0
  10. package/gui/dist/provider-icons/qoder.svg +5 -0
  11. package/package.json +5 -3
  12. package/src/adapters/anthropic.ts +31 -16
  13. package/src/adapters/codebuddy/adapter.ts +85 -0
  14. package/src/adapters/codebuddy/profiles.ts +52 -0
  15. package/src/adapters/coding-agent/profile.ts +100 -0
  16. package/src/adapters/coding-agent/protocol.ts +463 -0
  17. package/src/adapters/coding-agent/turn.ts +353 -0
  18. package/src/adapters/google.ts +15 -11
  19. package/src/adapters/mimo-free.ts +3 -0
  20. package/src/adapters/openai-chat.ts +2 -2
  21. package/src/adapters/openai-responses.ts +18 -11
  22. package/src/adapters/qoder/adapter.ts +70 -0
  23. package/src/adapters/qoder/live-models.ts +89 -0
  24. package/src/adapters/qoder/profiles.ts +36 -0
  25. package/src/adapters/registry.ts +12 -0
  26. package/src/adapters/responses-tool-schema.ts +113 -8
  27. package/src/claude/inbound.ts +17 -5
  28. package/src/cli/account-api.ts +18 -3
  29. package/src/cli/account-auth.ts +8 -1
  30. package/src/cli/account-extended.ts +2 -1
  31. package/src/cli/account.ts +1 -0
  32. package/src/cli/capabilities.ts +15 -1
  33. package/src/cli/dispatch.ts +2 -0
  34. package/src/cli/doctor.ts +40 -0
  35. package/src/cli/effort.ts +24 -8
  36. package/src/cli/help.ts +2 -0
  37. package/src/cli/index.ts +29 -2
  38. package/src/cli/models-runtime.ts +8 -3
  39. package/src/cli/observe.ts +13 -3
  40. package/src/cli/provider-runtime.ts +2 -1
  41. package/src/cli/registry.ts +2 -2
  42. package/src/cli/system-command.ts +10 -3
  43. package/src/cli/usage-report.ts +9 -5
  44. package/src/clients/config-export/zcode.ts +24 -0
  45. package/src/codex/account-lifecycle.ts +35 -2
  46. package/src/codex/account-runtime-state.ts +6 -1
  47. package/src/codex/account-store.ts +72 -9
  48. package/src/codex/account-usability.ts +3 -2
  49. package/src/codex/auth-api.ts +113 -26
  50. package/src/codex/auth-collision.ts +12 -2
  51. package/src/codex/auth-context.ts +96 -7
  52. package/src/codex/catalog/parsing.ts +23 -0
  53. package/src/codex/catalog/provider-fetch.ts +144 -11
  54. package/src/codex/catalog/sync.ts +14 -0
  55. package/src/codex/inject.ts +128 -30
  56. package/src/codex/internal/catalog-writer.ts +3 -0
  57. package/src/codex/journal.ts +61 -12
  58. package/src/codex/model-cache.ts +11 -4
  59. package/src/codex/native-profile-startup.ts +72 -5
  60. package/src/codex/native-profile-store.ts +2 -2
  61. package/src/codex/ocx-compaction-history.ts +226 -0
  62. package/src/codex/project-config-warnings.ts +3 -1
  63. package/src/codex/quota-auto-refresh.ts +6 -1
  64. package/src/codex/quota.ts +71 -15
  65. package/src/codex/reserve-availability.ts +21 -5
  66. package/src/codex/runtime.ts +45 -1
  67. package/src/codex/sync.ts +5 -0
  68. package/src/combos/index.ts +2 -0
  69. package/src/combos/resolve.ts +52 -0
  70. package/src/config.ts +59 -0
  71. package/src/generated/compatibility-version.json +178 -114
  72. package/src/images/loop.ts +1 -0
  73. package/src/images/xai-video-client.ts +2 -0
  74. package/src/integrations/registry.ts +1 -0
  75. package/src/lib/errors.ts +8 -0
  76. package/src/lib/privacy.ts +25 -0
  77. package/src/lib/process-control.ts +52 -8
  78. package/src/lib/upstream-retry.ts +1 -0
  79. package/src/oauth/chatgpt.ts +83 -0
  80. package/src/oauth/health.ts +47 -12
  81. package/src/oauth/index.ts +46 -8
  82. package/src/oauth/token-guardian.ts +32 -6
  83. package/src/oauth/xai.ts +151 -8
  84. package/src/providers/api-key-selection-capture.ts +10 -0
  85. package/src/providers/api-key-selection.ts +2 -7
  86. package/src/providers/caller-authorization.ts +36 -0
  87. package/src/providers/codebuddy-models.ts +184 -0
  88. package/src/providers/derive.ts +5 -0
  89. package/src/providers/free-directory.ts +26 -2
  90. package/src/providers/google-ai-studio-model-discovery.ts +74 -0
  91. package/src/providers/openai-sidecar.ts +35 -11
  92. package/src/providers/opencode-zen-rate-limit.ts +75 -0
  93. package/src/providers/qoder-models.ts +25 -0
  94. package/src/providers/quota.ts +15 -0
  95. package/src/providers/registry.ts +140 -1
  96. package/src/responses/compaction.ts +4 -0
  97. package/src/responses/task-input.ts +21 -1
  98. package/src/router.ts +1 -1
  99. package/src/server/auth-cors.ts +6 -0
  100. package/src/server/chat-completions.ts +30 -13
  101. package/src/server/chat-native.ts +10 -1
  102. package/src/server/claude-messages.ts +17 -7
  103. package/src/server/images.ts +3 -2
  104. package/src/server/index.ts +25 -2
  105. package/src/server/management/account-selection-stream.ts +13 -4
  106. package/src/server/management/config-routes.ts +24 -5
  107. package/src/server/management/logs-usage-routes.ts +5 -1
  108. package/src/server/management/model-rows.ts +16 -1
  109. package/src/server/management/native-integration-routes.ts +2 -1
  110. package/src/server/management/oauth-account-routes.ts +6 -2
  111. package/src/server/management/provider-routes.ts +33 -2
  112. package/src/server/management/request-history-routes.ts +4 -2
  113. package/src/server/management/route-registry.ts +5 -4
  114. package/src/server/management/shared.ts +66 -3
  115. package/src/server/management-api.ts +15 -1
  116. package/src/server/port-reclaim.ts +11 -26
  117. package/src/server/request-decompress.ts +91 -3
  118. package/src/server/request-log.ts +16 -0
  119. package/src/server/responses/codex-ws-wire.ts +1 -1
  120. package/src/server/responses/collaboration.ts +4 -9
  121. package/src/server/responses/compact.ts +8 -2
  122. package/src/server/responses/context-overflow.ts +11 -0
  123. package/src/server/responses/core.ts +285 -57
  124. package/src/server/responses/fetch-helpers.ts +18 -7
  125. package/src/server/responses/policy-fallback.ts +18 -2
  126. package/src/server/search.ts +2 -2
  127. package/src/service.ts +128 -9
  128. package/src/storage/cleanup.ts +77 -45
  129. package/src/types/accounts.ts +18 -0
  130. package/src/types/config.ts +43 -1
  131. package/src/types/provider.ts +56 -0
  132. package/src/types.ts +4 -0
  133. package/src/usage/log.ts +24 -0
  134. package/src/vision/anthropic-describe.ts +1 -0
  135. package/src/web-search/anthropic-executor.ts +1 -0
  136. package/src/web-search/loop.ts +1 -0
  137. package/src/web-search/ollama-executor.ts +127 -0
  138. package/src/web-search/passthrough-bridge.ts +761 -0
  139. package/src/web-search/progress-stream.ts +4 -0
  140. package/gui/dist/assets/index-B5r7LNHN.js +0 -115
  141. package/gui/dist/assets/index-D5SiRo8X.css +0 -1
@@ -0,0 +1,353 @@
1
+ import { spawn as nodeSpawn, type ChildProcess, type SpawnOptions } from "node:child_process";
2
+ import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../types";
3
+ import { commandInvocation } from "../../lib/win-exec";
4
+ import type { IncomingMeta } from "../base";
5
+ import { buildConversationInput, CodingAgentProtocolError, mapStreamMessageToEvents, readJsonLines, type StreamParseState } from "./protocol";
6
+ import { resolveCodingAgentBinary, resolveProfileByBaseUrl, type CodingAgentProviderProfile, type WhichFn } from "./profile";
7
+
8
+ /** Injectable spawn for tests; production uses node:child_process. */
9
+ export type SpawnFn = (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess;
10
+
11
+ /** Per-turn injectables: spawn/which seams for tests plus wall-clock ceilings for timeout, kill grace, and bounded reap. */
12
+ export interface CodingAgentDeps {
13
+ spawn?: SpawnFn;
14
+ which?: WhichFn;
15
+ /** Overall wall-clock ceiling for one turn (ms). */
16
+ timeoutMs?: number;
17
+ /** Grace period between SIGTERM and SIGKILL (ms). */
18
+ killGraceMs?: number;
19
+ /** Maximum time to wait for a child that never reports close after termination (ms). */
20
+ reapTimeoutMs?: number;
21
+ /** Test seam for Windows command-shim invocation. */
22
+ platform?: NodeJS.Platform;
23
+ }
24
+
25
+ const DEFAULT_TIMEOUT_MS = 300_000;
26
+ const DEFAULT_KILL_GRACE_MS = 2_000;
27
+ /** Bound captured stderr so an error message can never carry an unbounded (or secret) payload. */
28
+ const MAX_STDERR_BYTES = 8 * 1024;
29
+
30
+ /** Env keys a CLI needs to run; everything else is dropped so the child env is scoped and deterministic. */
31
+ const INHERITED_ENV_KEYS = [
32
+ "PATH", "HOME", "USERPROFILE", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR", "TEMP", "TMP",
33
+ "SHELL", "SYSTEMROOT", "APPDATA", "LOCALAPPDATA", "PROGRAMFILES", "PROGRAMFILES(X86)",
34
+ "COMSPEC", "PATHEXT", "SYSTEMDRIVE", "USERNAME", "TZ",
35
+ ] as const;
36
+
37
+ /**
38
+ * Base scoped child-process environment (§六/§十四).
39
+ *
40
+ * Never mutates `process.env` (no cross-provider pollution under concurrency) and never inherits a
41
+ * parent vendor variable, so a stray region switch in the host shell cannot flip a provider's
42
+ * region: the profile is the sole authority. Family builders layer the credential + region vars on
43
+ * top of this.
44
+ */
45
+ export function baseScopedEnv(): Record<string, string> {
46
+ const env: Record<string, string> = {};
47
+ for (const key of INHERITED_ENV_KEYS) {
48
+ const value = process.env[key];
49
+ if (typeof value === "string" && value.length > 0) env[key] = value;
50
+ }
51
+ return env;
52
+ }
53
+
54
+ /** Redact the profile's credential and common secret shapes before surfacing diagnostics. */
55
+ export function redactSecrets(text: string, tokenEnv: string, credential?: string): string {
56
+ const escaped = tokenEnv.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
57
+ let redacted = text;
58
+ if (credential) redacted = redacted.split(credential).join("[redacted]");
59
+ return redacted
60
+ .replace(new RegExp(`(${escaped}\\s*[:=]\\s*)\\S+`, "gi"), "$1[redacted]")
61
+ .replace(/(authorization\s*[:=]\s*)\S+/gi, "$1[redacted]")
62
+ .replace(/\b(sk-[A-Za-z0-9_-]{6,})\b/g, "[redacted]");
63
+ }
64
+
65
+ /** Inputs for one headless CLI turn: region profiles, request context, and family-specific arg/env builders. */
66
+ export interface CodingAgentTurnInput {
67
+ /** Region profiles for this family; the turn fails closed if the base URL matches none. */
68
+ profiles: readonly CodingAgentProviderProfile[];
69
+ provider: OcxProviderConfig;
70
+ parsed: OcxParsedRequest;
71
+ incoming: IncomingMeta;
72
+ emit: (event: AdapterEvent) => void;
73
+ /** Family-specific headless argument builder (tools disabled, model, reasoning, system prompt). */
74
+ buildArgs: (profile: CodingAgentProviderProfile, parsed: OcxParsedRequest, provider: OcxProviderConfig) => string[];
75
+ /** Family-specific scoped env builder (credential + region switch on top of baseScopedEnv). */
76
+ buildEnv: (profile: CodingAgentProviderProfile, apiKey: string) => Record<string, string>;
77
+ deps: CodingAgentDeps;
78
+ }
79
+
80
+ /**
81
+ * Run one headless coding-agent CLI turn as an OpenCodex `runTurn` (§七/§三十).
82
+ *
83
+ * Single transport for every official coding-agent CLI provider: fail closed on a non-canonical
84
+ * destination, pre-flight the credential and binary, spawn with a scoped env and tools disabled, feed
85
+ * the replayed conversation over stream-json, map the vendor's Anthropic-aligned frames to
86
+ * AdapterEvents, and always reap the process. Codex retains tool ownership: the CLI runs with its own
87
+ * tools disabled, so this turn yields text/reasoning (the control-protocol tool bridge is a
88
+ * documented fast-follow).
89
+ */
90
+ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise<void> {
91
+ const { profiles, provider, parsed, incoming, emit, buildArgs, buildEnv, deps } = input;
92
+ const spawnFn = deps.spawn ?? nodeSpawn;
93
+ const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
94
+ const killGraceMs = deps.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
95
+ const reapTimeoutMs = deps.reapTimeoutMs ?? (killGraceMs * 2 + 250);
96
+
97
+ if (incoming.abortSignal?.aborted) {
98
+ emit({ type: "error", message: "Coding-agent turn was aborted before start." });
99
+ return;
100
+ }
101
+
102
+ // Fail closed on a non-canonical destination BEFORE any credential is placed in an env (§十六).
103
+ const profile = resolveProfileByBaseUrl(profiles, provider.baseUrl);
104
+ if (!profile) {
105
+ emit({
106
+ type: "error",
107
+ message: "Provider base URL is not a canonical region destination; the credential was not sent.",
108
+ status: 400,
109
+ errorType: "invalid_request_error",
110
+ code: "non_canonical_destination",
111
+ retryable: false,
112
+ });
113
+ return;
114
+ }
115
+ const apiKey = provider.apiKey;
116
+ if (!apiKey) {
117
+ emit({
118
+ type: "error",
119
+ message: `${profile.label} credential missing — add an API key for this provider (${profile.tokenEnv}).`,
120
+ status: 401,
121
+ errorType: "authentication_error",
122
+ code: "missing_credential",
123
+ retryable: false,
124
+ });
125
+ return;
126
+ }
127
+ // Pre-flight binary discovery so a missing CLI is a clear error, not a mid-turn ENOENT (§二十六).
128
+ const binary = resolveCodingAgentBinary(profile, deps.which);
129
+ if (!binary) {
130
+ emit({
131
+ type: "error",
132
+ message: `${profile.label} CLI not found on PATH. Install it with: ${profile.installHint}`,
133
+ status: 500,
134
+ errorType: "upstream_error",
135
+ code: "cli_not_found",
136
+ retryable: false,
137
+ });
138
+ return;
139
+ }
140
+
141
+ const args = buildArgs(profile, parsed, provider);
142
+ const env = buildEnv(profile, apiKey);
143
+ const invocation = commandInvocation(binary, args, deps.platform ?? process.platform, { env });
144
+
145
+ let child: ChildProcess;
146
+ try {
147
+ child = spawnFn(invocation.file, invocation.args, {
148
+ ...invocation.options,
149
+ env,
150
+ stdio: ["pipe", "pipe", "pipe"],
151
+ windowsHide: true,
152
+ });
153
+ } catch (err) {
154
+ emit({
155
+ type: "error",
156
+ message: redactSecrets(err instanceof Error ? err.message : String(err), profile.tokenEnv, apiKey),
157
+ status: 500,
158
+ errorType: "upstream_error",
159
+ code: "cli_spawn_failed",
160
+ retryable: false,
161
+ });
162
+ return;
163
+ }
164
+
165
+ // `spawn()` reports launch failures such as ENOENT asynchronously through `error`; they are not
166
+ // reliably thrown by the call above. Subscribe immediately and create the lifecycle promise now,
167
+ // before stdout can end, so neither a fast close nor a launch failure can be missed by the reap step.
168
+ let childProcessError: Error | undefined;
169
+ const processLifecycle = new Promise<void>(resolve => {
170
+ let settled = false;
171
+ const settle = (): void => {
172
+ if (settled) return;
173
+ settled = true;
174
+ resolve();
175
+ };
176
+ child.once("error", err => {
177
+ childProcessError = err;
178
+ // A launch failure has no process to reap and is not guaranteed to emit `close` on every runtime.
179
+ if (child.pid === undefined) settle();
180
+ });
181
+ child.once("close", settle);
182
+ if (child.exitCode !== null) settle();
183
+ });
184
+
185
+ let terminalEmitted = false;
186
+ const emitOnce = (event: AdapterEvent): void => {
187
+ if (event.type === "done" || event.type === "error" || event.type === "incomplete") {
188
+ if (terminalEmitted) return;
189
+ terminalEmitted = true;
190
+ }
191
+ emit(event);
192
+ };
193
+
194
+ const stderrChunks: string[] = [];
195
+ let killed = false;
196
+ let killTimer: ReturnType<typeof setTimeout> | undefined;
197
+ const kill = (): void => {
198
+ if (killed || child.killed) return;
199
+ killed = true;
200
+ try { child.kill("SIGTERM"); } catch { /* already gone */ }
201
+ killTimer = setTimeout(() => {
202
+ try { child.kill("SIGKILL"); } catch { /* already gone */ }
203
+ }, killGraceMs);
204
+ };
205
+
206
+ const stopStream = (): void => {
207
+ try { child.stdout?.destroy(); } catch { /* already closed */ }
208
+ };
209
+ const onAbort = (): void => {
210
+ kill();
211
+ stopStream();
212
+ };
213
+ incoming.abortSignal?.addEventListener("abort", onAbort, { once: true });
214
+ const timeoutTimer = setTimeout(() => {
215
+ kill();
216
+ stopStream();
217
+ emitOnce({ type: "error", message: `${profile.label} turn timed out.`, status: 504, errorType: "upstream_error", code: "timeout", retryable: true });
218
+ }, timeoutMs);
219
+
220
+ child.stderr?.setEncoding("utf8");
221
+ child.stderr?.on("data", (chunk: string) => {
222
+ if (stderrChunks.join("").length < MAX_STDERR_BYTES) stderrChunks.push(chunk);
223
+ });
224
+
225
+ const cleanup = (): void => {
226
+ clearTimeout(timeoutTimer);
227
+ incoming.abortSignal?.removeEventListener("abort", onAbort);
228
+ try { child.stdin?.destroy(); } catch { /* ignore */ }
229
+ // Termination is owned by the reap step below, not here: killing in cleanup would set
230
+ // `child.killed` and let the wait resolve before the process is actually reaped (§三十).
231
+ };
232
+
233
+ let streamProtocolError: string | undefined;
234
+ let turnError: string | undefined;
235
+ const state: StreamParseState = {
236
+ sawPartialText: false,
237
+ sawPartialThinking: false,
238
+ sawTerminalResult: false,
239
+ openToolCallId: undefined,
240
+ };
241
+
242
+ try {
243
+ // Write the replayed conversation, then close stdin so a single-shot turn can complete.
244
+ const stdin = child.stdin;
245
+ if (stdin) {
246
+ stdin.on("error", () => { /* EPIPE if the CLI exits early; surfaced via close/stderr */ });
247
+ for (const line of buildConversationInput(parsed)) stdin.write(`${line}\n`);
248
+ stdin.end();
249
+ }
250
+ const stdout = child.stdout;
251
+ if (!stdout) throw new CodingAgentProtocolError(`${profile.label} CLI produced no stdout stream`);
252
+ try {
253
+ for await (const message of readJsonLines(stdout)) {
254
+ if (incoming.abortSignal?.aborted) break;
255
+ for (const event of mapStreamMessageToEvents(message, state)) {
256
+ emitOnce(event.type === "error"
257
+ ? { ...event, message: redactSecrets(event.message, profile.tokenEnv, apiKey) }
258
+ : event);
259
+ }
260
+ if (terminalEmitted) break;
261
+ }
262
+ } catch (err) {
263
+ kill();
264
+ streamProtocolError = err instanceof Error ? err.message : String(err);
265
+ }
266
+ } catch (err) {
267
+ kill();
268
+ turnError = err instanceof Error ? err.message : String(err);
269
+ } finally {
270
+ cleanup();
271
+ }
272
+
273
+ // Reap the process so no zombie is left behind (§三十): wait for the real `close`, and
274
+ // force-terminate only if it lingers past the grace window after the stream ended.
275
+ const graceTimer = setTimeout(() => { kill(); }, killGraceMs);
276
+ let reapTimer: ReturnType<typeof setTimeout> | undefined;
277
+ await Promise.race([
278
+ processLifecycle,
279
+ new Promise<void>(resolve => {
280
+ reapTimer = setTimeout(resolve, reapTimeoutMs);
281
+ }),
282
+ ]);
283
+ clearTimeout(graceTimer);
284
+ if (reapTimer) clearTimeout(reapTimer);
285
+ if (killTimer) clearTimeout(killTimer);
286
+
287
+ if (!terminalEmitted) {
288
+ const stderr = redactSecrets(boundedStderr(stderrChunks), profile.tokenEnv, apiKey);
289
+ if (incoming.abortSignal?.aborted) {
290
+ emitOnce({ type: "error", message: `${profile.label} turn was aborted.`, retryable: false });
291
+ } else if (childProcessError) {
292
+ emitOnce({
293
+ type: "error",
294
+ message: `${profile.label} CLI failed to start: ${redactSecrets(childProcessError.message, profile.tokenEnv, apiKey)}`,
295
+ status: 500,
296
+ errorType: "upstream_error",
297
+ code: "cli_spawn_failed",
298
+ retryable: false,
299
+ });
300
+ } else if (turnError) {
301
+ emitOnce({
302
+ type: "error",
303
+ message: redactSecrets(turnError, profile.tokenEnv, apiKey),
304
+ status: 502,
305
+ errorType: "upstream_error",
306
+ });
307
+ } else if (streamProtocolError) {
308
+ emitOnce({
309
+ type: "error",
310
+ message: redactSecrets(streamProtocolError, profile.tokenEnv, apiKey),
311
+ status: 502,
312
+ errorType: "upstream_error",
313
+ code: "protocol_error",
314
+ retryable: false,
315
+ });
316
+ } else if (child.exitCode !== null && child.exitCode !== 0) {
317
+ const exitMsg = stderr
318
+ ? `${profile.label} CLI exited with code ${child.exitCode}: ${stderr}`
319
+ : `${profile.label} CLI exited with non-zero exit code ${child.exitCode}`;
320
+ emitOnce({
321
+ type: "error",
322
+ message: exitMsg,
323
+ status: 502,
324
+ errorType: "upstream_error",
325
+ code: "process_exit_error",
326
+ retryable: false,
327
+ });
328
+ } else if (!state.sawTerminalResult) {
329
+ const msg = stderr
330
+ ? `${profile.label} CLI ended without a terminal result frame: ${stderr}`
331
+ : `${profile.label} CLI ended without a terminal result frame`;
332
+ emitOnce({
333
+ type: "error",
334
+ message: msg,
335
+ status: 502,
336
+ errorType: "upstream_error",
337
+ code: "protocol_error",
338
+ retryable: false,
339
+ });
340
+ }
341
+ }
342
+ }
343
+
344
+ function boundedStderr(chunks: string[]): string {
345
+ let total = 0;
346
+ const kept: string[] = [];
347
+ for (const chunk of chunks) {
348
+ if (total >= MAX_STDERR_BYTES) break;
349
+ kept.push(chunk);
350
+ total += chunk.length;
351
+ }
352
+ return kept.join("").slice(0, MAX_STDERR_BYTES).trim();
353
+ }
@@ -429,6 +429,18 @@ function messagesToGeminiFormat(
429
429
  }
430
430
  }
431
431
 
432
+ // Gemini API and Claude-on-Antigravity reject assistant-tail (model-tail in Gemini terms)
433
+ // histories. Gemini fails upstream with "Requests ending with a model turn are not supported"
434
+ // (HTTP 400), while Claude fails with "This model does not support assistant message prefill.
435
+ // The conversation must end with a user message." Context compaction, previous_response_id
436
+ // expansion, subagent orchestration, and interrupted-turn replay can all produce a
437
+ // model-tail history. Append a user "(continue)" nudge, mirroring the anthropic adapter's
438
+ // tail guard (src/adapters/anthropic.ts).
439
+ const lastTurn = contents.length > 0 ? (contents[contents.length - 1] as { role?: string }) : undefined;
440
+ if (!lastTurn || lastTurn.role === "model") {
441
+ contents.push({ role: "user", parts: [{ text: "(continue)" }] });
442
+ }
443
+
432
444
  return { systemInstruction, contents, replayedCallIds };
433
445
  }
434
446
 
@@ -894,17 +906,9 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
894
906
  // fills a first functionCall that replay could not sign. Outside the cache branch too,
895
907
  // because the turn still needs a signature when no session was ever recorded.
896
908
  applyAntigravityThoughtSignatureFallback(wireModelId, contents);
897
- // Claude-on-Antigravity rejects assistant-tail (model-tail in Gemini terms) histories
898
- // as prefill: "This model does not support assistant message prefill. The conversation
899
- // must end with a user message." Context compaction, previous_response_id expansion,
900
- // and interrupted-turn replay can all produce a model-tail history. Append a user
901
- // "(continue)" nudge, mirroring the anthropic adapter's tail guard (src/adapters/anthropic.ts).
902
- if (/claude/i.test(wireModelId)) {
903
- const last = contents.length > 0 ? contents[contents.length - 1] as { role?: string } : undefined;
904
- if (!last || last.role === "model") {
905
- contents.push({ role: "user", parts: [{ text: "(continue)" }] });
906
- }
907
- }
909
+ // The model-tail "(continue)" guard runs once, in messagesToGeminiFormat, so CCA,
910
+ // Vertex and AI Studio share one decision. A second check here would append a
911
+ // duplicate nudge whenever signature sanitization reshapes the tail afterwards.
908
912
  }
909
913
  const envelope = {
910
914
  model: wireModelId,
@@ -110,6 +110,7 @@ async function fetchJwt(signal?: AbortSignal): Promise<string> {
110
110
  const combined = signal ? AbortSignal.any([signal, timeout]) : timeout;
111
111
  const response = await fetch(BOOTSTRAP_URL, {
112
112
  method: "POST",
113
+ redirect: "manual",
113
114
  headers: {
114
115
  "Content-Type": "application/json",
115
116
  "User-Agent": randomUserAgent(),
@@ -249,6 +250,7 @@ export function createMimoFreeAdapter(provider: OcxProviderConfig): ProviderAdap
249
250
  async fetchResponse(request: AdapterRequest, ctx): Promise<Response> {
250
251
  const response = await fetch(request.url, {
251
252
  method: request.method,
253
+ redirect: "manual",
252
254
  headers: request.headers as Record<string, string>,
253
255
  body: request.body,
254
256
  signal: ctx?.abortSignal,
@@ -268,6 +270,7 @@ export function createMimoFreeAdapter(provider: OcxProviderConfig): ProviderAdap
268
270
  };
269
271
  return fetch(request.url, {
270
272
  method: request.method,
273
+ redirect: "manual",
271
274
  headers: retryHeaders,
272
275
  body: request.body,
273
276
  signal: ctx?.abortSignal,
@@ -27,7 +27,7 @@ import {
27
27
  type ResolvedFastPolicy,
28
28
  } from "../providers/fastwire";
29
29
  import { openaiChatCompletionsUrl } from "./openai-chat-url";
30
- import { stripResponsesOnlyEncryptedMarker } from "./responses-tool-schema";
30
+ import { stripResponsesOnlyEncryptedMarker, stripUnicodePropertyPatterns } from "./responses-tool-schema";
31
31
  import { agentRouterDefaultHeaders, frameAgentRouterMessages } from "./agentrouter";
32
32
  import {
33
33
  isXaiSchemaTarget,
@@ -1331,7 +1331,7 @@ function toolsToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig
1331
1331
  : moonshotTarget
1332
1332
  ? normalizeMoonshotToolParameters(t.parameters)
1333
1333
  : ensureRootObjectType(t.parameters);
1334
- const parameters = stripResponsesOnlyEncryptedMarker(normalized);
1334
+ const parameters = stripUnicodePropertyPatterns(stripResponsesOnlyEncryptedMarker(normalized));
1335
1335
 
1336
1336
  if (parameters === undefined) return [];
1337
1337
  return [{
@@ -25,6 +25,7 @@ import { rewriteRoutedToolSearchForUpstream } from "../responses/tool-search-com
25
25
  import { rewriteRoutedNamespaceToolsForUpstream } from "../responses/namespace-tool-compat";
26
26
  import { openaiResponsesUrl } from "./openai-responses-url";
27
27
  import { normalizeResponsesCodeMode } from "./responses-code-mode";
28
+ import { stripUnicodePropertyPatterns } from "./responses-tool-schema";
28
29
  import { injectXaiResponsesXSearch, normalizeXaiResponsesWebSearch } from "./xai-web-search";
29
30
  import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "./empty-tool-output-annotation";
30
31
  import {
@@ -649,14 +650,18 @@ function mapRoutedResponsesReasoningEffort(
649
650
 
650
651
  function normalizeFunctionToolSchema(tool: unknown, xaiTarget: boolean): unknown | undefined {
651
652
  if (!isPlainObject(tool) || tool.type !== "function") return tool;
653
+ // Runs for every Responses destination, forward auth included: the ChatGPT backend is where
654
+ // the `\p{…}` rejection was observed, and it reaches this function through the same seam.
655
+ const compatible = stripUnicodePropertyPatterns(tool);
656
+ const source = isPlainObject(compatible) ? compatible : tool;
652
657
  if (xaiTarget) {
653
- const parameters = normalizeXaiToolParameters(isPlainObject(tool.parameters) ? tool.parameters : {});
654
- return parameters === undefined ? undefined : { ...tool, parameters };
658
+ const parameters = normalizeXaiToolParameters(isPlainObject(source.parameters) ? source.parameters : {});
659
+ return parameters === undefined ? undefined : { ...source, parameters };
655
660
  }
656
- if (isPlainObject(tool.parameters) && tool.parameters.type === "object") return tool;
661
+ if (isPlainObject(source.parameters) && source.parameters.type === "object") return source;
657
662
  return {
658
- ...tool,
659
- parameters: { ...(isPlainObject(tool.parameters) ? tool.parameters : {}), type: "object" },
663
+ ...source,
664
+ parameters: { ...(isPlainObject(source.parameters) ? source.parameters : {}), type: "object" },
660
665
  };
661
666
  }
662
667
 
@@ -2134,6 +2139,7 @@ const MUSE_SPARK_WEB_SEARCH_STRICT_MODELS = new Set([
2134
2139
  const MUSE_SPARK_WEB_SEARCH_STRICT_RESPONSE_URLS = new Set([
2135
2140
  "https://opencode.ai/zen/v1/responses",
2136
2141
  "https://opencode.ai/zen/go/v1/responses",
2142
+ "https://api.meta.ai/v1/responses",
2137
2143
  ]);
2138
2144
 
2139
2145
  const MUSE_SPARK_UNSUPPORTED_WEB_SEARCH_FIELDS = [
@@ -2142,12 +2148,13 @@ const MUSE_SPARK_UNSUPPORTED_WEB_SEARCH_FIELDS = [
2142
2148
  ] as const;
2143
2149
 
2144
2150
  /**
2145
- * OpenCode Zen / Go Muse Spark Responses gateway refuses a short list of Codex
2146
- * `web_search` fields. `web_search_preview` keeps its accepted shape, and Luna
2147
- * remains untouched. Match the exact effective request URL; malformed, credentialed,
2148
- * or parameterized destinations keep their original body instead of assuming this
2149
- * gateway contract. Keep the rejected names together so a newly identified field is
2150
- * a one-line compatibility update rather than another bespoke rewrite.
2151
+ * OpenCode Zen / Go and the direct Meta Muse Spark Responses gateways refuse a
2152
+ * short list of Codex `web_search` fields. `web_search_preview` keeps its accepted
2153
+ * shape, and Luna remains untouched. Match the exact effective request URL;
2154
+ * malformed, credentialed, or parameterized destinations keep their original body
2155
+ * instead of assuming this gateway contract. Keep the rejected names together so a
2156
+ * newly identified field is a one-line compatibility update rather than another
2157
+ * bespoke rewrite.
2151
2158
  */
2152
2159
  function stripMuseSparkUnsupportedWebSearchFields(
2153
2160
  body: unknown,
@@ -0,0 +1,70 @@
1
+ import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../types";
2
+ import type { AdapterRequest, ProviderAdapter } from "../base";
3
+ import { mapReasoningEffort } from "../../reasoning-effort";
4
+ import { buildSystemPrompt } from "../coding-agent/protocol";
5
+ import { baseScopedEnv, runCodingAgentTurn, type CodingAgentDeps } from "../coding-agent/turn";
6
+ import { QODER_PROFILES, type QoderProfile } from "./profiles";
7
+
8
+ export type QoderAdapterDeps = CodingAgentDeps;
9
+
10
+ export function buildQoderChildEnv(profile: QoderProfile, apiKey: string): Record<string, string> {
11
+ return { ...baseScopedEnv(), NO_COLOR: "1", [profile.tokenEnv]: apiKey };
12
+ }
13
+
14
+ /** Single-shot, tools-disabled Qoder CLI invocation; Codex remains the tool owner. */
15
+ export function buildQoderArgs(parsed: OcxParsedRequest, provider: OcxProviderConfig): string[] {
16
+ const args = [
17
+ "-p",
18
+ "--output-format", "stream-json",
19
+ "--input-format", "stream-json",
20
+ "--tools", "",
21
+ "--strict-mcp-config",
22
+ "--setting-sources", "",
23
+ "--max-turns", "1",
24
+ "--no-session-persistence",
25
+ "--model", parsed.modelId,
26
+ ];
27
+ const effort = mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning);
28
+ if (effort) args.push("--reasoning-effort", effort);
29
+ const system = buildSystemPrompt(parsed);
30
+ if (system) args.push("--append-system-prompt", system);
31
+ return args;
32
+ }
33
+
34
+ export function createQoderAdapter(provider: OcxProviderConfig, deps: QoderAdapterDeps = {}): ProviderAdapter {
35
+ return {
36
+ name: "qoder",
37
+ buildRequest(): AdapterRequest {
38
+ return { url: provider.baseUrl, method: "POST", headers: {}, body: "" };
39
+ },
40
+ async *parseStream(): AsyncGenerator<AdapterEvent> {
41
+ yield { type: "error", message: "Qoder adapter uses runTurn; the fetch/parseStream path is disabled." };
42
+ },
43
+ async runTurn(parsed, incoming, emit): Promise<void> {
44
+ const hasImage = parsed.context.messages.some(message =>
45
+ Array.isArray(message.content) && message.content.some(part => part.type === "image"),
46
+ );
47
+ if (hasImage) {
48
+ emit({
49
+ type: "error",
50
+ message: "Qoder image input is not enabled because the CLI provider route has no verified multimodal contract.",
51
+ status: 400,
52
+ errorType: "invalid_request_error",
53
+ code: "unsupported_input_modality",
54
+ retryable: false,
55
+ });
56
+ return;
57
+ }
58
+ await runCodingAgentTurn({
59
+ profiles: QODER_PROFILES,
60
+ provider,
61
+ parsed,
62
+ incoming,
63
+ emit,
64
+ buildArgs: (_profile, req, prov) => buildQoderArgs(req, prov),
65
+ buildEnv: (profile, apiKey) => buildQoderChildEnv(profile as QoderProfile, apiKey),
66
+ deps,
67
+ });
68
+ },
69
+ };
70
+ }
@@ -0,0 +1,89 @@
1
+ import { execFile } from "node:child_process";
2
+ import { commandInvocation } from "../../lib/win-exec";
3
+ import { isValidModelDiscoveryModelId } from "../../providers/model-discovery-limits";
4
+ import { baseScopedEnv, redactSecrets } from "../coding-agent/turn";
5
+ import { resolveCodingAgentBinary, type WhichFn } from "../coding-agent/profile";
6
+ import type { QoderProfile } from "./profiles";
7
+
8
+ const MAX_OUTPUT_BYTES = 256 * 1024;
9
+ const MAX_MODELS = 256;
10
+
11
+ export type QoderModelsResult =
12
+ | { ok: true; models: string[] }
13
+ | { ok: false; error: "auth" | "cli_not_found" | "timeout" | "process" | "invalid_output" | "empty" | "too_large"; detail?: string };
14
+
15
+ export interface QoderExecResult { stdout: string; stderr: string }
16
+ export type QoderExecFn = (
17
+ command: string,
18
+ args: readonly string[],
19
+ options: { env: Record<string, string>; timeout: number; maxBuffer: number; windowsHide: boolean; windowsVerbatimArguments?: boolean },
20
+ ) => Promise<QoderExecResult>;
21
+
22
+ export interface QoderModelsDeps {
23
+ which?: WhichFn;
24
+ platform?: NodeJS.Platform;
25
+ timeoutMs?: number;
26
+ exec?: QoderExecFn;
27
+ }
28
+
29
+ type QoderModelsFetcher = (profile: QoderProfile, apiKey: string) => QoderModelsResult | Promise<QoderModelsResult>;
30
+ let qoderModelsFetcherForTests: QoderModelsFetcher | null = null;
31
+
32
+ export function setFetchQoderModelsForTests(next: QoderModelsFetcher | null): void {
33
+ qoderModelsFetcherForTests = next;
34
+ }
35
+
36
+ export function parseQoderModelList(stdout: string): QoderModelsResult {
37
+ if (Buffer.byteLength(stdout) > MAX_OUTPUT_BYTES) return { ok: false, error: "too_large" };
38
+ const lines = stdout.split(/\r?\n/);
39
+ const header = lines.findIndex(raw => /^model$/i.test(raw.trim()));
40
+ if (header < 0) return { ok: false, error: "invalid_output", detail: "Qoder model list header is missing" };
41
+ const models: string[] = [];
42
+ const seen = new Set<string>();
43
+ for (const raw of lines.slice(header + 1)) {
44
+ const id = raw.trim();
45
+ if (!id || seen.has(id) || !isValidModelDiscoveryModelId(id)) continue;
46
+ seen.add(id);
47
+ models.push(id);
48
+ if (models.length >= MAX_MODELS) break;
49
+ }
50
+ return models.length > 0 ? { ok: true, models } : { ok: false, error: "empty" };
51
+ }
52
+
53
+ function execQoder(command: string, args: readonly string[], options: Parameters<QoderExecFn>[2]): Promise<QoderExecResult> {
54
+ return new Promise((resolve, reject) => {
55
+ execFile(command, [...args], { ...options, encoding: "utf8" }, (error, stdout, stderr) => {
56
+ if (error) {
57
+ reject(Object.assign(error, { stdout, stderr }));
58
+ return;
59
+ }
60
+ resolve({ stdout, stderr });
61
+ });
62
+ });
63
+ }
64
+
65
+ /** Discover the roster exposed to this exact PAT via the documented `--list-models` command. */
66
+ export async function fetchQoderModels(profile: QoderProfile, apiKey: string, deps: QoderModelsDeps = {}): Promise<QoderModelsResult> {
67
+ if (qoderModelsFetcherForTests) return qoderModelsFetcherForTests(profile, apiKey);
68
+ const binary = resolveCodingAgentBinary(profile, deps.which);
69
+ if (!binary) return { ok: false, error: "cli_not_found", detail: profile.installHint };
70
+ const env = { ...baseScopedEnv(), NO_COLOR: "1", [profile.tokenEnv]: apiKey };
71
+ const invocation = commandInvocation(binary, ["--list-models"], deps.platform ?? process.platform, { env });
72
+ try {
73
+ const result = await (deps.exec ?? execQoder)(invocation.file, invocation.args, {
74
+ ...invocation.options,
75
+ env,
76
+ timeout: deps.timeoutMs ?? 8_000,
77
+ maxBuffer: MAX_OUTPUT_BYTES,
78
+ windowsHide: true,
79
+ });
80
+ return parseQoderModelList(result.stdout);
81
+ } catch (error) {
82
+ const failure = error as NodeJS.ErrnoException & { killed?: boolean; stderr?: string };
83
+ const stderr = redactSecrets(failure.stderr ?? failure.message ?? String(error), profile.tokenEnv, apiKey).trim().slice(0, 512);
84
+ if (failure.killed || failure.code === "ETIMEDOUT") return { ok: false, error: "timeout", detail: "Qoder model discovery timed out" };
85
+ if (failure.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") return { ok: false, error: "too_large" };
86
+ const auth = /not logged in|invalid (?:personal access )?token|authentication/i.test(stderr);
87
+ return { ok: false, error: auth ? "auth" : "process", ...(stderr ? { detail: stderr } : {}) };
88
+ }
89
+ }