@bitkyc08/opencodex 2.7.40 → 2.7.41
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 +63 -12
- package/assets/claude-code-models.gif +0 -0
- package/gui/dist/assets/index-B2J4t3te.css +1 -0
- package/gui/dist/assets/index-BmvM6wRb.js +65 -0
- package/gui/dist/index.html +2 -2
- package/package.json +3 -2
- package/src/adapters/google.ts +48 -9
- package/src/adapters/kiro-events.ts +15 -3
- package/src/adapters/kiro.ts +292 -28
- package/src/adapters/openai-chat.ts +63 -15
- package/src/adapters/openai-responses.ts +18 -0
- package/src/bridge.ts +76 -21
- package/src/chat/outbound.ts +66 -34
- package/src/claude/auth-detect.ts +229 -0
- package/src/claude/auth-mode-migration.ts +32 -0
- package/src/claude/auth-mode.ts +62 -0
- package/src/claude/desktop-3p-guard.ts +35 -0
- package/src/claude/desktop-3p.ts +121 -21
- package/src/claude/desktop-health.ts +26 -0
- package/src/claude/desktop-profile.ts +263 -0
- package/src/claude/inbound-debug.ts +4 -0
- package/src/claude/model-info.ts +9 -3
- package/src/cli/account-extended.ts +34 -0
- package/src/cli/account.ts +3 -1
- package/src/cli/claude-desktop.ts +152 -0
- package/src/cli/claude.ts +37 -3
- package/src/cli/doctor.ts +187 -6
- package/src/cli/help.ts +13 -1
- package/src/cli/index.ts +151 -57
- package/src/cli/status-oauth.ts +68 -0
- package/src/cli/status.ts +4 -0
- package/src/codex/account-lifecycle.ts +31 -0
- package/src/codex/auth-api.ts +133 -26
- package/src/codex/auth-collision.ts +55 -11
- package/src/codex/auth-context.ts +50 -6
- package/src/codex/catalog/provider-fetch.ts +6 -2
- package/src/codex/home.ts +61 -1
- package/src/codex/inject.ts +28 -66
- package/src/codex/injected-marker.ts +72 -0
- package/src/codex/journal.ts +39 -3
- package/src/codex/main-account-cache.ts +25 -0
- package/src/codex/model-cache.ts +20 -1
- package/src/codex/paths.ts +5 -0
- package/src/codex/routing.ts +138 -11
- package/src/codex/subagent-model-fallback.ts +455 -0
- package/src/codex/sync.ts +17 -0
- package/src/combos/failover.ts +10 -2
- package/src/combos/index.ts +1 -0
- package/src/combos/types.ts +9 -0
- package/src/config.ts +117 -0
- package/src/grok/inject.ts +339 -0
- package/src/grok/status.ts +88 -0
- package/src/grok/sync.ts +66 -0
- package/src/lib/destination-policy.ts +13 -0
- package/src/lib/errors.ts +59 -0
- package/src/lib/privacy.ts +9 -0
- package/src/lib/process-control.ts +57 -4
- package/src/oauth/health.ts +375 -0
- package/src/oauth/index.ts +69 -22
- package/src/oauth/kiro.ts +5 -3
- package/src/oauth/log.ts +48 -0
- package/src/oauth/store.ts +55 -6
- package/src/providers/alibaba-region-backup.ts +75 -0
- package/src/providers/alibaba-region-migration.ts +143 -0
- package/src/providers/alibaba-region-startup.ts +36 -0
- package/src/providers/api-keys.ts +5 -5
- package/src/providers/derive.ts +22 -1
- package/src/providers/free-directory.ts +181 -0
- package/src/providers/key-failover.ts +2 -2
- package/src/providers/kiro-models.ts +3 -2
- package/src/providers/openai-tiers.ts +1 -1
- package/src/providers/provider-id-rewrite.ts +150 -0
- package/src/providers/registry.ts +49 -2
- package/src/responses/parser.ts +49 -20
- package/src/responses/state.ts +156 -2
- package/src/router.ts +73 -5
- package/src/server/chat-completions.ts +78 -22
- package/src/server/claude-messages.ts +8 -0
- package/src/server/images.ts +2 -1
- package/src/server/index.ts +50 -30
- package/src/server/live.ts +2 -1
- package/src/server/management/agent-settings-routes.ts +337 -18
- package/src/server/management/api-access.ts +141 -0
- package/src/server/management/combo-routes.ts +3 -3
- package/src/server/management/config-routes.ts +4 -4
- package/src/server/management/logs-usage-routes.ts +64 -4
- package/src/server/management/model-routes.ts +114 -8
- package/src/server/management/oauth-account-routes.ts +31 -5
- package/src/server/management/provider-routes.ts +42 -11
- package/src/server/management/shared.ts +81 -2
- package/src/server/management/system-routes.ts +6 -1
- package/src/server/management-api.ts +24 -7
- package/src/server/port-reclaim.ts +261 -0
- package/src/server/request-log.ts +11 -5
- package/src/server/responses/collaboration.ts +11 -6
- package/src/server/responses/compact.ts +2 -1
- package/src/server/responses/core.ts +358 -155
- package/src/server/responses/passthrough-error.ts +53 -0
- package/src/server/search.ts +2 -1
- package/src/server/system-env.ts +23 -6
- package/src/server/windows-tcp-drop.ts +174 -0
- package/src/service.ts +43 -3
- package/src/types.ts +74 -6
- package/src/update/index.ts +30 -19
- package/src/update/job.ts +30 -15
- package/src/usage/log.ts +158 -2
- package/src/usage/summary.ts +8 -4
- package/src/web-search/loop.ts +4 -1
- package/gui/dist/assets/index-CMip1DzF.css +0 -1
- package/gui/dist/assets/index-cydcmbzC.js +0 -52
package/src/responses/state.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
1
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, opendirSync, readFileSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { atomicWriteFile, getConfigDir } from "../config";
|
|
4
4
|
import type { OcxProviderContinuationState } from "../types";
|
|
@@ -14,6 +14,10 @@ const MAX_STORED_RESPONSE_BYTES = 64 * 1024 * 1024;
|
|
|
14
14
|
* carry base64 `input_image` data URLs, and one screenshot-heavy thread must not balloon the file. */
|
|
15
15
|
const SNAPSHOT_ENTRY_MAX_BYTES = 2 * 1024 * 1024;
|
|
16
16
|
const SNAPSHOT_TOTAL_MAX_BYTES = 24 * 1024 * 1024;
|
|
17
|
+
const STALE_TEMP_GRACE_MS = 15 * 60 * 1_000;
|
|
18
|
+
const STALE_TEMP_MAX_ENTRIES = 4_096;
|
|
19
|
+
const STALE_TEMP_MAX_CLEANUPS = 512;
|
|
20
|
+
const RESPONSE_STATE_TEMP_NAME = /^responses-state\.json\.ocx\.(\d+)\.(\d+)\.tmp$/;
|
|
17
21
|
|
|
18
22
|
interface StoredResponseState {
|
|
19
23
|
createdAt: number;
|
|
@@ -88,6 +92,111 @@ function snapshotPath(): string {
|
|
|
88
92
|
return join(getConfigDir(), "responses-state.json");
|
|
89
93
|
}
|
|
90
94
|
|
|
95
|
+
export interface ResponseStateTempRecoveryResult {
|
|
96
|
+
matched: number;
|
|
97
|
+
removed: number;
|
|
98
|
+
failed: number;
|
|
99
|
+
bytesRemoved: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface ResponseStateTempRecoveryIO {
|
|
103
|
+
now: () => number;
|
|
104
|
+
list: (dir: string) => Iterable<string>;
|
|
105
|
+
inspect: (path: string) => { isFile: boolean; mtimeMs: number; size: number };
|
|
106
|
+
isProcessAlive: (pid: number) => boolean;
|
|
107
|
+
unlink: (path: string) => void;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
type ResponseStateTempRecoveryOptions = Partial<ResponseStateTempRecoveryIO> & {
|
|
111
|
+
maxEntries?: number;
|
|
112
|
+
maxCleanups?: number;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
function processIsAlive(pid: number): boolean {
|
|
116
|
+
if (pid === process.pid) return true;
|
|
117
|
+
try {
|
|
118
|
+
process.kill(pid, 0);
|
|
119
|
+
return true;
|
|
120
|
+
} catch (error) {
|
|
121
|
+
// EPERM means the process exists but cannot be signalled. Unknown platform errors
|
|
122
|
+
// are also protected; cleanup should prefer a false negative over touching a live writer.
|
|
123
|
+
return (error as NodeJS.ErrnoException).code !== "ESRCH";
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const responseStateTempRecoveryIO: ResponseStateTempRecoveryIO = {
|
|
128
|
+
now: Date.now,
|
|
129
|
+
list: function* list(dir) {
|
|
130
|
+
const handle = opendirSync(dir);
|
|
131
|
+
try {
|
|
132
|
+
for (let entry = handle.readSync(); entry; entry = handle.readSync()) yield entry.name;
|
|
133
|
+
} finally {
|
|
134
|
+
handle.closeSync();
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
inspect: path => {
|
|
138
|
+
const stat = lstatSync(path);
|
|
139
|
+
return { isFile: stat.isFile() && !stat.isSymbolicLink(), mtimeMs: stat.mtimeMs, size: stat.size };
|
|
140
|
+
},
|
|
141
|
+
isProcessAlive: processIsAlive,
|
|
142
|
+
unlink: unlinkSync,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Recover only abandoned response-state atomic-write files. The exact basename,
|
|
147
|
+
* regular-file check, age gate, and PID liveness check protect unrelated/active files.
|
|
148
|
+
* Cleanup is capped and best-effort because continuation state is only a cache. Removal
|
|
149
|
+
* deliberately uses unlink only: path-based truncation could follow a replacement symlink.
|
|
150
|
+
*/
|
|
151
|
+
export function recoverStaleResponseStateTemps(
|
|
152
|
+
dir = getConfigDir(),
|
|
153
|
+
options: ResponseStateTempRecoveryOptions = {},
|
|
154
|
+
): ResponseStateTempRecoveryResult {
|
|
155
|
+
const { maxEntries = STALE_TEMP_MAX_ENTRIES, maxCleanups = STALE_TEMP_MAX_CLEANUPS, ...overrides } = options;
|
|
156
|
+
const io = { ...responseStateTempRecoveryIO, ...overrides };
|
|
157
|
+
const result: ResponseStateTempRecoveryResult = {
|
|
158
|
+
matched: 0,
|
|
159
|
+
removed: 0,
|
|
160
|
+
failed: 0,
|
|
161
|
+
bytesRemoved: 0,
|
|
162
|
+
};
|
|
163
|
+
let names: Iterable<string>;
|
|
164
|
+
try { names = io.list(dir); } catch { return result; }
|
|
165
|
+
let iterator: Iterator<string>;
|
|
166
|
+
try { iterator = names[Symbol.iterator](); } catch { return result; }
|
|
167
|
+
let scanned = 0;
|
|
168
|
+
for (;;) {
|
|
169
|
+
let next: IteratorResult<string>;
|
|
170
|
+
try { next = iterator.next(); } catch { return result; }
|
|
171
|
+
if (next.done) break;
|
|
172
|
+
const name = next.value;
|
|
173
|
+
scanned += 1;
|
|
174
|
+
if (scanned > maxEntries || result.removed + result.failed >= maxCleanups) break;
|
|
175
|
+
const match = RESPONSE_STATE_TEMP_NAME.exec(name);
|
|
176
|
+
if (!match) continue;
|
|
177
|
+
result.matched += 1;
|
|
178
|
+
const pid = Number(match[1]);
|
|
179
|
+
const sequence = Number(match[2]);
|
|
180
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || !Number.isSafeInteger(sequence) || sequence <= 0) continue;
|
|
181
|
+
const path = join(dir, name);
|
|
182
|
+
let file: ReturnType<ResponseStateTempRecoveryIO["inspect"]>;
|
|
183
|
+
try { file = io.inspect(path); } catch { continue; }
|
|
184
|
+
if (!file.isFile || io.now() - file.mtimeMs < STALE_TEMP_GRACE_MS) continue;
|
|
185
|
+
if (pid === process.pid || io.isProcessAlive(pid)) continue;
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
io.unlink(path);
|
|
189
|
+
result.removed += 1;
|
|
190
|
+
result.bytesRemoved += file.size;
|
|
191
|
+
} catch {
|
|
192
|
+
// Locked files remain for a later startup. Do not truncate by path: a same-user
|
|
193
|
+
// replacement could turn that fallback into an arbitrary symlink-target write.
|
|
194
|
+
result.failed += 1;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
|
|
91
200
|
/**
|
|
92
201
|
* Best-effort disk snapshot so previous_response_id chains survive a proxy restart (the
|
|
93
202
|
* dominant expansion-miss cause: an in-memory-only store dies with the process, and the next
|
|
@@ -98,8 +207,13 @@ function snapshotPath(): string {
|
|
|
98
207
|
function ensureLoaded(): void {
|
|
99
208
|
if (loaded) return;
|
|
100
209
|
loaded = true;
|
|
210
|
+
const path = snapshotPath();
|
|
211
|
+
try {
|
|
212
|
+
recoverStaleResponseStateTemps(dirname(path));
|
|
213
|
+
} catch {
|
|
214
|
+
/* best-effort cleanup only; snapshot loading must remain independent */
|
|
215
|
+
}
|
|
101
216
|
try {
|
|
102
|
-
const path = snapshotPath();
|
|
103
217
|
if (!existsSync(path)) return;
|
|
104
218
|
const raw = JSON.parse(readFileSync(path, "utf-8")) as { version?: unknown; states?: unknown };
|
|
105
219
|
if ((raw.version !== 1 && raw.version !== 2) || !Array.isArray(raw.states)) return;
|
|
@@ -241,6 +355,46 @@ export function previousResponseProviderState(responseId: string | undefined): O
|
|
|
241
355
|
return providers ? structuredClone(providers) : undefined;
|
|
242
356
|
}
|
|
243
357
|
|
|
358
|
+
export interface ResponseStateMetrics {
|
|
359
|
+
/** Number of retained continuation entries currently in RAM. */
|
|
360
|
+
count: number;
|
|
361
|
+
/** Sum of serialized item bytes across all entries (the running in-memory byte accounting).
|
|
362
|
+
* Because expanded previous_response_id chains share prefix item references, this is an UPPER
|
|
363
|
+
* bound on true heap (it re-counts shared history), never an under-count — safe as a
|
|
364
|
+
* memory-pressure signal. */
|
|
365
|
+
totalBytes: number;
|
|
366
|
+
/** Serialized item bytes of the single largest entry (a long chain's head, or an image-heavy turn). */
|
|
367
|
+
largestBytes: number;
|
|
368
|
+
/** Age of the oldest retained entry, in ms (0 when the store is empty). */
|
|
369
|
+
oldestAgeMs: number;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Observe-only snapshot of the in-RAM continuation store, surfaced via GET /api/system/memory.
|
|
374
|
+
* Additive and side-effect free — it does NOT lazy-load the disk snapshot, prune, or evict — so a
|
|
375
|
+
* diagnostics probe can sample it without perturbing request handling. `totalBytes` reads the
|
|
376
|
+
* running byte counter and `largestBytes` reads each entry's cached `sizeBytes`, so a probe never
|
|
377
|
+
* re-serializes the whole store (a large transient allocation that would fire exactly when memory
|
|
378
|
+
* is already under pressure). This is the seam for deciding whether RAM growth originates in this
|
|
379
|
+
* store (JS heap) or in the runtime allocator (native).
|
|
380
|
+
*/
|
|
381
|
+
export function responseStateMetrics(): ResponseStateMetrics {
|
|
382
|
+
const at = now();
|
|
383
|
+
let largestBytes = 0;
|
|
384
|
+
let oldestCreatedAt = at;
|
|
385
|
+
for (const state of states.values()) {
|
|
386
|
+
const bytes = state.sizeBytes ?? 0;
|
|
387
|
+
if (bytes > largestBytes) largestBytes = bytes;
|
|
388
|
+
if (state.createdAt < oldestCreatedAt) oldestCreatedAt = state.createdAt;
|
|
389
|
+
}
|
|
390
|
+
return {
|
|
391
|
+
count: states.size,
|
|
392
|
+
totalBytes: storedResponseBytes,
|
|
393
|
+
largestBytes,
|
|
394
|
+
oldestAgeMs: states.size > 0 ? at - oldestCreatedAt : 0,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
244
398
|
/**
|
|
245
399
|
* Cache completed output and max_output_tokens partial output for previous_response_id replay.
|
|
246
400
|
* Content-filtered incomplete and failed output are not authoritative replay history.
|
package/src/router.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "./types";
|
|
2
|
-
import {
|
|
2
|
+
import { preservesPhysicalComboProvider, tryPickComboModel, type ComboPick } from "./combos";
|
|
3
3
|
import { hasOwnProvider, resolveEnvValue } from "./config";
|
|
4
4
|
import { assertProviderDestinationAllowed } from "./lib/destination-policy";
|
|
5
|
+
import { redactSecretString, redactUrlForLog } from "./lib/redact";
|
|
5
6
|
import { PROVIDER_REGISTRY, providerCodexAccountMode } from "./providers/registry";
|
|
6
7
|
import { LEGACY_CHATGPT_PROVIDER_ID, LEGACY_OPENAI_MULTI_PROVIDER_ID, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "./providers/openai-tiers";
|
|
7
8
|
import { decodeRoutedModelId, encodeRoutedModelId } from "./providers/slug-codec";
|
|
@@ -117,6 +118,73 @@ function mergeStringArrayRecord(
|
|
|
117
118
|
return out;
|
|
118
119
|
}
|
|
119
120
|
|
|
121
|
+
/** Same endpoint modulo surrounding space and trailing slashes — matches `matchBaseUrlChoice`. */
|
|
122
|
+
function isSameEndpoint(a: string, b: string): boolean {
|
|
123
|
+
return a.trim().replace(/\/+$/, "") === b.trim().replace(/\/+$/, "");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Origin of a user-configured URL, with the path withheld.
|
|
128
|
+
*
|
|
129
|
+
* A configured `baseUrl` is user-controlled and its path may itself be the credential — an
|
|
130
|
+
* account-scoped route token such as `https://proxy.example/v1/8fK2mP7qR4nV6x` is opaque and
|
|
131
|
+
* high-entropy, so it matches none of the prefix patterns in `redactSecretString`. Pattern
|
|
132
|
+
* redaction cannot be trusted for this value, so no path segment is logged at all. `URL.origin`
|
|
133
|
+
* also excludes userinfo, query and fragment.
|
|
134
|
+
*
|
|
135
|
+
* `…/…` marks that a path was present without revealing it, so a reader can tell an origin-only
|
|
136
|
+
* config apart from one whose path was dropped.
|
|
137
|
+
*/
|
|
138
|
+
function configuredOriginForLog(url: string): string {
|
|
139
|
+
try {
|
|
140
|
+
const parsed = new URL(url.trim());
|
|
141
|
+
// "null" is what URL.origin yields for non-special schemes; treat it as unusable.
|
|
142
|
+
if (!parsed.origin || parsed.origin === "null") return "(unloggable URL)";
|
|
143
|
+
const hasPath = parsed.pathname !== "" && parsed.pathname !== "/";
|
|
144
|
+
return hasPath ? `${parsed.origin}/…` : parsed.origin;
|
|
145
|
+
} catch {
|
|
146
|
+
return "(unparseable URL)";
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// `routedProviderConfig` runs per request, so warn once per (provider, discarded, effective) triple.
|
|
151
|
+
// Keyed by the URLs too: editing config.json to a different wrong value warns again.
|
|
152
|
+
const discardedBaseUrlWarnings = new Set<string>();
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A pinned registry entry — non-template `baseUrl`, no `allowBaseUrlOverride` — outranks a saved
|
|
156
|
+
* `baseUrl`. Dropping it silently is a footgun: requests go to an endpoint the user never
|
|
157
|
+
* configured, and a wrong-region or wrong-account URL then surfaces only as a 401 with nothing
|
|
158
|
+
* pointing back at the discarded setting.
|
|
159
|
+
*
|
|
160
|
+
* Warns rather than throws. The effective route is exactly what it was before, so a hard error
|
|
161
|
+
* here would break configs that route fine today (a stale `baseUrl` left over from an earlier
|
|
162
|
+
* provider is harmless whenever it names the same endpoint the registry pins).
|
|
163
|
+
*/
|
|
164
|
+
function warnIfBaseUrlDiscarded(providerName: string, userBaseUrl: string, effectiveBaseUrl: string): void {
|
|
165
|
+
if (isSameEndpoint(userBaseUrl, effectiveBaseUrl)) return;
|
|
166
|
+
// Asymmetric on purpose. Past the guard above, `effectiveBaseUrl` is necessarily
|
|
167
|
+
// `registryEntry.baseUrl`: the caller passes the resolved URL, and whenever that resolution
|
|
168
|
+
// kept the user's value the two are equal and we have already returned. So the effective side
|
|
169
|
+
// is a constant from this repo's registry and safe to print in full — it is also the useful
|
|
170
|
+
// half, naming the endpoint requests will actually use. The configured side is untrusted.
|
|
171
|
+
const discarded = configuredOriginForLog(userBaseUrl);
|
|
172
|
+
const effective = redactSecretString(redactUrlForLog(effectiveBaseUrl));
|
|
173
|
+
// Key off the logged forms: no raw credential is retained for the process lifetime, and
|
|
174
|
+
// rotating a key embedded in the URL no longer re-warns about the same endpoint mismatch.
|
|
175
|
+
// Coarser than the raw URLs — two bad paths on one host warn once, which is the right grain.
|
|
176
|
+
const key = `${providerName} | ${discarded} | ${effective}`;
|
|
177
|
+
if (discardedBaseUrlWarnings.has(key)) return;
|
|
178
|
+
discardedBaseUrlWarnings.add(key);
|
|
179
|
+
console.warn(
|
|
180
|
+
// Routing is what this warning speaks for: an adapter may adjust the endpoint again
|
|
181
|
+
// downstream (kiro re-derives the region), so do not promise where the request lands.
|
|
182
|
+
`⚠️ config.json provider "${providerName}": configured baseUrl ${discarded} is ignored`
|
|
183
|
+
+ ` because this provider's endpoint is fixed at ${effective}. A URL saved for a different`
|
|
184
|
+
+ ` account or region is a common cause of 401s here — drop it, or use the provider whose endpoint matches.`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
120
188
|
function routedProviderConfig(providerName: string, provider: OcxProviderConfig): OcxProviderConfig {
|
|
121
189
|
const registryEntry = PROVIDER_REGISTRY.find(entry => entry.id === providerName);
|
|
122
190
|
if (!registryEntry) {
|
|
@@ -150,6 +218,7 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig)
|
|
|
150
218
|
const noPenaltyModels = mergeStringArray(registryEntry.noPenaltyModels, provider.noPenaltyModels);
|
|
151
219
|
const autoToolChoiceOnlyModels = mergeStringArray(registryEntry.autoToolChoiceOnlyModels, provider.autoToolChoiceOnlyModels);
|
|
152
220
|
const preserveReasoningContentModels = mergeStringArray(registryEntry.preserveReasoningContentModels, provider.preserveReasoningContentModels);
|
|
221
|
+
const reasoningSplitModels = mergeStringArray(registryEntry.reasoningSplitModels, provider.reasoningSplitModels);
|
|
153
222
|
const thinkingToggleModels = mergeStringArray(registryEntry.thinkingToggleModels, provider.thinkingToggleModels);
|
|
154
223
|
const thinkingBudgetModels = mergeStringArray(registryEntry.thinkingBudgetModels, provider.thinkingBudgetModels);
|
|
155
224
|
const registryBaseUrlIsTemplate = /\{[^}]*\}/.test(registryEntry.baseUrl);
|
|
@@ -162,6 +231,7 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig)
|
|
|
162
231
|
const baseUrl = (registryBaseUrlIsTemplate || registryEntry.allowBaseUrlOverride) && userBaseUrlIsResolved
|
|
163
232
|
? userBaseUrl
|
|
164
233
|
: registryEntry.baseUrl;
|
|
234
|
+
if (userBaseUrlIsResolved) warnIfBaseUrlDiscarded(providerName, userBaseUrl, baseUrl);
|
|
165
235
|
assertProviderDestinationAllowed(providerName, { baseUrl, allowPrivateNetwork: provider.allowPrivateNetwork });
|
|
166
236
|
|
|
167
237
|
return {
|
|
@@ -203,6 +273,7 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig)
|
|
|
203
273
|
...(noPenaltyModels ? { noPenaltyModels } : {}),
|
|
204
274
|
...(autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels } : {}),
|
|
205
275
|
...(preserveReasoningContentModels ? { preserveReasoningContentModels } : {}),
|
|
276
|
+
...(reasoningSplitModels ? { reasoningSplitModels } : {}),
|
|
206
277
|
...(thinkingToggleModels ? { thinkingToggleModels } : {}),
|
|
207
278
|
...(thinkingBudgetModels ? { thinkingBudgetModels } : {}),
|
|
208
279
|
};
|
|
@@ -235,10 +306,7 @@ function routeResult(providerName: string, provider: OcxProviderConfig, modelId:
|
|
|
235
306
|
}
|
|
236
307
|
|
|
237
308
|
function routeModelInternal(config: OcxConfig, modelId: string, bypassCombos: boolean): RouteResult {
|
|
238
|
-
|
|
239
|
-
hasOwnProvider(config.providers, COMBO_NAMESPACE)
|
|
240
|
-
&& Object.keys(config.combos ?? {}).length === 0;
|
|
241
|
-
if (!bypassCombos && !preservePhysicalComboProvider) {
|
|
309
|
+
if (!bypassCombos && !preservesPhysicalComboProvider(config)) {
|
|
242
310
|
const combo = tryPickComboModel(config, modelId);
|
|
243
311
|
if (combo) {
|
|
244
312
|
const concrete = `${combo.target.provider}/${combo.target.model}`;
|
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
responsesJsonToChatCompletion,
|
|
15
15
|
responsesSseToChatCompletionsSse,
|
|
16
16
|
} from "../chat/outbound";
|
|
17
|
+
import { classifyError, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode } from "../lib/errors";
|
|
18
|
+
import { redactSecretString } from "../lib/redact";
|
|
17
19
|
import { estimateTokens } from "../lib/token-estimate";
|
|
18
20
|
import { routeModel } from "../router";
|
|
19
21
|
import { resolveWireProtocolOverride } from "./adapter-resolve";
|
|
@@ -61,6 +63,10 @@ export async function handleChatCompletions(
|
|
|
61
63
|
|
|
62
64
|
const requestedModel = (chatBody as Rec).model as string;
|
|
63
65
|
const stream = internalBody.stream === true;
|
|
66
|
+
// Best-effort Grok attribution: the managed fence stamps this header on every model
|
|
67
|
+
// it registers (extra_headers, sent verbatim by upstream Grok). Dashboard usage
|
|
68
|
+
// bucketing only — never an auth or billing signal.
|
|
69
|
+
if (req.headers.get("x-opencodex-grok") === "1") logCtx.surface = "grok";
|
|
64
70
|
// Routed adapters only support streamed turns; always stream internally and fold
|
|
65
71
|
// for non-streaming clients.
|
|
66
72
|
internalBody.stream = true;
|
|
@@ -146,43 +152,78 @@ export async function handleChatCompletions(
|
|
|
146
152
|
onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForTerminalStatus(status), { terminalStatus: status, closeReason: "terminal" }),
|
|
147
153
|
onNativePassthroughCancel: () => finalizeNativeLog(499, { closeReason: "client_cancel" }),
|
|
148
154
|
});
|
|
149
|
-
const response = logIds
|
|
150
|
-
? responseWithDeferredRequestLog(upstream, logIds.requestId, logIds.start, logCtx)
|
|
151
|
-
: upstream;
|
|
152
155
|
|
|
153
|
-
|
|
154
|
-
|
|
156
|
+
// Rewrite non-2xx before deferred logging so /api/logs records the client-facing status
|
|
157
|
+
// (e.g. cyber_policy remapped from a passthrough 5xx to HTTP 400).
|
|
158
|
+
if (!upstream.ok) {
|
|
159
|
+
let message = `upstream error (${upstream.status})`;
|
|
160
|
+
let upstreamCode: string | null | undefined;
|
|
161
|
+
let upstreamType: string | undefined;
|
|
155
162
|
try {
|
|
156
|
-
const text = await
|
|
163
|
+
const text = await upstream.text();
|
|
157
164
|
try {
|
|
158
|
-
const parsed = JSON.parse(text) as {
|
|
159
|
-
|
|
165
|
+
const parsed = JSON.parse(text) as {
|
|
166
|
+
error?: { message?: string; type?: string; code?: string | null } | string;
|
|
167
|
+
message?: string;
|
|
168
|
+
};
|
|
169
|
+
const nested = typeof parsed?.error === "object" && parsed.error ? parsed.error : undefined;
|
|
160
170
|
const flat = typeof parsed?.error === "string" ? parsed.error : parsed?.message;
|
|
161
|
-
|
|
171
|
+
const rawFallback = text
|
|
172
|
+
? `upstream error (${upstream.status}): ${redactSecretString(text).slice(0, 400)}`
|
|
173
|
+
: message;
|
|
174
|
+
message = nested?.message || flat || rawFallback;
|
|
175
|
+
if (nested) {
|
|
176
|
+
if (typeof nested.type === "string") upstreamType = nested.type;
|
|
177
|
+
if (nested.code === null || typeof nested.code === "string") upstreamCode = nested.code;
|
|
178
|
+
}
|
|
162
179
|
} catch {
|
|
163
|
-
if (text) message = `upstream error (${
|
|
180
|
+
if (text) message = `upstream error (${upstream.status}): ${redactSecretString(text).slice(0, 400)}`;
|
|
164
181
|
}
|
|
165
182
|
} catch { /* keep fallback */ }
|
|
166
|
-
const retryAfter =
|
|
167
|
-
|
|
183
|
+
const retryAfter = upstream.headers.get("retry-after");
|
|
184
|
+
const classified = classifyError(
|
|
185
|
+
upstream.status,
|
|
186
|
+
upstreamType
|
|
187
|
+
?? (upstream.status === 401 ? "authentication_error"
|
|
188
|
+
: upstream.status === 429 ? "rate_limit_error"
|
|
189
|
+
: upstream.status >= 500 ? "server_error"
|
|
190
|
+
: "invalid_request_error"),
|
|
191
|
+
message,
|
|
192
|
+
);
|
|
193
|
+
if (isCyberPolicyCode(upstreamCode)) {
|
|
194
|
+
classified.code = CYBER_POLICY_ERROR_CODE;
|
|
195
|
+
classified.type = "invalid_request_error";
|
|
196
|
+
} else if (upstreamCode === "model_not_found") {
|
|
197
|
+
// Structured model_not_found must win over classifyError's generic remaps.
|
|
198
|
+
classified.code = "model_not_found";
|
|
199
|
+
classified.type = "invalid_request_error";
|
|
200
|
+
} else if (upstreamCode !== undefined && upstreamCode !== null && classified.code == null) {
|
|
201
|
+
classified.code = upstreamCode;
|
|
202
|
+
}
|
|
203
|
+
const status = isCyberPolicyCode(classified.code) ? 400 : upstream.status;
|
|
204
|
+
const rewritten = new Response(JSON.stringify({
|
|
168
205
|
error: {
|
|
169
|
-
message,
|
|
170
|
-
type:
|
|
171
|
-
: response.status === 429 ? "rate_limit_error"
|
|
172
|
-
: response.status >= 500 ? "server_error"
|
|
173
|
-
: "invalid_request_error",
|
|
206
|
+
message: classified.message,
|
|
207
|
+
type: classified.type,
|
|
174
208
|
param: null,
|
|
175
|
-
code:
|
|
209
|
+
code: classified.code,
|
|
176
210
|
},
|
|
177
211
|
}), {
|
|
178
|
-
status
|
|
212
|
+
status,
|
|
179
213
|
headers: {
|
|
180
214
|
"Content-Type": "application/json",
|
|
181
215
|
...(retryAfter ? { "Retry-After": retryAfter } : {}),
|
|
182
216
|
},
|
|
183
217
|
});
|
|
218
|
+
return logIds
|
|
219
|
+
? responseWithDeferredRequestLog(rewritten, logIds.requestId, logIds.start, logCtx)
|
|
220
|
+
: rewritten;
|
|
184
221
|
}
|
|
185
222
|
|
|
223
|
+
const response = logIds
|
|
224
|
+
? responseWithDeferredRequestLog(upstream, logIds.requestId, logIds.start, logCtx)
|
|
225
|
+
: upstream;
|
|
226
|
+
|
|
186
227
|
const contentType = response.headers.get("content-type") ?? "";
|
|
187
228
|
if (contentType.includes("text/event-stream") && response.body) {
|
|
188
229
|
const chatSse = responsesSseToChatCompletionsSse(response.body, requestedModel);
|
|
@@ -206,7 +247,7 @@ export async function handleChatCompletions(
|
|
|
206
247
|
});
|
|
207
248
|
} catch (err) {
|
|
208
249
|
if (isChatCompletionsStreamError(err)) {
|
|
209
|
-
return chatCompletionsErrorResponse(err.status, err.message, err.type);
|
|
250
|
+
return chatCompletionsErrorResponse(err.status, err.message, err.type, err.code);
|
|
210
251
|
}
|
|
211
252
|
return chatCompletionsErrorResponse(
|
|
212
253
|
502,
|
|
@@ -225,8 +266,23 @@ export async function handleChatCompletions(
|
|
|
225
266
|
}
|
|
226
267
|
const status = (json as Rec)?.status;
|
|
227
268
|
if (status === "failed") {
|
|
228
|
-
const error = (json as { error?: { message?: string } }).error;
|
|
229
|
-
|
|
269
|
+
const error = (json as { error?: { message?: string; type?: string; code?: string | null } }).error;
|
|
270
|
+
const message = error?.message ?? "upstream request failed";
|
|
271
|
+
const classified = classifyError(502, error?.type ?? "server_error", message);
|
|
272
|
+
if (isCyberPolicyCode(error?.code)) {
|
|
273
|
+
classified.code = CYBER_POLICY_ERROR_CODE;
|
|
274
|
+
classified.type = "invalid_request_error";
|
|
275
|
+
} else if (error?.code === "model_not_found") {
|
|
276
|
+
// Same deliberate preserve as the non-OK path: structured code beats generic classify.
|
|
277
|
+
classified.code = "model_not_found";
|
|
278
|
+
classified.type = "invalid_request_error";
|
|
279
|
+
}
|
|
280
|
+
return chatCompletionsErrorResponse(
|
|
281
|
+
isCyberPolicyCode(classified.code) ? 400 : 502,
|
|
282
|
+
message,
|
|
283
|
+
classified.type,
|
|
284
|
+
classified.code,
|
|
285
|
+
);
|
|
230
286
|
}
|
|
231
287
|
const completion = responsesJsonToChatCompletion(json, requestedModel);
|
|
232
288
|
if (!stream) {
|
|
@@ -10,6 +10,8 @@ import { FORWARD_HEADERS } from "../adapters/openai-responses";
|
|
|
10
10
|
import { enforceAnthropicImageLimits } from "../adapters/anthropic-image-guard";
|
|
11
11
|
import { normalizeAnthropicImages } from "../adapters/anthropic-image-normalize";
|
|
12
12
|
import { AnthropicRequestError, anthropicToResponsesTranslation, extractOcxRouteDirective, resolveInboundModel, type ClaudeCacheKeySource } from "../claude/inbound";
|
|
13
|
+
import { resolveDesktop3pAlias } from "../claude/desktop-3p";
|
|
14
|
+
import { recordDesktopRequest } from "../claude/desktop-health";
|
|
13
15
|
import { stripOneMillionMarker } from "../claude/context-windows";
|
|
14
16
|
import { captureClaudeInbound } from "../claude/inbound-debug";
|
|
15
17
|
import { isTransientUpstreamStatus } from "../lib/upstream-retry";
|
|
@@ -547,6 +549,12 @@ export async function handleClaudeMessages(
|
|
|
547
549
|
: undefined,
|
|
548
550
|
req.headers.get("anthropic-beta") ?? undefined,
|
|
549
551
|
);
|
|
552
|
+
// Client surface discrimination: Desktop 3P aliases resolve through the
|
|
553
|
+
// desktop registry; Code uses readable aliases or direct model names.
|
|
554
|
+
if (isRec(anthropicBody) && typeof anthropicBody.model === "string" && resolveDesktop3pAlias(anthropicBody.model)) {
|
|
555
|
+
logCtx.surface = "claude-desktop";
|
|
556
|
+
recordDesktopRequest();
|
|
557
|
+
}
|
|
550
558
|
if (isRec(anthropicBody) && wantsNativePassthrough(req, config, anthropicBody.model)) {
|
|
551
559
|
return await anthropicNativePassthrough(req, config, logCtx, logIds, anthropicBody, "/v1/messages");
|
|
552
560
|
}
|
package/src/server/images.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import { formatErrorResponse } from "../bridge";
|
|
15
15
|
import {
|
|
16
16
|
CodexAccountCooldownError,
|
|
17
|
+
cooldownErrorResponse,
|
|
17
18
|
CodexAuthContextError,
|
|
18
19
|
CodexPoolAuthenticationError,
|
|
19
20
|
CodexThreadAffinityExpiredError,
|
|
@@ -84,7 +85,7 @@ export async function handleImages(
|
|
|
84
85
|
if (forward) logCtx.provider = formatCodexProviderForLog(forward.providerName, codexLogAccountId(forward.authContext), config);
|
|
85
86
|
} catch (err) {
|
|
86
87
|
if (err instanceof CodexAccountCooldownError) {
|
|
87
|
-
forwardAuthError =
|
|
88
|
+
forwardAuthError = cooldownErrorResponse(err);
|
|
88
89
|
} else if (err instanceof CodexThreadAffinityExpiredError) {
|
|
89
90
|
forwardAuthError = formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session");
|
|
90
91
|
} else if (err instanceof CodexAuthContextError) {
|