@bitkyc08/opencodex 2.48.0 → 2.49.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 +8 -5
- package/SPONSORS.md +1 -1
- package/assets/sponsors/orcarouter.png +0 -0
- package/assets/sponsors/packycode.png +0 -0
- package/gui/dist/assets/index-BoBRSehJ.css +1 -0
- package/gui/dist/assets/index-BtyONQrZ.js +115 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/packycode.svg +19 -0
- package/gui/dist/provider-icons/qoder.svg +5 -0
- package/package.json +5 -3
- package/src/adapters/anthropic.ts +31 -16
- package/src/adapters/codebuddy/adapter.ts +85 -0
- package/src/adapters/codebuddy/profiles.ts +52 -0
- package/src/adapters/coding-agent/profile.ts +100 -0
- package/src/adapters/coding-agent/protocol.ts +463 -0
- package/src/adapters/coding-agent/turn.ts +353 -0
- package/src/adapters/google.ts +15 -11
- package/src/adapters/mimo-free.ts +3 -0
- package/src/adapters/openai-chat.ts +2 -2
- package/src/adapters/openai-responses.ts +18 -11
- package/src/adapters/qoder/adapter.ts +70 -0
- package/src/adapters/qoder/live-models.ts +89 -0
- package/src/adapters/qoder/profiles.ts +36 -0
- package/src/adapters/registry.ts +12 -0
- package/src/adapters/responses-tool-schema.ts +113 -8
- package/src/cli/dispatch.ts +2 -0
- package/src/cli/doctor.ts +40 -0
- package/src/cli/effort.ts +24 -8
- package/src/cli/help.ts +2 -0
- package/src/cli/index.ts +24 -1
- package/src/cli/provider-runtime.ts +2 -1
- package/src/cli/registry.ts +2 -2
- package/src/cli/system-command.ts +10 -3
- package/src/cli/usage-report.ts +9 -5
- package/src/codex/account-lifecycle.ts +35 -2
- package/src/codex/auth-api.ts +6 -3
- package/src/codex/auth-collision.ts +12 -2
- package/src/codex/auth-context.ts +75 -7
- package/src/codex/catalog/provider-fetch.ts +73 -9
- package/src/codex/inject.ts +127 -30
- package/src/codex/internal/catalog-writer.ts +3 -0
- package/src/codex/journal.ts +61 -12
- package/src/codex/model-cache.ts +11 -4
- package/src/codex/native-profile-startup.ts +72 -5
- package/src/codex/native-profile-store.ts +2 -2
- package/src/codex/ocx-compaction-history.ts +226 -0
- package/src/codex/project-config-warnings.ts +3 -1
- package/src/codex/quota.ts +18 -8
- package/src/codex/reserve-availability.ts +21 -5
- package/src/codex/runtime.ts +45 -1
- package/src/codex/sync.ts +5 -0
- package/src/config.ts +1 -0
- package/src/generated/compatibility-version.json +126 -74
- package/src/images/loop.ts +1 -0
- package/src/images/xai-video-client.ts +2 -0
- package/src/integrations/registry.ts +1 -0
- package/src/lib/process-control.ts +52 -8
- package/src/lib/upstream-retry.ts +1 -0
- package/src/oauth/chatgpt.ts +83 -0
- package/src/oauth/xai.ts +151 -8
- package/src/providers/api-key-selection-capture.ts +10 -0
- package/src/providers/api-key-selection.ts +2 -7
- package/src/providers/caller-authorization.ts +36 -0
- package/src/providers/codebuddy-models.ts +184 -0
- package/src/providers/derive.ts +5 -0
- package/src/providers/free-directory.ts +26 -2
- package/src/providers/openai-sidecar.ts +35 -11
- package/src/providers/qoder-models.ts +25 -0
- package/src/providers/registry.ts +139 -0
- package/src/responses/compaction.ts +4 -0
- package/src/responses/task-input.ts +21 -1
- package/src/router.ts +1 -1
- package/src/server/chat-completions.ts +26 -9
- package/src/server/claude-messages.ts +12 -2
- package/src/server/images.ts +1 -0
- package/src/server/management/account-selection-stream.ts +13 -4
- package/src/server/management/config-routes.ts +24 -5
- package/src/server/management/logs-usage-routes.ts +1 -0
- package/src/server/management/native-integration-routes.ts +2 -1
- package/src/server/management/provider-routes.ts +24 -0
- package/src/server/management-api.ts +14 -0
- package/src/server/port-reclaim.ts +11 -26
- package/src/server/request-log.ts +6 -0
- package/src/server/responses/collaboration.ts +4 -9
- package/src/server/responses/core.ts +141 -19
- package/src/server/responses/fetch-helpers.ts +18 -7
- package/src/server/responses/policy-fallback.ts +12 -0
- package/src/service.ts +36 -2
- package/src/storage/cleanup.ts +77 -45
- package/src/types/config.ts +7 -1
- package/src/usage/log.ts +24 -0
- package/src/vision/anthropic-describe.ts +1 -0
- package/src/web-search/anthropic-executor.ts +1 -0
- package/src/web-search/loop.ts +1 -0
- package/src/web-search/progress-stream.ts +4 -0
- package/gui/dist/assets/index-B5r7LNHN.js +0 -115
- 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
|
+
}
|
package/src/adapters/google.ts
CHANGED
|
@@ -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
|
-
//
|
|
898
|
-
//
|
|
899
|
-
//
|
|
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(
|
|
654
|
-
return parameters === undefined ? undefined : { ...
|
|
658
|
+
const parameters = normalizeXaiToolParameters(isPlainObject(source.parameters) ? source.parameters : {});
|
|
659
|
+
return parameters === undefined ? undefined : { ...source, parameters };
|
|
655
660
|
}
|
|
656
|
-
if (isPlainObject(
|
|
661
|
+
if (isPlainObject(source.parameters) && source.parameters.type === "object") return source;
|
|
657
662
|
return {
|
|
658
|
-
...
|
|
659
|
-
parameters: { ...(isPlainObject(
|
|
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
|
|
2146
|
-
* `web_search` fields. `web_search_preview` keeps its accepted
|
|
2147
|
-
* remains untouched. Match the exact effective request URL;
|
|
2148
|
-
* or parameterized destinations keep their original body
|
|
2149
|
-
* gateway contract. Keep the rejected names together so a
|
|
2150
|
-
* a one-line compatibility update rather than another
|
|
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
|
+
}
|