@bitkyc08/opencodex 2.7.43 → 2.8.2-preview.20260731
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-BHsKRFh9.css +1 -0
- package/gui/dist/assets/index-GC0Vlu1Z.js +67 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +42 -7
- 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/adapters/openai-chat.ts +55 -4
- 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-desktop.ts +2 -2
- package/src/cli/claude.ts +32 -7
- package/src/cli/doctor.ts +48 -1
- package/src/cli/index.ts +5 -0
- package/src/cli/init.ts +129 -102
- 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/metadata.ts +6 -0
- 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 +3 -3
- 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 +21 -3
- package/src/lib/provider-outbound.ts +8 -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/lib/winsw.ts +6 -0
- package/src/oauth/index.ts +29 -5
- package/src/oauth/key-providers.ts +21 -2
- package/src/oauth/kiro-credentials.ts +129 -9
- package/src/oauth/kiro.ts +15 -3
- 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/free-directory.ts +4 -1
- 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/index.ts +3 -3
- package/src/server/live.ts +75 -25
- package/src/server/management/agent-settings-routes.ts +82 -8
- package/src/server/management/config-routes.ts +24 -7
- package/src/server/management/context.ts +11 -1
- package/src/server/management/model-routes.ts +61 -14
- package/src/server/management/provider-routes.ts +44 -9
- package/src/server/management/shared.ts +18 -5
- package/src/server/management/sidebar-routes.ts +39 -0
- package/src/server/management-api.ts +3 -1
- package/src/server/proxy-liveness.ts +9 -2
- 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 +395 -31
- 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 +86 -13
- package/src/types.ts +16 -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
package/src/server/live.ts
CHANGED
|
@@ -197,42 +197,92 @@ export function parseLiveSidebandTarget(pathname: string, searchParams: URLSearc
|
|
|
197
197
|
return null;
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
/**
|
|
201
|
+
* True for the loopback hosts plaintext development servers listen on.
|
|
202
|
+
* `URL.hostname` keeps the brackets on IPv6, so both forms are accepted.
|
|
203
|
+
*/
|
|
204
|
+
function isLoopbackHost(hostname: string): boolean {
|
|
205
|
+
const lower = hostname.toLowerCase();
|
|
206
|
+
return lower === "localhost" || lower.endsWith(".localhost")
|
|
207
|
+
|| lower === "127.0.0.1" || lower.startsWith("127.")
|
|
208
|
+
|| lower === "::1" || lower === "[::1]";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Normalize the sideband base to end in exactly `/v1`, with no query, fragment,
|
|
213
|
+
* or userinfo. Any failure closes to the canonical Realtime API root — never to
|
|
214
|
+
* the input — because this string decides where upstream bearer credentials and
|
|
215
|
+
* user audio are sent.
|
|
216
|
+
*
|
|
217
|
+
* Bounds, all fail-closed:
|
|
218
|
+
* - scheme must be https/wss, or http/ws with a loopback host (the local
|
|
219
|
+
* development case this knob exists for);
|
|
220
|
+
* - URL userinfo is rejected (URL#toString would forward it verbatim);
|
|
221
|
+
* - unparseable input is rejected.
|
|
222
|
+
*
|
|
223
|
+
* Endpoint-form overrides are recognized the way upstream recognizes them
|
|
224
|
+
* (codex-rs realtime_websocket/methods.rs:994): a terminal `/realtime`,
|
|
225
|
+
* `/realtime/calls/<id>`, or `/live/<id>` is stripped so the root can be
|
|
226
|
+
* re-derived. A path prefix survives (`https://host/api/v1` keeps `/api`).
|
|
227
|
+
*/
|
|
228
|
+
function normalizeSidebandRoot(baseUrl: string): string {
|
|
229
|
+
let parsed: URL;
|
|
230
|
+
try {
|
|
231
|
+
parsed = new URL(baseUrl);
|
|
232
|
+
} catch {
|
|
233
|
+
return LIVE_SIDEBAND_API_ROOT;
|
|
234
|
+
}
|
|
235
|
+
const secure = parsed.protocol === "https:" || parsed.protocol === "wss:";
|
|
236
|
+
const plaintext = parsed.protocol === "http:" || parsed.protocol === "ws:";
|
|
237
|
+
if ((!secure && !plaintext) || (plaintext && !isLoopbackHost(parsed.hostname)) || parsed.username || parsed.password) {
|
|
238
|
+
return LIVE_SIDEBAND_API_ROOT;
|
|
239
|
+
}
|
|
240
|
+
parsed.search = "";
|
|
241
|
+
parsed.hash = "";
|
|
242
|
+
const path = parsed.pathname
|
|
243
|
+
.replace(/\/+$/, "")
|
|
244
|
+
.replace(/\/realtime(?:\/calls\/[^/]+)?$/, "")
|
|
245
|
+
.replace(/\/live\/[^/]+$/, "")
|
|
246
|
+
.replace(/\/v1$/, "");
|
|
247
|
+
parsed.pathname = `${path}/v1`;
|
|
248
|
+
return parsed.toString().replace(/\/$/, "");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Resolve the sideband base. Upstream policy (codex-rs 438c9e98d): the sideband
|
|
253
|
+
* join is NOT derived from the selected model provider — precedence is exactly
|
|
254
|
+
* the explicit override when configured, otherwise the canonical Realtime API
|
|
255
|
+
* root. The provider base URL deliberately plays no part; a user who needs a
|
|
256
|
+
* non-canonical host sets the override, the same escape hatch upstream ships as
|
|
257
|
+
* `experimental_realtime_ws_base_url`.
|
|
258
|
+
*/
|
|
259
|
+
function sidebandBaseRoot(overrideBaseUrl?: string): string {
|
|
260
|
+
return normalizeSidebandRoot(overrideBaseUrl?.trim() || LIVE_SIDEBAND_API_ROOT);
|
|
261
|
+
}
|
|
262
|
+
|
|
200
263
|
/**
|
|
201
264
|
* Build the upstream sideband WebSocket URL for a resolved OpenAI/ChatGPT provider.
|
|
202
265
|
* Mirrors openai/codex `websocket_url_from_api_url_for_call` + `normalize_realtime_path`.
|
|
266
|
+
*
|
|
267
|
+
* Deliberate deviation: the realtime-query style keeps `intent=quicksilver`,
|
|
268
|
+
* which upstream does not send. That URL is live against real OpenAI
|
|
269
|
+
* infrastructure for every canonical voice user and this parameter is known to
|
|
270
|
+
* work; dropping it is future work gated on a live smoke test. Parity here is
|
|
271
|
+
* scoped to the host, override precedence, and provider-query exclusion.
|
|
203
272
|
*/
|
|
204
273
|
export function buildLiveSidebandUpstreamWsUrl(
|
|
205
|
-
providerBaseUrl: string,
|
|
206
|
-
usesBackendShape: boolean,
|
|
207
274
|
target: LiveSidebandTarget,
|
|
275
|
+
overrideBaseUrl?: string,
|
|
208
276
|
): string {
|
|
209
|
-
const
|
|
210
|
-
if (usesBackendShape) {
|
|
211
|
-
// ChatGPT backend-api call-create, but the sideband join lives on the public API host
|
|
212
|
-
// (matches openai/codex, which builds the sideband from the ApiKey provider default).
|
|
213
|
-
if (target.style === "frameless-path") {
|
|
214
|
-
return httpsToWss(`${LIVE_SIDEBAND_API_ROOT}/live/${target.callId}`);
|
|
215
|
-
}
|
|
216
|
-
if (target.style === "realtime-calls-path") {
|
|
217
|
-
return httpsToWss(`${LIVE_SIDEBAND_API_ROOT}/realtime/calls/${target.callId}`);
|
|
218
|
-
}
|
|
219
|
-
return httpsToWss(
|
|
220
|
-
`${LIVE_SIDEBAND_API_ROOT}/realtime?intent=quicksilver&call_id=${encodeURIComponent(target.callId)}`,
|
|
221
|
-
);
|
|
222
|
-
}
|
|
277
|
+
const sidebandRoot = sidebandBaseRoot(overrideBaseUrl);
|
|
223
278
|
if (target.style === "frameless-path") {
|
|
224
|
-
|
|
225
|
-
const apiRoot = root.replace(/\/v1\/?$/, "");
|
|
226
|
-
return httpsToWss(`${apiRoot}/v1/live/${target.callId}`);
|
|
279
|
+
return httpsToWss(`${sidebandRoot}/live/${target.callId}`);
|
|
227
280
|
}
|
|
228
281
|
if (target.style === "realtime-calls-path") {
|
|
229
|
-
|
|
230
|
-
return httpsToWss(`${apiRoot}/v1/realtime/calls/${target.callId}`);
|
|
282
|
+
return httpsToWss(`${sidebandRoot}/realtime/calls/${target.callId}`);
|
|
231
283
|
}
|
|
232
|
-
// Realtime v1/v2: /v1/realtime?intent=quicksilver&call_id=
|
|
233
|
-
const apiRoot = root.replace(/\/v1\/?$/, "");
|
|
234
284
|
return httpsToWss(
|
|
235
|
-
`${
|
|
285
|
+
`${sidebandRoot}/realtime?intent=quicksilver&call_id=${encodeURIComponent(target.callId)}`,
|
|
236
286
|
);
|
|
237
287
|
}
|
|
238
288
|
|
|
@@ -542,7 +592,7 @@ export async function resolveLiveSidebandUpgrade(
|
|
|
542
592
|
if (relay instanceof Response) return relay;
|
|
543
593
|
return {
|
|
544
594
|
headers: relay.headers,
|
|
545
|
-
upstreamWsUrl: buildLiveSidebandUpstreamWsUrl(
|
|
595
|
+
upstreamWsUrl: buildLiveSidebandUpstreamWsUrl(target, config.experimentalRealtimeWsBaseUrl),
|
|
546
596
|
recordOutcome: relay.recordOutcome,
|
|
547
597
|
};
|
|
548
598
|
}
|
|
@@ -80,12 +80,12 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
80
80
|
if (config.claudeCode?.desktopAutoApply === false) return;
|
|
81
81
|
if (!config.claudeCode?.desktopProfile) return;
|
|
82
82
|
const { writeDesktop3pConfig } = await import("../../claude/desktop-3p");
|
|
83
|
-
const {
|
|
83
|
+
const { filterCatalogVisibleModels, desktopVisibleNativeSlugs } = await import("../../codex/catalog");
|
|
84
84
|
const allModels = await fetchAllModels(config);
|
|
85
85
|
const routed = filterCatalogVisibleModels(allModels, config).map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow }));
|
|
86
86
|
const result = writeDesktop3pConfig(
|
|
87
87
|
config.port ?? 10100,
|
|
88
|
-
[...
|
|
88
|
+
[...desktopVisibleNativeSlugs(config)],
|
|
89
89
|
routed,
|
|
90
90
|
config.apiKeys?.[0]?.key,
|
|
91
91
|
"static",
|
|
@@ -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
|
}
|
|
@@ -533,7 +607,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
533
607
|
config.claudeCode = { ...(config.claudeCode ?? {}), desktopProfile: state.profile };
|
|
534
608
|
saveConfigPreservingClaudeCode(config);
|
|
535
609
|
const { writeDesktop3pConfig } = await import("../../claude/desktop-3p");
|
|
536
|
-
const {
|
|
610
|
+
const { desktopVisibleNativeSlugs } = await import("../../codex/catalog");
|
|
537
611
|
const routed = state.models
|
|
538
612
|
.filter(model => model.available && !model.route.startsWith("native/"))
|
|
539
613
|
.map(model => {
|
|
@@ -542,7 +616,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
542
616
|
});
|
|
543
617
|
const result = writeDesktop3pConfig(
|
|
544
618
|
Number(url.port) || config.port,
|
|
545
|
-
[...
|
|
619
|
+
[...desktopVisibleNativeSlugs(config)],
|
|
546
620
|
routed,
|
|
547
621
|
config.apiKeys?.[0]?.key,
|
|
548
622
|
"static",
|
|
@@ -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";
|
|
@@ -110,6 +111,11 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
110
111
|
);
|
|
111
112
|
}
|
|
112
113
|
return jsonResponse({
|
|
114
|
+
// The dashboard renders request-log timestamps. Without this it formats them in the
|
|
115
|
+
// BROWSER's zone, so a KST proxy viewed from a UTC browser reports every request nine
|
|
116
|
+
// hours off (#725). Carried on settings rather than /api/logs because that route's
|
|
117
|
+
// array response has four consumers that would have to change with it.
|
|
118
|
+
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
113
119
|
codexAutoStart: codexAutoStartEnabled(config),
|
|
114
120
|
port: config.port,
|
|
115
121
|
hostname: config.hostname ?? "127.0.0.1",
|
|
@@ -140,16 +146,20 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
140
146
|
}
|
|
141
147
|
|
|
142
148
|
if (url.pathname === "/api/startup-action" && req.method === "POST") {
|
|
143
|
-
let body: { action?: unknown };
|
|
149
|
+
let body: { action?: unknown; repair?: unknown };
|
|
144
150
|
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
145
151
|
if (!body || !["install-service", "install-shim"].includes(String(body.action))) {
|
|
146
152
|
return jsonResponse({ error: "action must be install-service or install-shim" }, 400);
|
|
147
153
|
}
|
|
154
|
+
if (body.repair !== undefined && typeof body.repair !== "boolean") {
|
|
155
|
+
return jsonResponse({ error: "repair must be a boolean when provided" }, 400);
|
|
156
|
+
}
|
|
148
157
|
try {
|
|
149
158
|
const action = body.action as StartupInstallAction;
|
|
150
|
-
const
|
|
159
|
+
const repair = body.repair === true;
|
|
160
|
+
const result = await (deps.runStartupInstallAction ?? runStartupInstallAction)(action, { repair });
|
|
151
161
|
invalidateStartupHealthCache();
|
|
152
|
-
return jsonResponse({ ok: true, action, message: result.message });
|
|
162
|
+
return jsonResponse({ ok: true, action, repair, message: result.message });
|
|
153
163
|
} catch (error) {
|
|
154
164
|
return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 500);
|
|
155
165
|
}
|
|
@@ -227,8 +237,6 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
227
237
|
const { syncModelsToCodex } = await import("../../codex/sync");
|
|
228
238
|
const { attachStaleAppServerHint } = await import("../../codex/app-server-processes");
|
|
229
239
|
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
240
|
return jsonResponse({
|
|
233
241
|
...attachStaleAppServerHint(result),
|
|
234
242
|
...(result.ok ? {} : { error: result.message }),
|
|
@@ -352,7 +360,11 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
352
360
|
|
|
353
361
|
if (url.pathname === "/api/shadow-call-settings" && req.method === "GET") {
|
|
354
362
|
const sci = config.shadowCallIntercept ?? {};
|
|
355
|
-
return jsonResponse({
|
|
363
|
+
return jsonResponse({
|
|
364
|
+
enabled: sci.enabled === true,
|
|
365
|
+
model: sci.model ?? "",
|
|
366
|
+
sourceModels: shadowSourceModels(sci.sourceModels),
|
|
367
|
+
});
|
|
356
368
|
}
|
|
357
369
|
|
|
358
370
|
if (url.pathname === "/api/shadow-call-settings" && req.method === "PUT") {
|
|
@@ -374,7 +386,12 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
374
386
|
}
|
|
375
387
|
saveConfigPreservingClaudeCode(config);
|
|
376
388
|
const sci = config.shadowCallIntercept;
|
|
377
|
-
return jsonResponse({
|
|
389
|
+
return jsonResponse({
|
|
390
|
+
ok: true,
|
|
391
|
+
enabled: sci.enabled === true,
|
|
392
|
+
model: sci.model ?? "",
|
|
393
|
+
sourceModels: shadowSourceModels(sci.sourceModels),
|
|
394
|
+
});
|
|
378
395
|
}
|
|
379
396
|
return null;
|
|
380
397
|
}
|
|
@@ -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";
|
|
@@ -54,7 +83,7 @@ import { drainAndShutdown } from "../lifecycle";
|
|
|
54
83
|
import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log";
|
|
55
84
|
import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
|
|
56
85
|
import type { PersistedUsageAttempt } from "../../usage/log";
|
|
57
|
-
import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors";
|
|
86
|
+
import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO, corsHeaders } from "../auth-cors";
|
|
58
87
|
import { applySystemEnvToggle } from "../system-env";
|
|
59
88
|
|
|
60
89
|
import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
|
|
@@ -63,6 +92,25 @@ 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;
|
|
100
|
+
|
|
101
|
+
if (url.pathname === "/api/catalog" && req.method === "GET") {
|
|
102
|
+
const { readCatalog, readCodexCatalogPath } = await import("../../codex/catalog");
|
|
103
|
+
const catalog = readCatalog(readCodexCatalogPath());
|
|
104
|
+
if (!catalog) return jsonResponse({ error: "catalog not found" }, 404, req, config);
|
|
105
|
+
const headers: Record<string, string> = {
|
|
106
|
+
"Content-Type": "application/json",
|
|
107
|
+
...corsHeaders(req, config),
|
|
108
|
+
};
|
|
109
|
+
const { loadPersistedCodexRuntime } = await import("../../codex/runtime");
|
|
110
|
+
const version = loadPersistedCodexRuntime()?.selectedVersion;
|
|
111
|
+
if (version) headers["x-opencodex-codex-version"] = version;
|
|
112
|
+
return new Response(JSON.stringify(catalog), { status: 200, headers });
|
|
113
|
+
}
|
|
66
114
|
|
|
67
115
|
if (url.pathname === "/api/models" && req.method === "GET") {
|
|
68
116
|
const models = await fetchAllModels(config);
|
|
@@ -123,8 +171,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
123
171
|
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
124
172
|
const disabled = Array.isArray(body.models) ? body.models.filter((m): m is string => typeof m === "string") : [];
|
|
125
173
|
config.disabledModels = disabled;
|
|
126
|
-
|
|
127
|
-
save(config);
|
|
174
|
+
persistConfig(config);
|
|
128
175
|
await refreshCodexCatalogBestEffort();
|
|
129
176
|
return jsonResponse({ ok: true, disabled });
|
|
130
177
|
}
|
|
@@ -223,7 +270,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
223
270
|
}
|
|
224
271
|
|
|
225
272
|
config.disabledModels = disabled;
|
|
226
|
-
|
|
273
|
+
persistConfig(config);
|
|
227
274
|
await refreshCodexCatalogBestEffort();
|
|
228
275
|
return jsonResponse({ ok: true, scope, provider, enabled: body.enabled, disabled });
|
|
229
276
|
}
|
|
@@ -244,7 +291,9 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
244
291
|
const displayName = typeof body.displayName === "string" && body.displayName.trim() ? body.displayName.trim() : undefined;
|
|
245
292
|
if (displayName?.includes("/")) return jsonResponse({ error: "displayName must not contain /" }, 400);
|
|
246
293
|
const contextWindow = typeof body.contextWindow === "number" && body.contextWindow > 0 ? Math.floor(body.contextWindow) : undefined;
|
|
247
|
-
const
|
|
294
|
+
const modalities = readInputModalities(body.inputModalities);
|
|
295
|
+
if (modalities.error) return jsonResponse({ error: modalities.error }, 400);
|
|
296
|
+
const inputModalities = modalities.values;
|
|
248
297
|
const existing = config.customModels ?? [];
|
|
249
298
|
const newSlug = routedSlug(provider, modelId);
|
|
250
299
|
if (existing.some(cm => routedSlug(cm.provider, cm.modelId) === newSlug)) {
|
|
@@ -260,8 +309,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
260
309
|
addedAt: new Date().toISOString(),
|
|
261
310
|
};
|
|
262
311
|
config.customModels = [...existing, entry];
|
|
263
|
-
|
|
264
|
-
save(config);
|
|
312
|
+
persistConfig(config);
|
|
265
313
|
await refreshCodexCatalogBestEffort();
|
|
266
314
|
return jsonResponse(entry, 201);
|
|
267
315
|
}
|
|
@@ -289,7 +337,9 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
289
337
|
cm.contextWindow = typeof body.contextWindow === "number" && body.contextWindow > 0 ? Math.floor(body.contextWindow) : undefined;
|
|
290
338
|
}
|
|
291
339
|
if (body.inputModalities !== undefined) {
|
|
292
|
-
|
|
340
|
+
const edited = readInputModalities(body.inputModalities);
|
|
341
|
+
if (edited.error) return jsonResponse({ error: edited.error }, 400);
|
|
342
|
+
cm.inputModalities = edited.values && edited.values.length > 0 ? edited.values : undefined;
|
|
293
343
|
}
|
|
294
344
|
const updatedSlug = routedSlug(cm.provider, cm.modelId);
|
|
295
345
|
if (list.some((other, i) => i !== idx && routedSlug(other.provider, other.modelId) === updatedSlug)) {
|
|
@@ -297,8 +347,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
297
347
|
}
|
|
298
348
|
list[idx] = cm;
|
|
299
349
|
config.customModels = list;
|
|
300
|
-
|
|
301
|
-
save(config);
|
|
350
|
+
persistConfig(config);
|
|
302
351
|
await refreshCodexCatalogBestEffort();
|
|
303
352
|
return jsonResponse(cm);
|
|
304
353
|
}
|
|
@@ -312,8 +361,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
312
361
|
if (idx === -1) return jsonResponse({ error: "not found" }, 404);
|
|
313
362
|
list.splice(idx, 1);
|
|
314
363
|
config.customModels = list.length > 0 ? list : undefined;
|
|
315
|
-
|
|
316
|
-
save(config);
|
|
364
|
+
persistConfig(config);
|
|
317
365
|
await refreshCodexCatalogBestEffort();
|
|
318
366
|
return jsonResponse({ ok: true });
|
|
319
367
|
}
|
|
@@ -349,8 +397,7 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
349
397
|
// Empty list clears the allowlist (provider reverts to exposing all models).
|
|
350
398
|
if (models.length > 0) config.providers[provider].selectedModels = models;
|
|
351
399
|
else delete config.providers[provider].selectedModels;
|
|
352
|
-
|
|
353
|
-
save(config);
|
|
400
|
+
persistConfig(config);
|
|
354
401
|
await refreshCodexCatalogBestEffort();
|
|
355
402
|
return jsonResponse({ ok: true, provider, selected: models });
|
|
356
403
|
}
|
|
@@ -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
|
}
|