@bitkyc08/opencodex 2.7.4 → 2.7.6
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.ko.md +6 -3
- package/README.md +6 -4
- package/README.zh-CN.md +4 -3
- package/gui/dist/assets/{index-66dPs6l_.js → index-mEjIne-M.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +30 -2
- package/src/adapters/base.ts +10 -0
- package/src/adapters/google-http.ts +37 -13
- package/src/adapters/google-tool-schema.ts +5 -0
- package/src/adapters/google.ts +3 -0
- package/src/adapters/kiro-retry.ts +33 -13
- package/src/adapters/kiro-tools.ts +4 -0
- package/src/adapters/kiro.ts +5 -1
- package/src/codex/catalog.ts +64 -6
- package/src/lib/abort.ts +40 -0
- package/src/lib/bounded-body.ts +202 -0
- package/src/providers/registry.ts +4 -1
- package/src/server/auth-cors.ts +16 -13
- package/src/server/images.ts +218 -0
- package/src/server/index.ts +29 -2
- package/src/server/responses.ts +2 -1
- package/src/types.ts +12 -0
- package/src/web-search/index.ts +44 -10
- package/src/web-search/loop.ts +165 -112
- package/src/web-search/progress-stream.ts +329 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/** Maximum number of response-body bytes that may be retained for an error. */
|
|
2
|
+
export const BOUNDED_BODY_MAX_BYTES = 65_536;
|
|
3
|
+
|
|
4
|
+
/** Default wall-clock and continuous-silence deadlines. */
|
|
5
|
+
export const BOUNDED_BODY_TIMEOUT_MS = 5_000;
|
|
6
|
+
|
|
7
|
+
export interface BoundedBodyOptions {
|
|
8
|
+
/** Abort the read with this signal. Its reason is rethrown by identity. */
|
|
9
|
+
signal?: AbortSignal;
|
|
10
|
+
/** Total wall-clock deadline. Exposed for focused tests. */
|
|
11
|
+
totalTimeoutMs?: number;
|
|
12
|
+
/** Deadline between non-empty raw chunks. Exposed for focused tests. */
|
|
13
|
+
inactivityTimeoutMs?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface BoundedBodyResult {
|
|
17
|
+
/** UTF-8 text retained from the response. Empty when the size limit was exceeded. */
|
|
18
|
+
text: string;
|
|
19
|
+
/** True when EOF was not observed. */
|
|
20
|
+
truncated: boolean;
|
|
21
|
+
/** True for either total-deadline or inactivity-deadline expiry. */
|
|
22
|
+
timedOut: boolean;
|
|
23
|
+
/** Distinguishes the wall-clock deadline from an inactivity deadline. */
|
|
24
|
+
totalTimedOut: boolean;
|
|
25
|
+
/** True only when continuous inactivity caused the timeout. */
|
|
26
|
+
inactivityTimedOut: boolean;
|
|
27
|
+
/** True when the body was observed to exceed the byte cap. */
|
|
28
|
+
oversized: boolean;
|
|
29
|
+
/** False means callers should use a status-only fallback, not `text`. */
|
|
30
|
+
displaySafe: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const TOTAL_TIMEOUT = Symbol("bounded body total timeout");
|
|
34
|
+
const INACTIVITY_TIMEOUT = Symbol("bounded body inactivity timeout");
|
|
35
|
+
|
|
36
|
+
function timeoutPromise(ms: number, value: symbol): { promise: Promise<symbol>; clear: () => void } {
|
|
37
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
38
|
+
const promise = new Promise<symbol>((resolve) => {
|
|
39
|
+
timer = setTimeout(() => resolve(value), Math.max(0, ms));
|
|
40
|
+
});
|
|
41
|
+
return {
|
|
42
|
+
promise,
|
|
43
|
+
clear: () => {
|
|
44
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function cancelWithoutWaiting(reader: ReadableStreamDefaultReader<Uint8Array>, reason?: unknown): void {
|
|
50
|
+
// A hostile/broken stream may reject or never settle cancel(). Neither should
|
|
51
|
+
// escape as an unhandled rejection or extend this primitive's own deadline.
|
|
52
|
+
try {
|
|
53
|
+
void reader.cancel(reason).catch(() => undefined);
|
|
54
|
+
} catch {
|
|
55
|
+
// Some stream implementations throw synchronously from cancel().
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function decodeUtf8(chunks: readonly Uint8Array[]): string {
|
|
60
|
+
const decoder = new TextDecoder();
|
|
61
|
+
let text = "";
|
|
62
|
+
for (const chunk of chunks) text += decoder.decode(chunk, { stream: true });
|
|
63
|
+
// Flush an incomplete trailing UTF-8 sequence deterministically.
|
|
64
|
+
text += decoder.decode();
|
|
65
|
+
return text;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Consume the original response body under strict memory and time bounds.
|
|
70
|
+
*
|
|
71
|
+
* This deliberately calls `getReader()` on `response.body`: it never clones or
|
|
72
|
+
* tees the response. Once an over-limit byte is observed, all retained raw data
|
|
73
|
+
* is discarded so an untrusted prefix can never become a client-facing error.
|
|
74
|
+
*/
|
|
75
|
+
export async function readBoundedResponseBody(
|
|
76
|
+
response: Response,
|
|
77
|
+
options: BoundedBodyOptions = {},
|
|
78
|
+
): Promise<BoundedBodyResult> {
|
|
79
|
+
const signal = options.signal;
|
|
80
|
+
if (signal?.aborted) throw signal.reason;
|
|
81
|
+
|
|
82
|
+
const body = response.body;
|
|
83
|
+
if (!body) {
|
|
84
|
+
return {
|
|
85
|
+
text: "",
|
|
86
|
+
truncated: false,
|
|
87
|
+
timedOut: false,
|
|
88
|
+
totalTimedOut: false,
|
|
89
|
+
inactivityTimedOut: false,
|
|
90
|
+
oversized: false,
|
|
91
|
+
displaySafe: true,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const reader = body.getReader();
|
|
96
|
+
const chunks: Uint8Array[] = [];
|
|
97
|
+
let retainedBytes = 0;
|
|
98
|
+
let mustCancel = false;
|
|
99
|
+
let cancelReason: unknown;
|
|
100
|
+
const total = timeoutPromise(options.totalTimeoutMs ?? BOUNDED_BODY_TIMEOUT_MS, TOTAL_TIMEOUT);
|
|
101
|
+
let inactivity = timeoutPromise(
|
|
102
|
+
options.inactivityTimeoutMs ?? BOUNDED_BODY_TIMEOUT_MS,
|
|
103
|
+
INACTIVITY_TIMEOUT,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
let rejectForAbort: ((reason: unknown) => void) | undefined;
|
|
107
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
108
|
+
rejectForAbort = reject;
|
|
109
|
+
});
|
|
110
|
+
const onAbort = () => rejectForAbort?.(signal?.reason);
|
|
111
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
112
|
+
// Close the narrow race between the preflight check and listener install.
|
|
113
|
+
if (signal?.aborted) onAbort();
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
while (true) {
|
|
117
|
+
// Attach a rejection handler before racing. If a deadline wins and
|
|
118
|
+
// cancellation later rejects this read, it remains observed.
|
|
119
|
+
const read = reader.read();
|
|
120
|
+
void read.catch(() => undefined);
|
|
121
|
+
const outcome = await Promise.race([read, total.promise, inactivity.promise, aborted]);
|
|
122
|
+
// Cancellation owns the body lifetime even when EOF/readability settles in
|
|
123
|
+
// the same turn. Promise.race otherwise lets array order hide the abort.
|
|
124
|
+
if (signal?.aborted) {
|
|
125
|
+
mustCancel = true;
|
|
126
|
+
cancelReason = signal.reason;
|
|
127
|
+
throw signal.reason;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (outcome === TOTAL_TIMEOUT || outcome === INACTIVITY_TIMEOUT) {
|
|
131
|
+
mustCancel = true;
|
|
132
|
+
cancelReason = new DOMException(
|
|
133
|
+
outcome === TOTAL_TIMEOUT ? "Error body total timeout" : "Error body inactivity timeout",
|
|
134
|
+
"TimeoutError",
|
|
135
|
+
);
|
|
136
|
+
return {
|
|
137
|
+
text: decodeUtf8(chunks),
|
|
138
|
+
truncated: true,
|
|
139
|
+
timedOut: true,
|
|
140
|
+
totalTimedOut: outcome === TOTAL_TIMEOUT,
|
|
141
|
+
inactivityTimedOut: outcome === INACTIVITY_TIMEOUT,
|
|
142
|
+
oversized: false,
|
|
143
|
+
displaySafe: false,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const { value, done } = outcome as ReadableStreamReadResult<Uint8Array>;
|
|
148
|
+
if (done) {
|
|
149
|
+
return {
|
|
150
|
+
text: decodeUtf8(chunks),
|
|
151
|
+
truncated: false,
|
|
152
|
+
timedOut: false,
|
|
153
|
+
totalTimedOut: false,
|
|
154
|
+
inactivityTimedOut: false,
|
|
155
|
+
oversized: false,
|
|
156
|
+
displaySafe: true,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!value || value.byteLength === 0) continue;
|
|
161
|
+
|
|
162
|
+
inactivity.clear();
|
|
163
|
+
inactivity = timeoutPromise(
|
|
164
|
+
options.inactivityTimeoutMs ?? BOUNDED_BODY_TIMEOUT_MS,
|
|
165
|
+
INACTIVITY_TIMEOUT,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
if (value.byteLength > BOUNDED_BODY_MAX_BYTES - retainedBytes) {
|
|
169
|
+
mustCancel = true;
|
|
170
|
+
cancelReason = new DOMException("Error body size limit reached", "QuotaExceededError");
|
|
171
|
+
chunks.length = 0;
|
|
172
|
+
retainedBytes = 0;
|
|
173
|
+
return {
|
|
174
|
+
text: "",
|
|
175
|
+
truncated: true,
|
|
176
|
+
timedOut: false,
|
|
177
|
+
totalTimedOut: false,
|
|
178
|
+
inactivityTimedOut: false,
|
|
179
|
+
oversized: true,
|
|
180
|
+
displaySafe: false,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
chunks.push(value);
|
|
185
|
+
retainedBytes += value.byteLength;
|
|
186
|
+
}
|
|
187
|
+
} catch (error) {
|
|
188
|
+
mustCancel = true;
|
|
189
|
+
cancelReason = error;
|
|
190
|
+
throw error;
|
|
191
|
+
} finally {
|
|
192
|
+
total.clear();
|
|
193
|
+
inactivity.clear();
|
|
194
|
+
signal?.removeEventListener("abort", onAbort);
|
|
195
|
+
if (mustCancel) cancelWithoutWaiting(reader, cancelReason);
|
|
196
|
+
try {
|
|
197
|
+
reader.releaseLock();
|
|
198
|
+
} catch {
|
|
199
|
+
// A pending read can keep the lock briefly while cancel settles.
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -348,11 +348,13 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
348
348
|
"kimi-k2.7-code-highspeed": [],
|
|
349
349
|
...Object.fromEntries(OPENCODE_GO_THINKING_TOGGLE_MODELS.map(id => [id, THINKING_TOGGLE_EFFORTS])),
|
|
350
350
|
...Object.fromEntries(OPENCODE_GO_THINKING_BUDGET_MODELS.map(id => [id, THINKING_BUDGET_EFFORTS])),
|
|
351
|
+
...Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, DEEPSEEK_THINKING_EFFORTS])),
|
|
351
352
|
},
|
|
352
353
|
// glm-5.2 uses identity labels now that `max` is a native Codex level (no alias map);
|
|
353
354
|
// the thinking-toggle map is a REAL wire alias (effort -> enabled/disabled) and stays.
|
|
354
355
|
modelReasoningEffortMap: {
|
|
355
356
|
...Object.fromEntries(OPENCODE_GO_THINKING_TOGGLE_MODELS.map(id => [id, THINKING_TOGGLE_MAP])),
|
|
357
|
+
...Object.fromEntries(DEEPSEEK_THINKING_MODELS.map(id => [id, DEEPSEEK_THINKING_REASONING_MAP])),
|
|
356
358
|
},
|
|
357
359
|
thinkingToggleModels: OPENCODE_GO_THINKING_TOGGLE_MODELS,
|
|
358
360
|
thinkingBudgetModels: THINKING_BUDGET_MODELS,
|
|
@@ -371,7 +373,8 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
371
373
|
noTopPModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"],
|
|
372
374
|
noPenaltyModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"],
|
|
373
375
|
autoToolChoiceOnlyModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"],
|
|
374
|
-
|
|
376
|
+
// Issue #78: DeepSeek V4 thinking mode requires reasoning_content replay on tool-call turns.
|
|
377
|
+
preserveReasoningContentModels: ["glm-5.2", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", ...DEEPSEEK_THINKING_MODELS],
|
|
375
378
|
},
|
|
376
379
|
{
|
|
377
380
|
id: "neuralwatt",
|
package/src/server/auth-cors.ts
CHANGED
|
@@ -117,31 +117,34 @@ export function assertServerAuthConfig(config: OcxConfig): void {
|
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const actual =
|
|
123
|
-
|| req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
|
|
120
|
+
/** Whether `token` is one of the proxy's own admission secrets (env token or config API keys). */
|
|
121
|
+
export function isProxyAdmissionSecret(token: string, config: OcxConfig): boolean {
|
|
122
|
+
const actual = token.trim();
|
|
124
123
|
if (!actual) return false;
|
|
124
|
+
const enc = new TextEncoder();
|
|
125
|
+
const actualBytes = enc.encode(actual);
|
|
125
126
|
// Check env-based token
|
|
126
127
|
const expected = configuredApiAuthToken(config);
|
|
127
128
|
if (expected) {
|
|
128
|
-
const enc = new TextEncoder();
|
|
129
129
|
const expectedBytes = enc.encode(expected);
|
|
130
|
-
const actualBytes = enc.encode(actual);
|
|
131
130
|
if (expectedBytes.length === actualBytes.length && timingSafeEqual(actualBytes, expectedBytes)) return true;
|
|
132
131
|
}
|
|
133
132
|
// Check config-based API keys
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
for (const k of config.apiKeys) {
|
|
138
|
-
const keyBytes = enc.encode(k.key);
|
|
139
|
-
if (keyBytes.length === actualBytes.length && timingSafeEqual(actualBytes, keyBytes)) return true;
|
|
140
|
-
}
|
|
133
|
+
for (const k of config.apiKeys ?? []) {
|
|
134
|
+
const keyBytes = enc.encode(k.key);
|
|
135
|
+
if (keyBytes.length === actualBytes.length && timingSafeEqual(actualBytes, keyBytes)) return true;
|
|
141
136
|
}
|
|
142
137
|
return false;
|
|
143
138
|
}
|
|
144
139
|
|
|
140
|
+
export function hasValidApiAuth(req: Request, config: OcxConfig): boolean {
|
|
141
|
+
if (!isApiAuthRequired(config)) return true;
|
|
142
|
+
const actual = req.headers.get("x-opencodex-api-key")?.trim()
|
|
143
|
+
|| req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
|
|
144
|
+
if (!actual) return false;
|
|
145
|
+
return isProxyAdmissionSecret(actual, config);
|
|
146
|
+
}
|
|
147
|
+
|
|
145
148
|
export function requireApiAuth(req: Request, config: OcxConfig, kind: "management" | "data-plane"): Response | null {
|
|
146
149
|
if (hasValidApiAuth(req, config)) return null;
|
|
147
150
|
if (kind === "management") return jsonResponse({ error: "opencodex API key required" }, 401);
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /v1/images/{generations,edits} relay (issue #83).
|
|
3
|
+
*
|
|
4
|
+
* codex-rs's standalone image_gen extension executes CLIENT-SIDE: it POSTs
|
|
5
|
+
* `{base_url}/images/generations` (edits when reference images are attached) with the same
|
|
6
|
+
* ChatGPT bearer auth it uses for chat. Under Design B injection base_url IS this proxy, so
|
|
7
|
+
* without a route the tool died on the /v1/* JSON-404 guard. Only an OpenAI-family upstream
|
|
8
|
+
* can serve these endpoints — routed providers (Cursor, Kiro, Gemini, …) have no image
|
|
9
|
+
* generation surface — so the handler relays the body verbatim to the ChatGPT forward
|
|
10
|
+
* provider (or an OpenAI API-key provider) and passes the response through untouched:
|
|
11
|
+
* codex's images client parses `{created, data:[{b64_json}]}` strictly and Debug-prints
|
|
12
|
+
* error bodies into the model-visible failure, so upstream errors must stay legible.
|
|
13
|
+
*/
|
|
14
|
+
import { formatErrorResponse } from "../bridge";
|
|
15
|
+
import {
|
|
16
|
+
CodexAccountCooldownError,
|
|
17
|
+
CodexAuthContextError,
|
|
18
|
+
CodexThreadAffinityExpiredError,
|
|
19
|
+
headersForCodexAuthContext,
|
|
20
|
+
isCodexAuthContextUsable,
|
|
21
|
+
resolveCodexAuthContext,
|
|
22
|
+
} from "../codex/auth-context";
|
|
23
|
+
import { formatCodexProviderForLog } from "../codex/routing";
|
|
24
|
+
import { resolveEnvValue } from "../config";
|
|
25
|
+
import { signalWithTimeout } from "../lib/abort";
|
|
26
|
+
import { sidecarEnter } from "../lib/sidecar-tracker";
|
|
27
|
+
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
28
|
+
import { isProxyAdmissionSecret } from "./auth-cors";
|
|
29
|
+
import { readJsonRequestBody } from "./request-decompress";
|
|
30
|
+
import type { RequestLogContext } from "./request-log";
|
|
31
|
+
import { codexLogAccountId, decodeRequestErrorResponse, sidecarOutcomeRecorder } from "./responses";
|
|
32
|
+
|
|
33
|
+
export type ImagesEndpoint = "generations" | "edits";
|
|
34
|
+
|
|
35
|
+
/** Image generation is slow (tens of seconds); bound a hung upstream, not a working one. */
|
|
36
|
+
const IMAGES_UPSTREAM_TIMEOUT_MS = 300_000;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Cap for the buffered upstream response body (100 MiB). Images responses are JSON documents
|
|
40
|
+
* containing base64-encoded images — typically a few MB. This prevents an oversized or malicious
|
|
41
|
+
* response from exhausting process memory.
|
|
42
|
+
*/
|
|
43
|
+
const IMAGES_RESPONSE_MAX_BYTES = 100 * 1024 * 1024;
|
|
44
|
+
|
|
45
|
+
interface NamedProvider {
|
|
46
|
+
name: string;
|
|
47
|
+
provider: OcxProviderConfig;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface ImagesUpstreamCandidates {
|
|
51
|
+
/** ChatGPT passthrough — the backend codex itself would have called absent the base_url override. */
|
|
52
|
+
forward?: NamedProvider;
|
|
53
|
+
/** Keyed openai-responses provider (e.g. api.openai.com), whose /v1/images/* is the platform Images API. */
|
|
54
|
+
keyed?: NamedProvider & { apiKey: string };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Collect the upstreams that can serve /images/*. The forward provider is preferred (same
|
|
59
|
+
* precedence as the vision/web-search sidecars) but only usable when the request actually
|
|
60
|
+
* carries relayable ChatGPT auth — startServer auto-upserts a `chatgpt` forward entry into
|
|
61
|
+
* every config, so its mere presence proves nothing about credentials.
|
|
62
|
+
*/
|
|
63
|
+
function findImagesUpstreams(config: OcxConfig): ImagesUpstreamCandidates {
|
|
64
|
+
const candidates: ImagesUpstreamCandidates = {};
|
|
65
|
+
for (const [name, provider] of Object.entries(config.providers)) {
|
|
66
|
+
if (provider.disabled === true) continue;
|
|
67
|
+
if (provider.authMode === "forward") {
|
|
68
|
+
candidates.forward ??= { name, provider };
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (candidates.keyed || provider.adapter !== "openai-responses" || provider.authMode === "oauth") continue;
|
|
72
|
+
const apiKey = resolveEnvValue(provider.apiKey);
|
|
73
|
+
if (apiKey) candidates.keyed = { name, provider, apiKey };
|
|
74
|
+
}
|
|
75
|
+
return candidates;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function handleImages(
|
|
79
|
+
req: Request,
|
|
80
|
+
config: OcxConfig,
|
|
81
|
+
endpoint: ImagesEndpoint,
|
|
82
|
+
logCtx: RequestLogContext,
|
|
83
|
+
): Promise<Response> {
|
|
84
|
+
let body: unknown;
|
|
85
|
+
try {
|
|
86
|
+
body = await readJsonRequestBody(req);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
return decodeRequestErrorResponse(err, "images");
|
|
89
|
+
}
|
|
90
|
+
const model = (body as { model?: unknown } | null)?.model;
|
|
91
|
+
if (typeof model === "string" && model) logCtx.model = model;
|
|
92
|
+
|
|
93
|
+
const candidates = findImagesUpstreams(config);
|
|
94
|
+
if (!candidates.forward && !candidates.keyed) {
|
|
95
|
+
// 400, not 5xx: codex retries every 5xx up to 5 total attempts, and this is a permanent
|
|
96
|
+
// configuration state that must surface on the first attempt.
|
|
97
|
+
return formatErrorResponse(
|
|
98
|
+
400,
|
|
99
|
+
"invalid_request_error",
|
|
100
|
+
"Built-in image generation needs an OpenAI upstream (ChatGPT login or an OpenAI API-key provider), "
|
|
101
|
+
+ "but none is configured in opencodex. Routed providers cannot serve /v1/images/* — "
|
|
102
|
+
+ "add an OpenAI provider or disable the tool with `codex features disable image_generation`.",
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Resolve forward auth first; failures are captured, not returned, so a configured keyed
|
|
107
|
+
// provider can still serve the request (e.g. every pool account cooling down must not
|
|
108
|
+
// 429 image_gen while api.openai.com sits idle).
|
|
109
|
+
let forwardAuthHeaders: Headers | undefined;
|
|
110
|
+
let forwardAuthError: Response | undefined;
|
|
111
|
+
let recordOutcome: ReturnType<typeof sidecarOutcomeRecorder>;
|
|
112
|
+
if (candidates.forward) {
|
|
113
|
+
try {
|
|
114
|
+
const authCtx = await resolveCodexAuthContext(req.headers, config);
|
|
115
|
+
if (!isCodexAuthContextUsable(authCtx, config)) {
|
|
116
|
+
forwardAuthError = formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication");
|
|
117
|
+
} else {
|
|
118
|
+
// Forwarded caller auth, overridden by the routed pool account's token when one is selected.
|
|
119
|
+
const authHeaders = headersForCodexAuthContext(req.headers, authCtx);
|
|
120
|
+
const bearer = authHeaders.get("authorization")?.replace(/^Bearer\s+/i, "") ?? "";
|
|
121
|
+
// A caller may authenticate to the proxy itself with `Authorization: Bearer <admission
|
|
122
|
+
// token>` (non-loopback binds); that secret must never be relayed to chatgpt.com.
|
|
123
|
+
if (bearer && isProxyAdmissionSecret(bearer, config)) authHeaders.delete("authorization");
|
|
124
|
+
// Only relay through the ChatGPT backend when there is a bearer to relay: startServer
|
|
125
|
+
// auto-upserts the `chatgpt` provider, so an unauthenticated request must not be bounced
|
|
126
|
+
// off chatgpt.com when a keyed OpenAI provider (or an honest error) serves it better.
|
|
127
|
+
if (authHeaders.get("authorization")) {
|
|
128
|
+
forwardAuthHeaders = authHeaders;
|
|
129
|
+
recordOutcome = sidecarOutcomeRecorder(config, authCtx);
|
|
130
|
+
logCtx.provider = formatCodexProviderForLog(candidates.forward.name, codexLogAccountId(authCtx), config);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
} catch (err) {
|
|
134
|
+
if (err instanceof CodexAccountCooldownError) {
|
|
135
|
+
forwardAuthError = formatErrorResponse(429, "rate_limit_error", "Selected Codex account is cooling down");
|
|
136
|
+
} else if (err instanceof CodexThreadAffinityExpiredError) {
|
|
137
|
+
forwardAuthError = formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session");
|
|
138
|
+
} else if (err instanceof CodexAuthContextError) {
|
|
139
|
+
const safeAccountLabel = formatCodexProviderForLog(candidates.forward.name, err.accountId, config);
|
|
140
|
+
console.error(`[images] Pool account ${safeAccountLabel} token failed; reauthentication required`);
|
|
141
|
+
forwardAuthError = formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication");
|
|
142
|
+
} else {
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
149
|
+
let url: string;
|
|
150
|
+
if (forwardAuthHeaders && candidates.forward) {
|
|
151
|
+
const { provider } = candidates.forward;
|
|
152
|
+
if (provider.headers) Object.assign(headers, provider.headers);
|
|
153
|
+
for (const [name, value] of forwardAuthHeaders) headers[name] = value;
|
|
154
|
+
// The ChatGPT codex backend takes bare paths (matches the adapter's `${baseUrl}/responses`).
|
|
155
|
+
url = `${provider.baseUrl}/images/${endpoint}`;
|
|
156
|
+
} else if (candidates.keyed) {
|
|
157
|
+
const { provider, apiKey, name } = candidates.keyed;
|
|
158
|
+
if (provider.headers) Object.assign(headers, provider.headers);
|
|
159
|
+
headers["authorization"] = `Bearer ${apiKey}`;
|
|
160
|
+
logCtx.provider = name;
|
|
161
|
+
// Keyed providers tolerate baseUrl with or without /v1 (mirrors openai-responses.ts).
|
|
162
|
+
url = `${provider.baseUrl.replace(/\/v1\/?$/, "")}/v1/images/${endpoint}`;
|
|
163
|
+
} else if (forwardAuthError) {
|
|
164
|
+
return forwardAuthError;
|
|
165
|
+
} else {
|
|
166
|
+
return formatErrorResponse(
|
|
167
|
+
401,
|
|
168
|
+
"authentication_error",
|
|
169
|
+
"image generation relay needs ChatGPT auth (Authorization header) or an OpenAI API-key provider",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const timeoutMs = config.images?.timeoutMs ?? IMAGES_UPSTREAM_TIMEOUT_MS;
|
|
174
|
+
const linkedSignal = signalWithTimeout(timeoutMs, req.signal);
|
|
175
|
+
const sidecarExit = sidecarEnter("images");
|
|
176
|
+
try {
|
|
177
|
+
// Images POSTs create paid, non-idempotent work. One fetch only: no reset retry without a
|
|
178
|
+
// source-proven idempotency contract.
|
|
179
|
+
const upstreamResponse = await fetch(url, {
|
|
180
|
+
method: "POST",
|
|
181
|
+
headers,
|
|
182
|
+
body: JSON.stringify(body),
|
|
183
|
+
signal: linkedSignal.signal,
|
|
184
|
+
});
|
|
185
|
+
// Buffer rather than stream: the payload is one JSON document (base64 image, typically a few
|
|
186
|
+
// MB), and buffering keeps the timeout window covering the whole exchange. Cap the size to
|
|
187
|
+
// prevent an oversized response from exhausting process memory.
|
|
188
|
+
const payload = await upstreamResponse.arrayBuffer();
|
|
189
|
+
if (payload.byteLength > IMAGES_RESPONSE_MAX_BYTES) {
|
|
190
|
+
return formatErrorResponse(502, "upstream_error", `image ${endpoint} response too large (${payload.byteLength} bytes)`);
|
|
191
|
+
}
|
|
192
|
+
recordOutcome?.(upstreamResponse.status);
|
|
193
|
+
const relayHeaders: Record<string, string> = {};
|
|
194
|
+
const contentType = upstreamResponse.headers.get("content-type");
|
|
195
|
+
if (contentType) relayHeaders["content-type"] = contentType;
|
|
196
|
+
return new Response(payload, { status: upstreamResponse.status, headers: relayHeaders });
|
|
197
|
+
} catch (err) {
|
|
198
|
+
// Client cancel first: it aborts the linked signal too, and must not be logged as an
|
|
199
|
+
// upstream failure (499 maps to client_closed_request in the request log).
|
|
200
|
+
if (req.signal.aborted) {
|
|
201
|
+
return formatErrorResponse(499, "client_closed_request", `image ${endpoint} request canceled by client`);
|
|
202
|
+
}
|
|
203
|
+
if (err instanceof Error && err.name === "TimeoutError") {
|
|
204
|
+
recordOutcome?.("timeout");
|
|
205
|
+
// codex retries 5xx up to 4 more times; a retried 504 is acceptable for a transient hang.
|
|
206
|
+
return formatErrorResponse(504, "upstream_error", `image ${endpoint} upstream timed out`);
|
|
207
|
+
}
|
|
208
|
+
recordOutcome?.("connect_error");
|
|
209
|
+
return formatErrorResponse(
|
|
210
|
+
502,
|
|
211
|
+
"upstream_error",
|
|
212
|
+
`image ${endpoint} relay failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
213
|
+
);
|
|
214
|
+
} finally {
|
|
215
|
+
sidecarExit();
|
|
216
|
+
linkedSignal.cleanup();
|
|
217
|
+
}
|
|
218
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -59,6 +59,7 @@ export {
|
|
|
59
59
|
} from "./lifecycle";
|
|
60
60
|
import {
|
|
61
61
|
addFinalRequestLog,
|
|
62
|
+
httpStatusForRequestLogTerminal,
|
|
62
63
|
httpStatusForTerminalStatus,
|
|
63
64
|
inspectResponseLogSsePayload,
|
|
64
65
|
nextRequestLogId,
|
|
@@ -116,6 +117,7 @@ export {
|
|
|
116
117
|
} from "./auth-cors";
|
|
117
118
|
import { disableResponsesRequestTimeout, handleResponses, handleResponsesCompact } from "./responses";
|
|
118
119
|
export { disableResponsesRequestTimeout, linkAbortSignal } from "./responses";
|
|
120
|
+
import { handleImages } from "./images";
|
|
119
121
|
import { fetchAllModels, handleManagementAPI, VERSION } from "./management-api";
|
|
120
122
|
|
|
121
123
|
const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
|
|
@@ -304,6 +306,31 @@ export function startServer(port?: number) {
|
|
|
304
306
|
return withCors(await handleResponsesCompact(req, config), req, config);
|
|
305
307
|
}
|
|
306
308
|
|
|
309
|
+
if (
|
|
310
|
+
req.method === "POST"
|
|
311
|
+
&& (url.pathname === "/v1/images/generations" || url.pathname === "/v1/images/edits")
|
|
312
|
+
) {
|
|
313
|
+
disableResponsesRequestTimeout(req, requestServer);
|
|
314
|
+
if (isDraining()) {
|
|
315
|
+
return new Response("Service shutting down", {
|
|
316
|
+
status: 503,
|
|
317
|
+
headers: { ...corsHeaders(req, config), "Retry-After": "5" },
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
const apiAuthError = requireApiAuth(req, config, "data-plane");
|
|
321
|
+
if (apiAuthError) return withCors(apiAuthError, req, config);
|
|
322
|
+
if (!isAllowedRequestOrigin(req, config)) {
|
|
323
|
+
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
|
|
324
|
+
}
|
|
325
|
+
const start = Date.now();
|
|
326
|
+
const requestId = nextRequestLogId(start);
|
|
327
|
+
const logCtx: RequestLogContext = { model: "image_gen", provider: "unknown" };
|
|
328
|
+
const endpoint = url.pathname.endsWith("/edits") ? "edits" as const : "generations" as const;
|
|
329
|
+
const response = await handleImages(req, config, endpoint, logCtx);
|
|
330
|
+
addFinalRequestLog(requestId, start, logCtx, response.status, response.status === 499 ? { closeReason: "client_cancel" } : undefined);
|
|
331
|
+
return withCors(response, req, config);
|
|
332
|
+
}
|
|
333
|
+
|
|
307
334
|
if (url.pathname === "/v1/responses" && req.method === "POST") {
|
|
308
335
|
disableResponsesRequestTimeout(req, requestServer);
|
|
309
336
|
if (isDraining()) {
|
|
@@ -343,7 +370,7 @@ export function startServer(port?: number) {
|
|
|
343
370
|
|
|
344
371
|
// Data-plane guard: unknown /v1/* paths must fail with JSON 404, never fall through to the
|
|
345
372
|
// GUI static handler (extensionless paths would get index.html with HTTP 200 and codex-rs
|
|
346
|
-
// endpoint clients — alpha/search,
|
|
373
|
+
// endpoint clients — alpha/search, memories/*, realtime/* — would surface confusing
|
|
347
374
|
// serde decode errors instead of a clean not-found).
|
|
348
375
|
if (url.pathname.startsWith("/v1/")) {
|
|
349
376
|
return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config);
|
|
@@ -485,7 +512,7 @@ export function startServer(port?: number) {
|
|
|
485
512
|
onSsePayload: payload => inspectResponseLogSsePayload(logCtx, payload),
|
|
486
513
|
onTerminal: status => {
|
|
487
514
|
terminalRecorder?.(status);
|
|
488
|
-
finalizeLog(
|
|
515
|
+
finalizeLog(httpStatusForRequestLogTerminal(status, logCtx), {
|
|
489
516
|
terminalStatus: status,
|
|
490
517
|
closeReason: "terminal",
|
|
491
518
|
});
|
package/src/server/responses.ts
CHANGED
|
@@ -374,7 +374,7 @@ export function codexForwardTerminalOutcomeRecorder(
|
|
|
374
374
|
* zstd-compressed screenshot history exceeds the limit), or a genuine JSON syntax error (400). The
|
|
375
375
|
* real decode error was previously swallowed, so log it before returning the generic 400.
|
|
376
376
|
*/
|
|
377
|
-
function decodeRequestErrorResponse(err: unknown, label: string): Response {
|
|
377
|
+
export function decodeRequestErrorResponse(err: unknown, label: string): Response {
|
|
378
378
|
if (err instanceof UnsupportedContentEncodingError) {
|
|
379
379
|
return formatErrorResponse(415, "invalid_request_error", err.message);
|
|
380
380
|
}
|
|
@@ -810,6 +810,7 @@ export async function handleResponses(
|
|
|
810
810
|
abortSignal: options.abortSignal,
|
|
811
811
|
recordSidecarOutcome,
|
|
812
812
|
connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
|
|
813
|
+
routedModelStallTimeoutMs: wsPlan.routedModelStallTimeoutMs,
|
|
813
814
|
stallTimeoutSec: wsPlan.stallTimeoutSec,
|
|
814
815
|
on429: retryAfter => {
|
|
815
816
|
const rotated = rotateKeyOn429(config, route.providerName, retryAfter, Date.now(), route.provider.apiKey);
|
package/src/types.ts
CHANGED
|
@@ -312,6 +312,8 @@ export interface OcxConfig {
|
|
|
312
312
|
webSearchSidecar?: OcxWebSearchSidecarConfig;
|
|
313
313
|
/** Vision sidecar: describe images via a gpt vision model so text-only models can "see" them. */
|
|
314
314
|
visionSidecar?: OcxVisionSidecarConfig;
|
|
315
|
+
/** /v1/images relay for codex's built-in image_gen tool. */
|
|
316
|
+
images?: OcxImagesConfig;
|
|
315
317
|
/** Codex multi-account pool. */
|
|
316
318
|
codexAccounts?: CodexAccount[];
|
|
317
319
|
/** Active pool account id for next session. undefined = main (passthrough as-is). */
|
|
@@ -357,6 +359,11 @@ export interface OcxTokenGuardianConfig {
|
|
|
357
359
|
codexWarmupModel?: string;
|
|
358
360
|
}
|
|
359
361
|
|
|
362
|
+
export interface OcxImagesConfig {
|
|
363
|
+
/** Upstream timeout (ms) for one /v1/images relay. Default 300000 — generation is slow. */
|
|
364
|
+
timeoutMs?: number;
|
|
365
|
+
}
|
|
366
|
+
|
|
360
367
|
export interface OcxVisionSidecarConfig {
|
|
361
368
|
/** Master switch. Default: enabled when a forward (ChatGPT) provider exists and the caller is logged in. */
|
|
362
369
|
enabled?: boolean;
|
|
@@ -377,6 +384,11 @@ export interface OcxWebSearchSidecarConfig {
|
|
|
377
384
|
maxSearchesPerTurn?: number;
|
|
378
385
|
/** Sidecar fetch timeout (ms). */
|
|
379
386
|
timeoutMs?: number;
|
|
387
|
+
/**
|
|
388
|
+
* Config-file-only deadline (ms) for continuous routed-model response-body raw-byte inactivity
|
|
389
|
+
* during a web-search turn. Default 200000. Must be an integer from 1 through 2147483647.
|
|
390
|
+
*/
|
|
391
|
+
routedModelStallTimeoutMs?: number;
|
|
380
392
|
}
|
|
381
393
|
|
|
382
394
|
export interface OcxProviderConfig {
|