@bitkyc08/opencodex 2.7.43 → 2.8.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/bin/ocx.mjs +34 -8
- package/gui/dist/assets/index-BDjpkcRN.js +67 -0
- package/gui/dist/assets/index-BHsKRFh9.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/cursor/discovery.ts +4 -1
- package/src/adapters/cursor/effort-map.ts +3 -0
- package/src/adapters/kiro.ts +15 -1
- package/src/claude/alias.ts +94 -14
- package/src/claude/outbound.ts +6 -3
- package/src/cli/catalog-prewarm.ts +24 -0
- package/src/cli/claude.ts +32 -7
- package/src/cli/doctor.ts +48 -1
- package/src/cli/index.ts +5 -0
- package/src/cli/interactive-confirm.ts +5 -1
- package/src/cli/star-prompt.ts +26 -4
- package/src/cli/v2.ts +10 -1
- package/src/codex/account-store.ts +2 -0
- package/src/codex/catalog/bundled.ts +9 -2
- package/src/codex/catalog/parsing.ts +26 -1
- package/src/codex/catalog/provider-fetch.ts +240 -82
- package/src/codex/catalog/sync.ts +27 -5
- package/src/codex/catalog.ts +1 -1
- package/src/codex/features.ts +524 -5
- package/src/codex/quota.ts +77 -2
- package/src/codex/runtime.ts +10 -1
- package/src/config.ts +8 -0
- package/src/generated/jawcode-model-metadata.ts +12 -12
- package/src/github/star-state.ts +191 -0
- package/src/lib/bun-binary-validator.d.mts +3 -0
- package/src/lib/bun-binary-validator.mjs +18 -0
- package/src/lib/bun-runtime.ts +6 -20
- package/src/lib/destination-policy.ts +10 -3
- package/src/lib/provider-outbound.ts +5 -2
- package/src/lib/shadow-call.ts +30 -0
- package/src/lib/test-home-guard.ts +90 -0
- package/src/lib/win-exec.ts +12 -2
- package/src/oauth/index.ts +29 -5
- package/src/oauth/key-providers.ts +21 -2
- package/src/oauth/kiro-credentials.ts +57 -8
- package/src/oauth/kiro.ts +2 -1
- package/src/oauth/login-cli.ts +1 -1
- package/src/oauth/store.ts +2 -0
- package/src/providers/derive.ts +2 -2
- package/src/providers/model-discovery.ts +356 -0
- package/src/providers/registry.ts +114 -0
- package/src/router.ts +5 -3
- package/src/server/auth-cors.ts +4 -2
- package/src/server/live.ts +75 -25
- package/src/server/management/agent-settings-routes.ts +78 -4
- package/src/server/management/config-routes.ts +19 -7
- package/src/server/management/context.ts +11 -1
- package/src/server/management/model-routes.ts +46 -13
- package/src/server/management/provider-routes.ts +44 -9
- package/src/server/management/shared.ts +2 -2
- package/src/server/management/sidebar-routes.ts +39 -0
- package/src/server/management-api.ts +3 -1
- package/src/server/responses/core.ts +31 -20
- package/src/server/responses/upstream-error.ts +48 -0
- package/src/server/startup-action-control.ts +30 -14
- package/src/service.ts +237 -19
- package/src/storage/policy-job.ts +26 -5
- package/src/storage/restore-job.ts +16 -5
- package/src/storage/worker-lifecycle.ts +81 -0
- package/src/tray/windows.ts +32 -4
- package/src/types.ts +11 -0
- package/src/update/badge.ts +72 -0
- package/src/update/job.ts +8 -4
- package/src/usage/expected-prices.ts +6 -5
- package/src/usage/log.ts +8 -0
- package/src/web-search/loop.ts +57 -16
- package/gui/dist/assets/index-Czw-jpTU.css +0 -1
- package/gui/dist/assets/index-cmds12BG.js +0 -67
|
@@ -104,22 +104,43 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
104
104
|
// itself never writes config — this endpoint is the only server-side mutation
|
|
105
105
|
// surface for the flag.
|
|
106
106
|
if (url.pathname === "/api/v2" && req.method === "GET") {
|
|
107
|
-
const {
|
|
107
|
+
const {
|
|
108
|
+
isMultiAgentV2Enabled, hasAgentsMaxThreads, getLogicalMaxThreads,
|
|
109
|
+
getAgentsEnabled, getAgentsMaxDepth, getSubagentDeveloperInstructions,
|
|
110
|
+
} = await import("../../codex/features");
|
|
108
111
|
const enabled = isMultiAgentV2Enabled();
|
|
109
112
|
return jsonResponse({
|
|
110
113
|
enabled,
|
|
111
114
|
agentsMaxThreadsConflict: enabled && hasAgentsMaxThreads(),
|
|
112
115
|
maxConcurrentThreadsPerSession: getLogicalMaxThreads(),
|
|
113
116
|
multiAgentMode: config.multiAgentMode ?? "default",
|
|
117
|
+
agentsEnabled: getAgentsEnabled(),
|
|
118
|
+
agentsMaxDepth: getAgentsMaxDepth(),
|
|
119
|
+
subagentDeveloperInstructions: getSubagentDeveloperInstructions(),
|
|
120
|
+
// max_depth is V1-only upstream; this is the global-flag statement, derived
|
|
121
|
+
// server-side so no client can present it as an effective V2 limit.
|
|
122
|
+
agentsMaxDepthAppliesWhenV2Disabled: !enabled,
|
|
114
123
|
});
|
|
115
124
|
}
|
|
116
125
|
if (url.pathname === "/api/v2" && req.method === "PUT") {
|
|
117
|
-
let body: {
|
|
126
|
+
let body: {
|
|
127
|
+
enabled?: unknown;
|
|
128
|
+
maxConcurrentThreadsPerSession?: unknown;
|
|
129
|
+
multiAgentMode?: unknown;
|
|
130
|
+
agentsEnabled?: unknown;
|
|
131
|
+
agentsMaxDepth?: unknown;
|
|
132
|
+
subagentDeveloperInstructions?: unknown;
|
|
133
|
+
};
|
|
118
134
|
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
119
135
|
const wantsFlag = body.enabled !== undefined;
|
|
120
136
|
const wantsThreads = body.maxConcurrentThreadsPerSession !== undefined;
|
|
121
137
|
const wantsMode = body.multiAgentMode !== undefined;
|
|
122
|
-
|
|
138
|
+
const wantsAgentsEnabled = body.agentsEnabled !== undefined;
|
|
139
|
+
const wantsMaxDepth = body.agentsMaxDepth !== undefined;
|
|
140
|
+
const wantsSubagentInstructions = body.subagentDeveloperInstructions !== undefined;
|
|
141
|
+
if (!wantsFlag && !wantsThreads && !wantsMode && !wantsAgentsEnabled && !wantsMaxDepth && !wantsSubagentInstructions) {
|
|
142
|
+
return jsonResponse({ error: "body must set enabled, multiAgentMode, maxConcurrentThreadsPerSession, agentsEnabled, agentsMaxDepth, and/or subagentDeveloperInstructions" }, 400);
|
|
143
|
+
}
|
|
123
144
|
if (wantsFlag && typeof body.enabled !== "boolean") return jsonResponse({ error: "body.enabled must be a boolean" }, 400);
|
|
124
145
|
if (wantsMode && body.multiAgentMode !== "v1" && body.multiAgentMode !== "default" && body.multiAgentMode !== "v2") {
|
|
125
146
|
return jsonResponse({ error: "body.multiAgentMode must be 'v1', 'default', or 'v2'" }, 400);
|
|
@@ -127,12 +148,31 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
127
148
|
if (wantsThreads && (typeof body.maxConcurrentThreadsPerSession !== "number" || !Number.isInteger(body.maxConcurrentThreadsPerSession) || body.maxConcurrentThreadsPerSession < 1)) {
|
|
128
149
|
return jsonResponse({ error: "body.maxConcurrentThreadsPerSession must be an integer >= 1" }, 400);
|
|
129
150
|
}
|
|
151
|
+
// Validate every new field BEFORE any write, so each 400 leaves config untouched.
|
|
152
|
+
// null unsets the key; "" is a meaningful value for instructions and must not be
|
|
153
|
+
// collapsed by a falsy check. The i32 preflight mirrors the upstream Option<i32>
|
|
154
|
+
// contract — out-of-range would otherwise surface as a mid-sequence write failure.
|
|
155
|
+
if (wantsAgentsEnabled && body.agentsEnabled !== null && typeof body.agentsEnabled !== "boolean") {
|
|
156
|
+
return jsonResponse({ error: "body.agentsEnabled must be a boolean or null" }, 400);
|
|
157
|
+
}
|
|
158
|
+
if (wantsMaxDepth && body.agentsMaxDepth !== null
|
|
159
|
+
&& (typeof body.agentsMaxDepth !== "number" || !Number.isInteger(body.agentsMaxDepth)
|
|
160
|
+
|| body.agentsMaxDepth < -2_147_483_648 || body.agentsMaxDepth > 2_147_483_647)) {
|
|
161
|
+
return jsonResponse({ error: "body.agentsMaxDepth must be an integer within signed i32 range, or null" }, 400);
|
|
162
|
+
}
|
|
163
|
+
if (wantsSubagentInstructions && body.subagentDeveloperInstructions !== null && typeof body.subagentDeveloperInstructions !== "string") {
|
|
164
|
+
return jsonResponse({ error: "body.subagentDeveloperInstructions must be a string or null" }, 400);
|
|
165
|
+
}
|
|
130
166
|
const mode = wantsMode ? body.multiAgentMode as "v1" | "default" | "v2" : undefined;
|
|
131
167
|
const modeFlag = mode === "v2" ? true : mode === "v1" ? false : undefined;
|
|
132
168
|
if (wantsFlag && modeFlag !== undefined && body.enabled !== modeFlag) {
|
|
133
169
|
return jsonResponse({ error: `body.enabled conflicts with multiAgentMode '${mode}'` }, 400);
|
|
134
170
|
}
|
|
135
|
-
const {
|
|
171
|
+
const {
|
|
172
|
+
isMultiAgentV2Enabled, hasAgentsMaxThreads, getLogicalMaxThreads, transitionMultiAgentV2,
|
|
173
|
+
getAgentsEnabled, getAgentsMaxDepth, getSubagentDeveloperInstructions,
|
|
174
|
+
setAgentsEnabled, setAgentsMaxDepth, setSubagentDeveloperInstructions,
|
|
175
|
+
} = await import("../../codex/features");
|
|
136
176
|
const warnings: string[] = [];
|
|
137
177
|
const requestedFlag = wantsFlag ? body.enabled as boolean : modeFlag;
|
|
138
178
|
if (requestedFlag !== undefined || wantsThreads) {
|
|
@@ -159,6 +199,36 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
159
199
|
saveConfigPreservingClaudeCode(config);
|
|
160
200
|
warnings.push(`Multi-agent mode set to '${mode}'. Applies to new sessions.`);
|
|
161
201
|
}
|
|
202
|
+
// New-key scalar writes: each writer is individually atomic, so apply them in
|
|
203
|
+
// sequence after the transition. A failure here is a persistence failure (the
|
|
204
|
+
// writers' ok:false result or a throw from the underlying atomic write helper),
|
|
205
|
+
// reported as 502 naming the failed key plus the writes that already landed.
|
|
206
|
+
// NOTE: do not name that helper literally here — tests/grok-writer-boundary.test.ts
|
|
207
|
+
// asserts this route file contains no direct write primitive, and matches on the
|
|
208
|
+
// symbol name even inside a comment.
|
|
209
|
+
const scalarWrites: Array<{ field: string; run: () => { ok: true; changed: boolean } | { ok: false; error: string } }> = [];
|
|
210
|
+
if (wantsAgentsEnabled) scalarWrites.push({ field: "agentsEnabled", run: () => setAgentsEnabled(body.agentsEnabled as boolean | null) });
|
|
211
|
+
if (wantsMaxDepth) scalarWrites.push({ field: "agentsMaxDepth", run: () => setAgentsMaxDepth(body.agentsMaxDepth as number | null) });
|
|
212
|
+
if (wantsSubagentInstructions) scalarWrites.push({ field: "subagentDeveloperInstructions", run: () => setSubagentDeveloperInstructions(body.subagentDeveloperInstructions as string | null) });
|
|
213
|
+
const landed: string[] = [];
|
|
214
|
+
for (const write of scalarWrites) {
|
|
215
|
+
try {
|
|
216
|
+
const result = write.run();
|
|
217
|
+
if (!result.ok) {
|
|
218
|
+
return jsonResponse({ error: `writing ${write.field} failed: ${result.error}${landed.length > 0 ? ` (already applied: ${landed.join(", ")})` : ""}` }, 502);
|
|
219
|
+
}
|
|
220
|
+
landed.push(write.field);
|
|
221
|
+
} catch (err) {
|
|
222
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
223
|
+
return jsonResponse({ error: `writing ${write.field} failed: ${message}${landed.length > 0 ? ` (already applied: ${landed.join(", ")})` : ""}` }, 502);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// Derived from fresh post-write readers (readConfigText is uncached): upstream
|
|
227
|
+
// lets an enabled multi_agent_v2 feature override [agents].enabled = false, so
|
|
228
|
+
// warn rather than reject — silently accepting would imply multi-agent is off.
|
|
229
|
+
if (getAgentsEnabled() === false && isMultiAgentV2Enabled()) {
|
|
230
|
+
warnings.push("agents.enabled = false has no effect while features.multi_agent_v2 is enabled; upstream keeps V2 active.");
|
|
231
|
+
}
|
|
162
232
|
await refreshCodexCatalogBestEffort();
|
|
163
233
|
if (requestedFlag !== undefined) warnings.push("Applies to new sessions; restart the Codex app or wait out its picker cache to see the ladder change.");
|
|
164
234
|
const enabled = isMultiAgentV2Enabled();
|
|
@@ -168,6 +238,10 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
168
238
|
agentsMaxThreadsConflict: enabled && hasAgentsMaxThreads(),
|
|
169
239
|
maxConcurrentThreadsPerSession: getLogicalMaxThreads(),
|
|
170
240
|
multiAgentMode: config.multiAgentMode ?? "default",
|
|
241
|
+
agentsEnabled: getAgentsEnabled(),
|
|
242
|
+
agentsMaxDepth: getAgentsMaxDepth(),
|
|
243
|
+
subagentDeveloperInstructions: getSubagentDeveloperInstructions(),
|
|
244
|
+
agentsMaxDepthAppliesWhenV2Disabled: !enabled,
|
|
171
245
|
warnings,
|
|
172
246
|
});
|
|
173
247
|
}
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
import { removeCredential } from "../../oauth/store";
|
|
25
25
|
import { providerDestinationResolvedError } from "../../lib/destination-policy";
|
|
26
26
|
import { isStreamMode } from "../../lib/bun-stream-caps";
|
|
27
|
+
import { shadowSourceModels } from "../../lib/shadow-call";
|
|
27
28
|
import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
|
|
28
29
|
import { deriveProviderPresets } from "../../providers/derive";
|
|
29
30
|
import { providerCodexAccountMode } from "../../providers/registry";
|
|
@@ -140,16 +141,20 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
140
141
|
}
|
|
141
142
|
|
|
142
143
|
if (url.pathname === "/api/startup-action" && req.method === "POST") {
|
|
143
|
-
let body: { action?: unknown };
|
|
144
|
+
let body: { action?: unknown; repair?: unknown };
|
|
144
145
|
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
145
146
|
if (!body || !["install-service", "install-shim"].includes(String(body.action))) {
|
|
146
147
|
return jsonResponse({ error: "action must be install-service or install-shim" }, 400);
|
|
147
148
|
}
|
|
149
|
+
if (body.repair !== undefined && typeof body.repair !== "boolean") {
|
|
150
|
+
return jsonResponse({ error: "repair must be a boolean when provided" }, 400);
|
|
151
|
+
}
|
|
148
152
|
try {
|
|
149
153
|
const action = body.action as StartupInstallAction;
|
|
150
|
-
const
|
|
154
|
+
const repair = body.repair === true;
|
|
155
|
+
const result = await (deps.runStartupInstallAction ?? runStartupInstallAction)(action, { repair });
|
|
151
156
|
invalidateStartupHealthCache();
|
|
152
|
-
return jsonResponse({ ok: true, action, message: result.message });
|
|
157
|
+
return jsonResponse({ ok: true, action, repair, message: result.message });
|
|
153
158
|
} catch (error) {
|
|
154
159
|
return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 500);
|
|
155
160
|
}
|
|
@@ -227,8 +232,6 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
227
232
|
const { syncModelsToCodex } = await import("../../codex/sync");
|
|
228
233
|
const { attachStaleAppServerHint } = await import("../../codex/app-server-processes");
|
|
229
234
|
const result = await syncModelsToCodex(undefined, config, null);
|
|
230
|
-
// Hint only after a real catalog/cache write — never enumerate processes here
|
|
231
|
-
// (WMIC/PowerShell would block Bun's event loop on every dashboard sync).
|
|
232
235
|
return jsonResponse({
|
|
233
236
|
...attachStaleAppServerHint(result),
|
|
234
237
|
...(result.ok ? {} : { error: result.message }),
|
|
@@ -352,7 +355,11 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
352
355
|
|
|
353
356
|
if (url.pathname === "/api/shadow-call-settings" && req.method === "GET") {
|
|
354
357
|
const sci = config.shadowCallIntercept ?? {};
|
|
355
|
-
return jsonResponse({
|
|
358
|
+
return jsonResponse({
|
|
359
|
+
enabled: sci.enabled === true,
|
|
360
|
+
model: sci.model ?? "",
|
|
361
|
+
sourceModels: shadowSourceModels(sci.sourceModels),
|
|
362
|
+
});
|
|
356
363
|
}
|
|
357
364
|
|
|
358
365
|
if (url.pathname === "/api/shadow-call-settings" && req.method === "PUT") {
|
|
@@ -374,7 +381,12 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
374
381
|
}
|
|
375
382
|
saveConfigPreservingClaudeCode(config);
|
|
376
383
|
const sci = config.shadowCallIntercept;
|
|
377
|
-
return jsonResponse({
|
|
384
|
+
return jsonResponse({
|
|
385
|
+
ok: true,
|
|
386
|
+
enabled: sci.enabled === true,
|
|
387
|
+
model: sci.model ?? "",
|
|
388
|
+
sourceModels: shadowSourceModels(sci.sourceModels),
|
|
389
|
+
});
|
|
378
390
|
}
|
|
379
391
|
return null;
|
|
380
392
|
}
|
|
@@ -4,10 +4,20 @@ import type { StartupInstallAction } from "../startup-action-control";
|
|
|
4
4
|
export interface ManagementApiDeps {
|
|
5
5
|
toggleCodexMultiAgentV2?: (enabled: boolean) => void;
|
|
6
6
|
refreshCodexCatalog?: () => Promise<void>;
|
|
7
|
+
/**
|
|
8
|
+
* Persistence seam for route-level tests. Production leaves this unset and uses
|
|
9
|
+
* `saveConfigPreservingClaudeCode`; tests that pass an in-memory fixture config
|
|
10
|
+
* MUST inject a no-op/spy so the fixture can never overwrite the user's real
|
|
11
|
+
* OPENCODEX_HOME (incident: devlog 260730.../070).
|
|
12
|
+
*/
|
|
13
|
+
saveConfigPreservingClaudeCode?: (config: OcxConfig) => void;
|
|
7
14
|
clearThreadAccountMap?: () => void;
|
|
8
15
|
clearProviderQuotaCache?: () => void;
|
|
9
16
|
primeCodexPoolQuotas?: (config: OcxConfig, reason: string) => Promise<void> | void;
|
|
10
|
-
runStartupInstallAction?: (
|
|
17
|
+
runStartupInstallAction?: (
|
|
18
|
+
action: StartupInstallAction,
|
|
19
|
+
options?: { repair?: boolean },
|
|
20
|
+
) => Promise<{ message: string }>;
|
|
11
21
|
}
|
|
12
22
|
|
|
13
23
|
|
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Codex parses a catalog entry's `input_modalities` as a closed enum, and one out-of-enum
|
|
6
|
+
* value makes it reject the ENTIRE catalog file — plugins, apps and MCP servers all stop
|
|
7
|
+
* loading over one model's metadata (#759).
|
|
8
|
+
*
|
|
9
|
+
* The catalog writer normalizes on the way out, but a rejected value stored here would still
|
|
10
|
+
* be handed back to the GUI and CLI as if it were real, and the offline `ocx models add` path
|
|
11
|
+
* already refuses it. Validate at ingress so all three paths agree.
|
|
12
|
+
*/
|
|
13
|
+
const ALLOWED_INPUT_MODALITIES = new Set(["text", "image", "audio"]);
|
|
14
|
+
|
|
15
|
+
function readInputModalities(raw: unknown): { values?: string[]; error?: string } {
|
|
16
|
+
if (raw === undefined) return {};
|
|
17
|
+
if (!Array.isArray(raw)) return { error: "inputModalities must be an array" };
|
|
18
|
+
// Reject non-strings rather than filtering them out. Dropping them silently accepted a
|
|
19
|
+
// malformed POST and, worse, let a PUT of `[42]` clear the stored modalities while
|
|
20
|
+
// answering 200 — the opposite of the contract this validator exists to state. An empty
|
|
21
|
+
// array stays valid: that is how `ocx models edit --modalities -` clears the field.
|
|
22
|
+
const rejected: string[] = [];
|
|
23
|
+
for (const value of raw) {
|
|
24
|
+
if (typeof value !== "string") return { error: "inputModalities must contain only strings" };
|
|
25
|
+
if (!ALLOWED_INPUT_MODALITIES.has(value)) rejected.push(value);
|
|
26
|
+
}
|
|
27
|
+
if (rejected.length > 0) {
|
|
28
|
+
return { error: `unsupported input modality: ${rejected.join(", ")} (allowed: text, image, audio)` };
|
|
29
|
+
}
|
|
30
|
+
return { values: raw as string[] };
|
|
31
|
+
}
|
|
3
32
|
import type { CatalogModel } from "../../codex/catalog";
|
|
4
33
|
import { catalogModelSlug, disabledNativeSlugs, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog";
|
|
5
34
|
import { getProviderLiveModelCount } from "../../codex/model-cache";
|
|
@@ -63,6 +92,11 @@ import type { ManagementContext } from "./context";
|
|
|
63
92
|
|
|
64
93
|
export async function handleModelRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
65
94
|
const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx;
|
|
95
|
+
// A handler persists the exact config object passed in. Production defaults to
|
|
96
|
+
// the real store; tests that pass an in-memory fixture inject a no-op/spy. Do not
|
|
97
|
+
// bypass this seam with a dynamic config import — doing so replaced a user's
|
|
98
|
+
// ~/.opencodex/config.json with the `existing-uuid` test fixture.
|
|
99
|
+
const persistConfig = deps.saveConfigPreservingClaudeCode ?? saveConfigPreservingClaudeCode;
|
|
66
100
|
|
|
67
101
|
if (url.pathname === "/api/models" && req.method === "GET") {
|
|
68
102
|
const models = await fetchAllModels(config);
|
|
@@ -123,8 +157,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
123
157
|
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
124
158
|
const disabled = Array.isArray(body.models) ? body.models.filter((m): m is string => typeof m === "string") : [];
|
|
125
159
|
config.disabledModels = disabled;
|
|
126
|
-
|
|
127
|
-
save(config);
|
|
160
|
+
persistConfig(config);
|
|
128
161
|
await refreshCodexCatalogBestEffort();
|
|
129
162
|
return jsonResponse({ ok: true, disabled });
|
|
130
163
|
}
|
|
@@ -223,7 +256,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
223
256
|
}
|
|
224
257
|
|
|
225
258
|
config.disabledModels = disabled;
|
|
226
|
-
|
|
259
|
+
persistConfig(config);
|
|
227
260
|
await refreshCodexCatalogBestEffort();
|
|
228
261
|
return jsonResponse({ ok: true, scope, provider, enabled: body.enabled, disabled });
|
|
229
262
|
}
|
|
@@ -244,7 +277,9 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
244
277
|
const displayName = typeof body.displayName === "string" && body.displayName.trim() ? body.displayName.trim() : undefined;
|
|
245
278
|
if (displayName?.includes("/")) return jsonResponse({ error: "displayName must not contain /" }, 400);
|
|
246
279
|
const contextWindow = typeof body.contextWindow === "number" && body.contextWindow > 0 ? Math.floor(body.contextWindow) : undefined;
|
|
247
|
-
const
|
|
280
|
+
const modalities = readInputModalities(body.inputModalities);
|
|
281
|
+
if (modalities.error) return jsonResponse({ error: modalities.error }, 400);
|
|
282
|
+
const inputModalities = modalities.values;
|
|
248
283
|
const existing = config.customModels ?? [];
|
|
249
284
|
const newSlug = routedSlug(provider, modelId);
|
|
250
285
|
if (existing.some(cm => routedSlug(cm.provider, cm.modelId) === newSlug)) {
|
|
@@ -260,8 +295,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
260
295
|
addedAt: new Date().toISOString(),
|
|
261
296
|
};
|
|
262
297
|
config.customModels = [...existing, entry];
|
|
263
|
-
|
|
264
|
-
save(config);
|
|
298
|
+
persistConfig(config);
|
|
265
299
|
await refreshCodexCatalogBestEffort();
|
|
266
300
|
return jsonResponse(entry, 201);
|
|
267
301
|
}
|
|
@@ -289,7 +323,9 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
289
323
|
cm.contextWindow = typeof body.contextWindow === "number" && body.contextWindow > 0 ? Math.floor(body.contextWindow) : undefined;
|
|
290
324
|
}
|
|
291
325
|
if (body.inputModalities !== undefined) {
|
|
292
|
-
|
|
326
|
+
const edited = readInputModalities(body.inputModalities);
|
|
327
|
+
if (edited.error) return jsonResponse({ error: edited.error }, 400);
|
|
328
|
+
cm.inputModalities = edited.values && edited.values.length > 0 ? edited.values : undefined;
|
|
293
329
|
}
|
|
294
330
|
const updatedSlug = routedSlug(cm.provider, cm.modelId);
|
|
295
331
|
if (list.some((other, i) => i !== idx && routedSlug(other.provider, other.modelId) === updatedSlug)) {
|
|
@@ -297,8 +333,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
297
333
|
}
|
|
298
334
|
list[idx] = cm;
|
|
299
335
|
config.customModels = list;
|
|
300
|
-
|
|
301
|
-
save(config);
|
|
336
|
+
persistConfig(config);
|
|
302
337
|
await refreshCodexCatalogBestEffort();
|
|
303
338
|
return jsonResponse(cm);
|
|
304
339
|
}
|
|
@@ -312,8 +347,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
312
347
|
if (idx === -1) return jsonResponse({ error: "not found" }, 404);
|
|
313
348
|
list.splice(idx, 1);
|
|
314
349
|
config.customModels = list.length > 0 ? list : undefined;
|
|
315
|
-
|
|
316
|
-
save(config);
|
|
350
|
+
persistConfig(config);
|
|
317
351
|
await refreshCodexCatalogBestEffort();
|
|
318
352
|
return jsonResponse({ ok: true });
|
|
319
353
|
}
|
|
@@ -349,8 +383,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
349
383
|
// Empty list clears the allowlist (provider reverts to exposing all models).
|
|
350
384
|
if (models.length > 0) config.providers[provider].selectedModels = models;
|
|
351
385
|
else delete config.providers[provider].selectedModels;
|
|
352
|
-
|
|
353
|
-
save(config);
|
|
386
|
+
persistConfig(config);
|
|
354
387
|
await refreshCodexCatalogBestEffort();
|
|
355
388
|
return jsonResponse({ ok: true, provider, selected: models });
|
|
356
389
|
}
|
|
@@ -2,7 +2,6 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import type { CatalogModel } from "../../codex/catalog";
|
|
4
4
|
import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog";
|
|
5
|
-
import { providerModelsListFromProbeResponse } from "../../codex/catalog/provider-fetch";
|
|
6
5
|
import {
|
|
7
6
|
DEFAULT_SUBAGENT_MODELS,
|
|
8
7
|
codexAutoStartEnabled,
|
|
@@ -24,10 +23,16 @@ import {
|
|
|
24
23
|
} from "../../oauth";
|
|
25
24
|
import { removeCredential } from "../../oauth/store";
|
|
26
25
|
import { providerDestinationResolvedError } from "../../lib/destination-policy";
|
|
27
|
-
import { providerOutboundGet, providerRedirectError } from "../../lib/provider-outbound";
|
|
26
|
+
import { ProviderOutboundPolicyError, providerOutboundGet, providerRedirectError } from "../../lib/provider-outbound";
|
|
28
27
|
import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
|
|
29
28
|
import { deriveProviderPresets } from "../../providers/derive";
|
|
30
29
|
import { providerCodexAccountMode } from "../../providers/registry";
|
|
30
|
+
import {
|
|
31
|
+
extractModelEnvelopeRows,
|
|
32
|
+
extractProviderModelItems,
|
|
33
|
+
readBoundedDiscoveryJson,
|
|
34
|
+
resolveProviderModelDiscovery,
|
|
35
|
+
} from "../../providers/model-discovery";
|
|
31
36
|
import { routedSlug, slugEquals } from "../../providers/slug-codec";
|
|
32
37
|
import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota";
|
|
33
38
|
import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
@@ -339,6 +344,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
339
344
|
return jsonResponse({ ok: false, latencyMs: 0, error: "static catalog only — upstream not verified (not logged in)" });
|
|
340
345
|
}
|
|
341
346
|
const { url: modelsUrl, headers } = buildModelsRequest(prov, apiKey, name);
|
|
347
|
+
const discovery = resolveProviderModelDiscovery(name, prov);
|
|
342
348
|
const started = Date.now();
|
|
343
349
|
try {
|
|
344
350
|
const res = await providerOutboundGet(name, prov, modelsUrl, {
|
|
@@ -355,15 +361,42 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
355
361
|
});
|
|
356
362
|
}
|
|
357
363
|
if (!res.ok) {
|
|
364
|
+
try {
|
|
365
|
+
void res.body?.cancel().catch(() => undefined);
|
|
366
|
+
} catch {
|
|
367
|
+
// Best-effort release for non-conforming response streams.
|
|
368
|
+
}
|
|
358
369
|
return jsonResponse({ ok: false, latencyMs, error: `upstream /models returned ${res.status}` });
|
|
359
370
|
}
|
|
360
|
-
const
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
371
|
+
const bounded = await readBoundedDiscoveryJson(res, discovery.maxResponseBytes);
|
|
372
|
+
if (!bounded.ok) {
|
|
373
|
+
return jsonResponse({
|
|
374
|
+
ok: false,
|
|
375
|
+
latencyMs,
|
|
376
|
+
error: bounded.reason === "response_too_large"
|
|
377
|
+
? `upstream /models exceeded the ${discovery.maxResponseBytes}-byte response limit`
|
|
378
|
+
: "upstream /models returned invalid JSON",
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
// OpenAI-style lists (and Together top-level arrays) use the same validation/dedupe/filter
|
|
382
|
+
// as catalog discovery. Google's /v1beta/models uses `models[].name` and remains a
|
|
383
|
+
// connectivity-only count because it is not an authoritative catalog source.
|
|
384
|
+
const record = bounded.value !== null && typeof bounded.value === "object" && !Array.isArray(bounded.value)
|
|
385
|
+
? bounded.value as Record<string, unknown>
|
|
386
|
+
: undefined;
|
|
387
|
+
const extracted = Array.isArray(bounded.value) || Array.isArray(record?.data)
|
|
388
|
+
? extractProviderModelItems(bounded.value, discovery)
|
|
389
|
+
: extractModelEnvelopeRows(bounded.value, discovery.maxModels, ["models"]);
|
|
390
|
+
if (!extracted.ok) {
|
|
391
|
+
return jsonResponse({
|
|
392
|
+
ok: false,
|
|
393
|
+
latencyMs,
|
|
394
|
+
error: extracted.reason === "too_many_models"
|
|
395
|
+
? `upstream /models exceeded the ${discovery.maxModels}-row model limit`
|
|
396
|
+
: "upstream /models returned an unexpected shape",
|
|
397
|
+
});
|
|
365
398
|
}
|
|
366
|
-
const models =
|
|
399
|
+
const models = "items" in extracted ? extracted.items.length : extracted.rows.length;
|
|
367
400
|
return jsonResponse({
|
|
368
401
|
ok: true,
|
|
369
402
|
latencyMs,
|
|
@@ -374,7 +407,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
374
407
|
return jsonResponse({
|
|
375
408
|
ok: false,
|
|
376
409
|
latencyMs: Date.now() - started,
|
|
377
|
-
error: err instanceof
|
|
410
|
+
error: err instanceof ProviderOutboundPolicyError
|
|
411
|
+
? `upstream /models blocked by destination policy: ${err.message}`
|
|
412
|
+
: err instanceof Error ? err.message : "Connection test failed",
|
|
378
413
|
});
|
|
379
414
|
}
|
|
380
415
|
}
|
|
@@ -37,7 +37,7 @@ import { readUsageEntries } from "../../usage/log";
|
|
|
37
37
|
import { getUsageDebugLogEntries } from "../../usage/debug";
|
|
38
38
|
import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary";
|
|
39
39
|
import { stripCodexRuntimeProviderFields } from "../../codex/auth-context";
|
|
40
|
-
import { getProviderRegistryEntry } from "../../providers/registry";
|
|
40
|
+
import { getProviderRegistryEntry, providerMatchesRegistryTransport } from "../../providers/registry";
|
|
41
41
|
import { getDebugLogEntries } from "../../lib/debug-log-buffer";
|
|
42
42
|
import { getInjectionDebugLogEntries } from "../../lib/injection-debug-log";
|
|
43
43
|
import {
|
|
@@ -201,7 +201,7 @@ export async function fetchGrokCandidateModels(config: OcxConfig): Promise<GrokC
|
|
|
201
201
|
}
|
|
202
202
|
|
|
203
203
|
export function stripRegistryOnlyStaticHeaders(name: string, provider: OcxProviderConfig): OcxProviderConfig {
|
|
204
|
-
const entry = getProviderRegistryEntry(name);
|
|
204
|
+
const entry = providerMatchesRegistryTransport(name, provider) ? getProviderRegistryEntry(name) : undefined;
|
|
205
205
|
if (!entry?.staticHeaders || !provider.headers) return provider;
|
|
206
206
|
const headerEntries = Object.entries(provider.headers);
|
|
207
207
|
const staticEntries = Object.entries(entry.staticHeaders);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /api/github/star and /api/update/badge — the two cheap polls behind the
|
|
3
|
+
* sidebar's GitHub star and update controls.
|
|
4
|
+
*
|
|
5
|
+
* Both ride the standard management gate (auth + origin check happen before
|
|
6
|
+
* dispatch), and both are scalar-only: a star state enum, a repo slug, version
|
|
7
|
+
* strings, and a fixed error code. No GitHub token, account login, or raw `gh`/npm
|
|
8
|
+
* output is ever serialized here — starring runs through the user's own `gh` CLI and
|
|
9
|
+
* this surface only learns the yes/no answer. `gh` writes the authenticated account
|
|
10
|
+
* name to stderr, so that output is discarded at the source rather than forwarded.
|
|
11
|
+
*/
|
|
12
|
+
import { jsonResponse } from "../auth-cors";
|
|
13
|
+
import type { ManagementContext } from "./context";
|
|
14
|
+
|
|
15
|
+
export async function handleSidebarRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
16
|
+
const { req, url } = ctx;
|
|
17
|
+
|
|
18
|
+
if (url.pathname === "/api/github/star" && req.method === "GET") {
|
|
19
|
+
const { getStarStatus } = await import("../../github/star-state");
|
|
20
|
+
return jsonResponse(await getStarStatus());
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (url.pathname === "/api/github/star" && req.method === "POST") {
|
|
24
|
+
const { starRepository } = await import("../../github/star-state");
|
|
25
|
+
const result = await starRepository();
|
|
26
|
+
return jsonResponse({
|
|
27
|
+
...result.status,
|
|
28
|
+
ok: result.ok,
|
|
29
|
+
...(result.code ? { code: result.code } : {}),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (url.pathname === "/api/update/badge" && req.method === "GET") {
|
|
34
|
+
const { readUpdateBadge } = await import("../../update/badge");
|
|
35
|
+
return jsonResponse(readUpdateBadge());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
@@ -65,6 +65,7 @@ import { handleAgentSettingsRoutes } from "./management/agent-settings-routes";
|
|
|
65
65
|
import { handleOauthAccountRoutes } from "./management/oauth-account-routes";
|
|
66
66
|
import { handleComboRoutes } from "./management/combo-routes";
|
|
67
67
|
import { handleSystemRoutes } from "./management/system-routes";
|
|
68
|
+
import { handleSidebarRoutes } from "./management/sidebar-routes";
|
|
68
69
|
import type { ManagementContext } from "./management/context";
|
|
69
70
|
export type { ManagementApiDeps } from "./management/context";
|
|
70
71
|
import { fetchAllModels } from "./management/shared";
|
|
@@ -130,7 +131,8 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
130
131
|
?? (await handleAgentSettingsRoutes(ctx))
|
|
131
132
|
?? (await handleOauthAccountRoutes(ctx))
|
|
132
133
|
?? (await handleComboRoutes(ctx))
|
|
133
|
-
?? (await handleSystemRoutes(ctx))
|
|
134
|
+
?? (await handleSystemRoutes(ctx))
|
|
135
|
+
?? (await handleSidebarRoutes(ctx));
|
|
134
136
|
if (routed) return routed;
|
|
135
137
|
|
|
136
138
|
if (url.pathname === "/api/stop" && req.method === "POST") {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Server } from "bun";
|
|
2
2
|
import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "../../bridge";
|
|
3
3
|
import { formatPassthroughUpstreamError } from "./passthrough-error";
|
|
4
|
+
import { describeUpstreamConnectFailure } from "./upstream-error";
|
|
4
5
|
import {
|
|
5
6
|
getConfigPath,
|
|
6
7
|
multiAgentGuidanceEnabled,
|
|
@@ -173,16 +174,9 @@ export function sidecarOutcomeRecorder(
|
|
|
173
174
|
|
|
174
175
|
|
|
175
176
|
|
|
176
|
-
|
|
177
|
+
import { isShadowSourceModel } from "../../lib/shadow-call";
|
|
177
178
|
|
|
178
|
-
export
|
|
179
|
-
if (modelId.includes("/")) return false;
|
|
180
|
-
const configuredStrings = Array.isArray(configured)
|
|
181
|
-
? configured.filter((v): v is string => typeof v === "string" && v.trim() !== "")
|
|
182
|
-
: [];
|
|
183
|
-
const prefixes = configuredStrings.length > 0 ? configuredStrings : DEFAULT_SHADOW_SOURCE_MODELS;
|
|
184
|
-
return prefixes.some(prefix => modelId.startsWith(prefix.trim()));
|
|
185
|
-
}
|
|
179
|
+
export { DEFAULT_SHADOW_SOURCE_MODELS, isShadowSourceModel, shadowSourceModels } from "../../lib/shadow-call";
|
|
186
180
|
|
|
187
181
|
|
|
188
182
|
|
|
@@ -1143,10 +1137,6 @@ export async function handleResponses(
|
|
|
1143
1137
|
}
|
|
1144
1138
|
if (parsed._compactionRequest === true) parsed._cursorIsolateConversation = true;
|
|
1145
1139
|
|
|
1146
|
-
if (isThreadSpawnRequest(req.headers)) {
|
|
1147
|
-
await maybePrimeSubagentQuota(config);
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
1140
|
let route: RouteResult;
|
|
1151
1141
|
try {
|
|
1152
1142
|
route = routeModel(config, parsed.modelId);
|
|
@@ -1157,6 +1147,17 @@ export async function handleResponses(
|
|
|
1157
1147
|
return formatErrorResponse(404, "invalid_request_error", err instanceof Error ? err.message : String(err));
|
|
1158
1148
|
}
|
|
1159
1149
|
|
|
1150
|
+
const hasUnexpandedPreviousResponse = !!parsed.previousResponseId
|
|
1151
|
+
&& parsed._previousResponseInputExpanded !== true;
|
|
1152
|
+
// A canonical replay miss must not poll quota upstream before the final fail-closed decision.
|
|
1153
|
+
// Cached fallback state can still select a provider with native continuation support below.
|
|
1154
|
+
if (
|
|
1155
|
+
isThreadSpawnRequest(req.headers)
|
|
1156
|
+
&& !(hasUnexpandedPreviousResponse && isCanonicalOpenAiForwardProvider(route.provider))
|
|
1157
|
+
) {
|
|
1158
|
+
await maybePrimeSubagentQuota(config);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1160
1161
|
let authCtx: CodexAuthContext = { kind: "main", accountId: null };
|
|
1161
1162
|
let selectedForwardHeaders = req.headers;
|
|
1162
1163
|
let subagentFallbackAccountId = config.activeCodexAccountId ?? null;
|
|
@@ -1205,6 +1206,20 @@ export async function handleResponses(
|
|
|
1205
1206
|
return unreadableEncryptedAgentTaskResponse();
|
|
1206
1207
|
}
|
|
1207
1208
|
|
|
1209
|
+
// The canonical ChatGPT backend rejects previous_response_id, so a local replay miss leaves no
|
|
1210
|
+
// safe way to recover the omitted history. Fail before auth, adapter construction, or upstream
|
|
1211
|
+
// I/O instead of stripping the id and silently forwarding a context-free delta (#702).
|
|
1212
|
+
if (
|
|
1213
|
+
hasUnexpandedPreviousResponse
|
|
1214
|
+
&& isCanonicalOpenAiForwardProvider(route.provider)
|
|
1215
|
+
) {
|
|
1216
|
+
return formatErrorResponse(
|
|
1217
|
+
400,
|
|
1218
|
+
"invalid_request_error",
|
|
1219
|
+
"OpenAI forward continuation state is unavailable or expired; start a new session instead of reusing this previous_response_id.",
|
|
1220
|
+
);
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1208
1223
|
await applyFinalRouteRequestNormalization({ parsed, route, config, req, logCtx });
|
|
1209
1224
|
|
|
1210
1225
|
{
|
|
@@ -1443,7 +1458,7 @@ export async function handleResponses(
|
|
|
1443
1458
|
}
|
|
1444
1459
|
const msg = outcome === "timeout"
|
|
1445
1460
|
? `Provider connect timeout after ${connectMs}ms`
|
|
1446
|
-
:
|
|
1461
|
+
: describeUpstreamConnectFailure(err, connectMs);
|
|
1447
1462
|
return formatErrorResponse(502, "upstream_error", msg);
|
|
1448
1463
|
};
|
|
1449
1464
|
try {
|
|
@@ -2101,9 +2116,7 @@ export async function handleResponses(
|
|
|
2101
2116
|
cleanupUpstreamAbort();
|
|
2102
2117
|
upstream.abort();
|
|
2103
2118
|
if (options.abortSignal?.aborted) return clientCancelledResponse();
|
|
2104
|
-
const msg = err
|
|
2105
|
-
? `Provider connect timeout after ${connectMs}ms`
|
|
2106
|
-
: `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
|
|
2119
|
+
const msg = describeUpstreamConnectFailure(err, connectMs);
|
|
2107
2120
|
return formatErrorResponse(502, "upstream_error", msg);
|
|
2108
2121
|
}
|
|
2109
2122
|
|
|
@@ -2143,9 +2156,7 @@ export async function handleResponses(
|
|
|
2143
2156
|
if (options.abortSignal?.aborted) {
|
|
2144
2157
|
return { failed: clientCancelledResponse() };
|
|
2145
2158
|
}
|
|
2146
|
-
const msg = err
|
|
2147
|
-
? `Provider connect timeout after ${connectMs}ms`
|
|
2148
|
-
: `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
|
|
2159
|
+
const msg = describeUpstreamConnectFailure(err, connectMs);
|
|
2149
2160
|
return { failed: formatErrorResponse(502, "upstream_error", msg) };
|
|
2150
2161
|
}
|
|
2151
2162
|
};
|