@bitkyc08/opencodex 2.7.35 → 2.7.36
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.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +4 -2
- package/README.ru.md +1 -1
- package/README.zh-CN.md +1 -1
- package/bin/ocx.mjs +52 -0
- package/gui/dist/assets/index-BpX-hoSd.css +1 -0
- package/gui/dist/assets/index-ZmFopEYw.js +52 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/cursor/cursor-errors.ts +38 -1
- package/src/adapters/cursor/discovery.ts +1 -0
- package/src/adapters/cursor/effort-map.ts +1 -0
- package/src/adapters/cursor/live-models.ts +22 -5
- package/src/adapters/cursor/live-transport.ts +82 -7
- package/src/adapters/cursor/transport.ts +2 -0
- package/src/adapters/cursor.ts +5 -2
- package/src/adapters/openai-responses.ts +64 -1
- package/src/cli/doctor.ts +10 -0
- package/src/cli/help.ts +10 -0
- package/src/cli/index.ts +88 -9
- package/src/cli/internal-dispatch.ts +20 -0
- package/src/cli/status.ts +15 -4
- package/src/cli/tray-proxy.ts +52 -0
- package/src/codex/auth-api.ts +46 -5
- package/src/codex/autostart-health.ts +149 -0
- package/src/codex/catalog/aggregation.ts +268 -0
- package/src/codex/catalog/bundled.ts +188 -0
- package/src/codex/catalog/effort.ts +263 -0
- package/src/codex/catalog/metadata.ts +176 -0
- package/src/codex/catalog/parsing.ts +399 -0
- package/src/codex/catalog/provider-fetch.ts +609 -0
- package/src/codex/catalog/sync.ts +540 -0
- package/src/codex/catalog.ts +11 -2426
- package/src/codex/inject.ts +165 -3
- package/src/codex/shim.ts +141 -8
- package/src/codex/sync.ts +17 -2
- package/src/config.ts +23 -0
- package/src/lib/errors.ts +11 -0
- package/src/providers/antigravity-models.ts +33 -0
- package/src/providers/kiro-models.ts +2 -0
- package/src/providers/registry.ts +2 -2
- package/src/responses/state.ts +69 -6
- package/src/server/auth-cors.ts +3 -0
- package/src/server/management/agent-settings-routes.ts +536 -0
- package/src/server/management/combo-routes.ts +210 -0
- package/src/server/management/config-routes.ts +302 -0
- package/src/server/management/context.ts +21 -0
- package/src/server/management/logs-usage-routes.ts +176 -0
- package/src/server/management/model-routes.ts +253 -0
- package/src/server/management/oauth-account-routes.ts +301 -0
- package/src/server/management/provider-routes.ts +408 -0
- package/src/server/management/shared.ts +186 -0
- package/src/server/management-api.ts +23 -1806
- package/src/server/responses/collaboration.ts +300 -0
- package/src/server/responses/compact.ts +342 -0
- package/src/server/responses/core.ts +1498 -0
- package/src/server/responses/encrypted-payload.ts +231 -0
- package/src/server/responses/fetch-helpers.ts +157 -0
- package/src/server/responses.ts +9 -2172
- package/src/server/startup-action-control.ts +41 -0
- package/src/server/startup-health-cache.ts +100 -0
- package/src/server/windows-tray-control.ts +41 -0
- package/src/service.ts +171 -19
- package/src/tray/assets/opencodex-tray-offline.ico +0 -0
- package/src/tray/assets/opencodex-tray-online.ico +0 -0
- package/src/tray/assets/opencodex-tray-warning.ico +0 -0
- package/src/tray/assets/opencodex-tray.png +0 -0
- package/src/tray/windows-tray.ps1 +290 -0
- package/src/tray/windows.ts +628 -0
- package/src/types.ts +5 -0
- package/src/update/index.ts +43 -0
- package/src/update/job.ts +46 -0
- package/src/update/tray-update-plan.d.mts +18 -0
- package/src/update/tray-update-plan.mjs +38 -0
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +9 -2
- package/src/usage/summary.ts +42 -7
- package/gui/dist/assets/index-BunUANVE.js +0 -52
- package/gui/dist/assets/index-Sg-7L_oZ.css +0 -1
|
@@ -56,7 +56,18 @@ import type { PersistedUsageAttempt } from "../usage/log";
|
|
|
56
56
|
import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "./auth-cors";
|
|
57
57
|
import { applySystemEnvToggle } from "./system-env";
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
import type { ManagementApiDeps } from "./management/context";
|
|
60
|
+
import { handleConfigRoutes } from "./management/config-routes";
|
|
61
|
+
import { handleLogsUsageRoutes } from "./management/logs-usage-routes";
|
|
62
|
+
import { handleProviderRoutes } from "./management/provider-routes";
|
|
63
|
+
import { handleModelRoutes } from "./management/model-routes";
|
|
64
|
+
import { handleAgentSettingsRoutes } from "./management/agent-settings-routes";
|
|
65
|
+
import { handleOauthAccountRoutes } from "./management/oauth-account-routes";
|
|
66
|
+
import { handleComboRoutes } from "./management/combo-routes";
|
|
67
|
+
import type { ManagementContext } from "./management/context";
|
|
68
|
+
export type { ManagementApiDeps } from "./management/context";
|
|
69
|
+
import { fetchAllModels } from "./management/shared";
|
|
70
|
+
|
|
60
71
|
// installed npm version instead of a stale hardcode.
|
|
61
72
|
export const VERSION = (() => {
|
|
62
73
|
try {
|
|
@@ -66,118 +77,6 @@ export const VERSION = (() => {
|
|
|
66
77
|
}
|
|
67
78
|
})();
|
|
68
79
|
|
|
69
|
-
export interface ManagementApiDeps {
|
|
70
|
-
toggleCodexMultiAgentV2?: (enabled: boolean) => void;
|
|
71
|
-
refreshCodexCatalog?: () => Promise<void>;
|
|
72
|
-
clearThreadAccountMap?: () => void;
|
|
73
|
-
clearProviderQuotaCache?: () => void;
|
|
74
|
-
primeCodexPoolQuotas?: (config: OcxConfig, reason: string) => Promise<void> | void;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Narrow an unknown JSON value to a plain (non-array) object for strict request-body validation. */
|
|
78
|
-
function isPlainRecord(v: unknown): v is Record<string, unknown> {
|
|
79
|
-
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function parseDebugLogQuery(url: URL): { after: number; limit: number } {
|
|
83
|
-
const after = Number(url.searchParams.get("after") ?? url.searchParams.get("since") ?? "0");
|
|
84
|
-
const limit = Number(url.searchParams.get("limit") ?? "500");
|
|
85
|
-
return {
|
|
86
|
-
after: Number.isFinite(after) && after > 0 ? after : 0,
|
|
87
|
-
limit: Number.isFinite(limit) && limit > 0 ? Math.min(limit, 2000) : 500,
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// ---- /api/logs display metrics (devlog/_plan/260720_toks_speed_price_columns/020) ----
|
|
92
|
-
// Derived at response time only; NEVER persisted to the request log or usage.jsonl.
|
|
93
|
-
|
|
94
|
-
type MetricUnavailableReason =
|
|
95
|
-
| "usage_missing" | "usage_unsupported" | "output_missing" | "invalid_duration"
|
|
96
|
-
| "price_unmatched" | "invalid_cache_breakdown"
|
|
97
|
-
| "invalid_usage" | "combo_attempt_unavailable";
|
|
98
|
-
|
|
99
|
-
type TokPerSecondResult =
|
|
100
|
-
| { kind: "value"; value: number; estimated: boolean }
|
|
101
|
-
| { kind: "unavailable"; reason: MetricUnavailableReason };
|
|
102
|
-
|
|
103
|
-
type CostEstimateReason = "usage_estimated" | "cache_detail_missing" | "expected_price_overlay";
|
|
104
|
-
|
|
105
|
-
type CostResult =
|
|
106
|
-
| { kind: "value"; estimate: NonNullable<ReturnType<typeof estimateRequestCost>>; estimateReasons: CostEstimateReason[] }
|
|
107
|
-
| { kind: "unavailable"; reason: MetricUnavailableReason };
|
|
108
|
-
|
|
109
|
-
type MetricSource = Pick<RequestLogEntry, "provider" | "model" | "durationMs" | "usageStatus" | "usage"> & {
|
|
110
|
-
attempts?: readonly PersistedUsageAttempt[];
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
function tokPerSecondResult(entry: Pick<MetricSource, "durationMs" | "usageStatus" | "usage">): TokPerSecondResult {
|
|
114
|
-
if (!entry.usage) return { kind: "unavailable", reason: "usage_missing" };
|
|
115
|
-
if (entry.usageStatus === "unsupported") return { kind: "unavailable", reason: "usage_unsupported" };
|
|
116
|
-
const value = tokensPerSecond(entry.usage.outputTokens, entry.durationMs);
|
|
117
|
-
if (value === null) {
|
|
118
|
-
return {
|
|
119
|
-
kind: "unavailable",
|
|
120
|
-
reason: entry.usage.outputTokens <= 0 ? "output_missing" : "invalid_duration",
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
return { kind: "value", value, estimated: entry.usageStatus === "estimated" || entry.usage.estimated === true };
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function unavailableCostReason(entry: MetricSource): MetricUnavailableReason {
|
|
127
|
-
// Normalizer-first classification: the landed normalizer recovers legacy
|
|
128
|
-
// cachedInputTokens=read+write rows via retry, so a raw read+write>input
|
|
129
|
-
// pre-check would misclassify recoverable rows (020 audit blocker #2).
|
|
130
|
-
if (!entry.usage && !entry.attempts?.length) return "usage_missing";
|
|
131
|
-
if (entry.usageStatus === "unsupported") return "usage_unsupported";
|
|
132
|
-
if (entry.attempts?.length) return "combo_attempt_unavailable";
|
|
133
|
-
if (!entry.usage) return "usage_missing";
|
|
134
|
-
if (!normalizeCostTokens(entry.usage)) {
|
|
135
|
-
const effectiveRead = entry.usage.cacheReadInputTokens ?? entry.usage.cachedInputTokens ?? 0;
|
|
136
|
-
const effectiveWrite = entry.usage.cacheCreationInputTokens ?? 0;
|
|
137
|
-
const finite = [entry.usage.inputTokens, entry.usage.outputTokens, effectiveRead, effectiveWrite]
|
|
138
|
-
.every(v => Number.isFinite(v) && v >= 0);
|
|
139
|
-
return finite ? "invalid_cache_breakdown" : "invalid_usage";
|
|
140
|
-
}
|
|
141
|
-
return "price_unmatched";
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function costResult(entry: MetricSource): CostResult {
|
|
145
|
-
const estimate = entry.attempts?.length
|
|
146
|
-
? estimateComboCost(entry.attempts)
|
|
147
|
-
: estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus });
|
|
148
|
-
if (!estimate) return { kind: "unavailable", reason: unavailableCostReason(entry) };
|
|
149
|
-
const estimateReasons = [
|
|
150
|
-
entry.usageStatus === "estimated" || entry.usage?.estimated ? "usage_estimated" as const : undefined,
|
|
151
|
-
entry.usage && entry.usage.cachedInputTokens === undefined
|
|
152
|
-
&& entry.usage.cacheReadInputTokens === undefined
|
|
153
|
-
&& entry.usage.cacheCreationInputTokens === undefined ? "cache_detail_missing" as const : undefined,
|
|
154
|
-
estimate.price?.source === "expected" || estimate.attempts?.some(a => a.price.source === "expected")
|
|
155
|
-
? "expected_price_overlay" as const : undefined,
|
|
156
|
-
].filter((reason): reason is CostEstimateReason => reason !== undefined);
|
|
157
|
-
return { kind: "value", estimate, estimateReasons };
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function requestLogDto(entry: RequestLogEntry): Record<string, unknown> {
|
|
161
|
-
return {
|
|
162
|
-
...entry,
|
|
163
|
-
displayMetrics: {
|
|
164
|
-
tokPerSecond: tokPerSecondResult(entry),
|
|
165
|
-
cost: costResult(entry),
|
|
166
|
-
},
|
|
167
|
-
...(entry.attempts?.length
|
|
168
|
-
? {
|
|
169
|
-
attempts: entry.attempts.map(attempt => ({
|
|
170
|
-
...attempt,
|
|
171
|
-
displayMetrics: {
|
|
172
|
-
tokPerSecond: tokPerSecondResult(attempt),
|
|
173
|
-
cost: costResult({ ...attempt, attempts: undefined }),
|
|
174
|
-
},
|
|
175
|
-
})),
|
|
176
|
-
}
|
|
177
|
-
: {}),
|
|
178
|
-
};
|
|
179
|
-
}
|
|
180
|
-
|
|
181
80
|
export async function handleManagementAPI(req: Request, url: URL, config: OcxConfig, deps: ManagementApiDeps = {}): Promise<Response | null> {
|
|
182
81
|
if (!isAllowedRequestOrigin(req, config)) {
|
|
183
82
|
return jsonResponse({ error: "cross-origin request blocked" }, 403, req, config);
|
|
@@ -221,1678 +120,16 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
221
120
|
}
|
|
222
121
|
} catch { /* best-effort */ }
|
|
223
122
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
if (
|
|
234
|
-
return jsonResponse({
|
|
235
|
-
codexAutoStart: codexAutoStartEnabled(config),
|
|
236
|
-
port: config.port,
|
|
237
|
-
hostname: config.hostname ?? "127.0.0.1",
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
if (url.pathname === "/api/settings" && req.method === "PUT") {
|
|
242
|
-
let body: { codexAutoStart?: unknown };
|
|
243
|
-
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
244
|
-
if (typeof body.codexAutoStart !== "boolean") {
|
|
245
|
-
return jsonResponse({ error: "codexAutoStart boolean is required" }, 400);
|
|
246
|
-
}
|
|
247
|
-
config.codexAutoStart = body.codexAutoStart;
|
|
248
|
-
saveConfig(config);
|
|
249
|
-
return jsonResponse({ ok: true, codexAutoStart: codexAutoStartEnabled(config) });
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
if (url.pathname === "/api/diagnostics/project-config" && req.method === "GET") {
|
|
253
|
-
const { getCachedProjectConfigDiagnostics } = await import("../codex/project-config-warnings");
|
|
254
|
-
const { warnings, grouped } = getCachedProjectConfigDiagnostics();
|
|
255
|
-
return jsonResponse({ warnings, grouped });
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
if (url.pathname === "/api/sync" && req.method === "POST") {
|
|
259
|
-
const { syncModelsToCodex } = await import("../codex/sync");
|
|
260
|
-
const result = await syncModelsToCodex(undefined, config, null);
|
|
261
|
-
return jsonResponse({
|
|
262
|
-
...result,
|
|
263
|
-
staleAppServerHint: "If Codex App still shows an older model list, restart its long-lived app-server process after sync.",
|
|
264
|
-
}, result.ok ? 200 : 500);
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
if (url.pathname === "/api/update/check" && req.method === "GET") {
|
|
268
|
-
const { checkForUpdate, normalizeUpdateChannel } = await import("../update/job");
|
|
269
|
-
const rawTag = url.searchParams.get("tag");
|
|
270
|
-
if (rawTag && rawTag !== "latest" && rawTag !== "preview") {
|
|
271
|
-
return jsonResponse({ error: "tag must be latest or preview" }, 400);
|
|
272
|
-
}
|
|
273
|
-
return jsonResponse(checkForUpdate(normalizeUpdateChannel(rawTag)));
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
if (url.pathname === "/api/update/run" && req.method === "POST") {
|
|
277
|
-
const { normalizeUpdateChannel, startUpdateJob, UpdateJobError } = await import("../update/job");
|
|
278
|
-
let body: { tag?: unknown; restart?: unknown };
|
|
279
|
-
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
280
|
-
if (body.tag !== undefined && body.tag !== "latest" && body.tag !== "preview") {
|
|
281
|
-
return jsonResponse({ error: "tag must be latest or preview" }, 400);
|
|
282
|
-
}
|
|
283
|
-
if (body.restart !== undefined && typeof body.restart !== "boolean") {
|
|
284
|
-
return jsonResponse({ error: "restart boolean is required" }, 400);
|
|
285
|
-
}
|
|
286
|
-
try {
|
|
287
|
-
return jsonResponse({ ok: true, job: startUpdateJob(normalizeUpdateChannel(body.tag as string | undefined), body.restart !== false) });
|
|
288
|
-
} catch (err) {
|
|
289
|
-
if (err instanceof UpdateJobError) {
|
|
290
|
-
return jsonResponse({ error: err.message, code: err.code }, err.status);
|
|
291
|
-
}
|
|
292
|
-
return jsonResponse({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
if (url.pathname === "/api/update/status" && req.method === "GET") {
|
|
297
|
-
const { readUpdateJob } = await import("../update/job");
|
|
298
|
-
const job = readUpdateJob(url.searchParams.get("jobId"));
|
|
299
|
-
if (!job) return jsonResponse({ error: "update job not found" }, 404);
|
|
300
|
-
return jsonResponse({ ok: true, job });
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
if (url.pathname === "/api/sidecar-settings" && req.method === "GET") {
|
|
304
|
-
const ws = config.webSearchSidecar ?? {};
|
|
305
|
-
const vs = config.visionSidecar ?? {};
|
|
306
|
-
return jsonResponse({
|
|
307
|
-
webSearch: { model: ws.model ?? "gpt-5.6-luna", backend: ws.backend },
|
|
308
|
-
vision: {
|
|
309
|
-
model: vs.model ?? "gpt-5.6-luna",
|
|
310
|
-
backend: vs.backend,
|
|
311
|
-
maxDescriptionsPerTurn: vs.maxDescriptionsPerTurn,
|
|
312
|
-
},
|
|
313
|
-
});
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
if (url.pathname === "/api/sidecar-settings" && req.method === "PUT") {
|
|
317
|
-
let raw: unknown;
|
|
318
|
-
try { raw = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
319
|
-
// Strict shape (review F2): reject non-object bodies and non-object sections instead of throwing
|
|
320
|
-
// on `null` or silently accepting arrays/strings as no-op updates.
|
|
321
|
-
if (!isPlainRecord(raw)) return jsonResponse({ error: "body must be a JSON object" }, 400);
|
|
322
|
-
if (raw.webSearch !== undefined && !isPlainRecord(raw.webSearch)) return jsonResponse({ error: "webSearch must be an object" }, 400);
|
|
323
|
-
if (raw.vision !== undefined && !isPlainRecord(raw.vision)) return jsonResponse({ error: "vision must be an object" }, 400);
|
|
324
|
-
const body = raw as {
|
|
325
|
-
webSearch?: { model?: unknown; backend?: unknown; reasoning?: unknown };
|
|
326
|
-
vision?: { model?: unknown; backend?: unknown; maxDescriptionsPerTurn?: unknown };
|
|
327
|
-
};
|
|
328
|
-
if (body.webSearch && body.webSearch.backend !== undefined && body.webSearch.backend !== null
|
|
329
|
-
&& body.webSearch.backend !== "openai" && body.webSearch.backend !== "anthropic") {
|
|
330
|
-
return jsonResponse({ error: "webSearch.backend must be openai, anthropic, or null" }, 400);
|
|
331
|
-
}
|
|
332
|
-
if (body.vision && body.vision.backend !== undefined
|
|
333
|
-
&& body.vision.backend !== null && body.vision.backend !== "openai" && body.vision.backend !== "anthropic") {
|
|
334
|
-
return jsonResponse({ error: "vision.backend must be openai, anthropic, or null" }, 400);
|
|
335
|
-
}
|
|
336
|
-
if (body.vision && body.vision.maxDescriptionsPerTurn !== undefined
|
|
337
|
-
&& (typeof body.vision.maxDescriptionsPerTurn !== "number"
|
|
338
|
-
|| !Number.isInteger(body.vision.maxDescriptionsPerTurn)
|
|
339
|
-
|| body.vision.maxDescriptionsPerTurn <= 0)) {
|
|
340
|
-
return jsonResponse({ error: "vision.maxDescriptionsPerTurn must be a positive integer" }, 400);
|
|
341
|
-
}
|
|
342
|
-
if (body.webSearch) {
|
|
343
|
-
config.webSearchSidecar = { ...config.webSearchSidecar };
|
|
344
|
-
if (typeof body.webSearch.model === "string") {
|
|
345
|
-
if (body.webSearch.model === "") delete config.webSearchSidecar.model;
|
|
346
|
-
else config.webSearchSidecar.model = body.webSearch.model;
|
|
347
|
-
}
|
|
348
|
-
if (body.webSearch.backend === null) delete config.webSearchSidecar.backend;
|
|
349
|
-
else if (body.webSearch.backend === "openai" || body.webSearch.backend === "anthropic") {
|
|
350
|
-
config.webSearchSidecar.backend = body.webSearch.backend;
|
|
351
|
-
}
|
|
352
|
-
if (typeof body.webSearch.reasoning === "string") config.webSearchSidecar.reasoning = body.webSearch.reasoning;
|
|
353
|
-
}
|
|
354
|
-
if (body.vision) {
|
|
355
|
-
config.visionSidecar = { ...config.visionSidecar };
|
|
356
|
-
if (typeof body.vision.model === "string") {
|
|
357
|
-
if (body.vision.model === "") delete config.visionSidecar.model;
|
|
358
|
-
else config.visionSidecar.model = body.vision.model;
|
|
359
|
-
}
|
|
360
|
-
if (body.vision.backend === null) delete config.visionSidecar.backend;
|
|
361
|
-
else if (body.vision.backend === "openai" || body.vision.backend === "anthropic") {
|
|
362
|
-
config.visionSidecar.backend = body.vision.backend;
|
|
363
|
-
}
|
|
364
|
-
if (typeof body.vision.maxDescriptionsPerTurn === "number") {
|
|
365
|
-
config.visionSidecar.maxDescriptionsPerTurn = body.vision.maxDescriptionsPerTurn;
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
saveConfig(config);
|
|
369
|
-
const ws = config.webSearchSidecar ?? {};
|
|
370
|
-
const vs = config.visionSidecar ?? {};
|
|
371
|
-
return jsonResponse({
|
|
372
|
-
ok: true,
|
|
373
|
-
webSearch: { model: ws.model ?? "gpt-5.6-luna", backend: ws.backend },
|
|
374
|
-
vision: {
|
|
375
|
-
model: vs.model ?? "gpt-5.6-luna",
|
|
376
|
-
backend: vs.backend,
|
|
377
|
-
maxDescriptionsPerTurn: vs.maxDescriptionsPerTurn,
|
|
378
|
-
},
|
|
379
|
-
});
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
if (url.pathname === "/api/shadow-call-settings" && req.method === "GET") {
|
|
383
|
-
const sci = config.shadowCallIntercept ?? {};
|
|
384
|
-
return jsonResponse({ enabled: sci.enabled === true, model: sci.model ?? "" });
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
if (url.pathname === "/api/shadow-call-settings" && req.method === "PUT") {
|
|
388
|
-
let raw: unknown;
|
|
389
|
-
try { raw = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
390
|
-
if (!isPlainRecord(raw)) return jsonResponse({ error: "body must be a JSON object" }, 400);
|
|
391
|
-
const body = raw as { enabled?: unknown; model?: unknown };
|
|
392
|
-
if (body.enabled !== undefined && typeof body.enabled !== "boolean") {
|
|
393
|
-
return jsonResponse({ error: "enabled must be a boolean" }, 400);
|
|
394
|
-
}
|
|
395
|
-
if (body.model !== undefined && typeof body.model !== "string") {
|
|
396
|
-
return jsonResponse({ error: "model must be a string" }, 400);
|
|
397
|
-
}
|
|
398
|
-
config.shadowCallIntercept = { ...config.shadowCallIntercept };
|
|
399
|
-
if (typeof body.enabled === "boolean") config.shadowCallIntercept.enabled = body.enabled;
|
|
400
|
-
if (typeof body.model === "string") {
|
|
401
|
-
if (body.model === "") delete config.shadowCallIntercept.model;
|
|
402
|
-
else config.shadowCallIntercept.model = body.model;
|
|
403
|
-
}
|
|
404
|
-
saveConfig(config);
|
|
405
|
-
const sci = config.shadowCallIntercept;
|
|
406
|
-
return jsonResponse({ ok: true, enabled: sci.enabled === true, model: sci.model ?? "" });
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
if (url.pathname === "/api/logs" && req.method === "GET") {
|
|
410
|
-
const logs = filterRequestLogs(getRequestLogEntries(), url.searchParams);
|
|
411
|
-
return jsonResponse(logs.map(requestLogDto));
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
if (url.pathname === "/api/debug" && req.method === "GET") {
|
|
415
|
-
return jsonResponse(getDebugSettings());
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
if (url.pathname === "/api/debug/logs" && req.method === "GET") {
|
|
419
|
-
const { after, limit } = parseDebugLogQuery(url);
|
|
420
|
-
return jsonResponse(getDebugLogEntries({ after, limit }));
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
if (url.pathname === "/api/debug/usage-logs" && req.method === "GET") {
|
|
424
|
-
const { after, limit } = parseDebugLogQuery(url);
|
|
425
|
-
return jsonResponse(getUsageDebugLogEntries({ after, limit }));
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
if (url.pathname === "/api/claude/inbound-debug" && req.method === "GET") {
|
|
429
|
-
const { getClaudeInboundDebugEntries } = await import("../claude/inbound-debug");
|
|
430
|
-
const { isClaudeDebugEnabled } = await import("../lib/debug-settings");
|
|
431
|
-
return jsonResponse({ enabled: isClaudeDebugEnabled(), entries: getClaudeInboundDebugEntries() });
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
if (url.pathname === "/api/debug/injection-logs" && req.method === "GET") {
|
|
435
|
-
const { after, limit } = parseDebugLogQuery(url);
|
|
436
|
-
return jsonResponse(getInjectionDebugLogEntries({ after, limit }));
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
if (url.pathname === "/api/debug" && req.method === "PUT") {
|
|
440
|
-
let body: { debug?: unknown; usage?: unknown; injection?: unknown; claude?: unknown; reset?: unknown };
|
|
441
|
-
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
442
|
-
if (body.reset === true) return jsonResponse(clearDebugSettings());
|
|
443
|
-
if (body.reset === "debug" || body.reset === "provider") return jsonResponse(clearDebugSetting("debug"));
|
|
444
|
-
if (body.reset === "usage") return jsonResponse(clearDebugSetting("usage"));
|
|
445
|
-
if (body.reset === "injection") return jsonResponse(clearDebugSetting("injection"));
|
|
446
|
-
if (body.reset === "claude") return jsonResponse(clearDebugSetting("claude"));
|
|
447
|
-
const partial: Partial<Record<DebugFlag, boolean>> = {};
|
|
448
|
-
for (const key of ["debug", "usage", "injection", "claude"] as const) {
|
|
449
|
-
if (body[key] === undefined) continue;
|
|
450
|
-
if (typeof body[key] !== "boolean") return jsonResponse({ error: `${key} must be a boolean` }, 400);
|
|
451
|
-
partial[key] = body[key];
|
|
452
|
-
}
|
|
453
|
-
if (Object.keys(partial).length === 0) {
|
|
454
|
-
return jsonResponse({ error: "provide debug/usage/injection/claude booleans or reset:true" }, 400);
|
|
455
|
-
}
|
|
456
|
-
// Turning capture off should also flush already-captured entries (privacy contract).
|
|
457
|
-
if (partial.claude === false) {
|
|
458
|
-
const { clearClaudeInboundDebug } = await import("../claude/inbound-debug");
|
|
459
|
-
clearClaudeInboundDebug();
|
|
460
|
-
}
|
|
461
|
-
return jsonResponse(setDebugSettings(partial));
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
if (url.pathname === "/api/usage" && req.method === "GET") {
|
|
465
|
-
const range = parseRange(url.searchParams.get("range"));
|
|
466
|
-
const surface = parseUsageSurface(url.searchParams.get("surface"));
|
|
467
|
-
const now = Date.now();
|
|
468
|
-
try {
|
|
469
|
-
return jsonResponse(summarizeUsage(readUsageEntries(), range, now, surface));
|
|
470
|
-
} catch {
|
|
471
|
-
return jsonResponse({
|
|
472
|
-
range,
|
|
473
|
-
surface,
|
|
474
|
-
since: null,
|
|
475
|
-
generatedAt: now,
|
|
476
|
-
summary: {
|
|
477
|
-
requests: 0,
|
|
478
|
-
attemptCount: 0,
|
|
479
|
-
measuredRequests: 0,
|
|
480
|
-
reportedRequests: 0,
|
|
481
|
-
unreportedRequests: 0,
|
|
482
|
-
unsupportedRequests: 0,
|
|
483
|
-
estimatedRequests: 0,
|
|
484
|
-
inputTokens: 0,
|
|
485
|
-
outputTokens: 0,
|
|
486
|
-
cachedInputTokens: 0,
|
|
487
|
-
cacheReadInputTokens: 0,
|
|
488
|
-
cacheCreationInputTokens: 0,
|
|
489
|
-
reasoningOutputTokens: 0,
|
|
490
|
-
totalTokens: 0,
|
|
491
|
-
coverageRatio: 0,
|
|
492
|
-
estimatedCostUsd: 0,
|
|
493
|
-
pricedRequests: 0,
|
|
494
|
-
unpricedRequests: 0,
|
|
495
|
-
unmeteredRequests: 0,
|
|
496
|
-
},
|
|
497
|
-
days: [],
|
|
498
|
-
models: [],
|
|
499
|
-
providers: [],
|
|
500
|
-
error: "read_failed",
|
|
501
|
-
});
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
if (url.pathname === "/api/storage" && req.method === "GET") {
|
|
506
|
-
try {
|
|
507
|
-
return jsonResponse(scanStorage());
|
|
508
|
-
} catch {
|
|
509
|
-
return jsonResponse({
|
|
510
|
-
codexHome: resolveCodexHomeDir(),
|
|
511
|
-
generatedAt: Date.now(),
|
|
512
|
-
total: { bytes: 0, fileCount: 0 },
|
|
513
|
-
buckets: [],
|
|
514
|
-
error: "scan_failed",
|
|
515
|
-
});
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
if (url.pathname === "/api/provider-quotas" && req.method === "GET") {
|
|
520
|
-
const forceRefresh = url.searchParams.get("refresh") === "1" || url.searchParams.get("refresh") === "true";
|
|
521
|
-
return jsonResponse(await fetchProviderQuotaReports(config, forceRefresh));
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
if (url.pathname === "/api/providers" && req.method === "GET") {
|
|
525
|
-
return jsonResponse(Object.entries(config.providers).map(([name, p]) => ({
|
|
526
|
-
name, adapter: p.adapter, baseUrl: publicProviderBaseUrl(p.baseUrl), defaultModel: p.defaultModel,
|
|
527
|
-
hasApiKey: !!p.apiKey,
|
|
528
|
-
allowPrivateNetwork: p.allowPrivateNetwork === true,
|
|
529
|
-
liveModels: p.liveModels !== false,
|
|
530
|
-
models: p.models ?? [],
|
|
531
|
-
authMode: p.authMode,
|
|
532
|
-
disabled: p.disabled === true,
|
|
533
|
-
codexAccountMode: providerCodexAccountMode(name, p),
|
|
534
|
-
})));
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
// Add (or overwrite) a single provider. Merges into the live in-memory config and
|
|
538
|
-
// persists — existing providers' real keys are never round-tripped (unlike PUT /api/config,
|
|
539
|
-
// which would re-save the masked keys from GET). Live routing picks it up immediately.
|
|
540
|
-
if (url.pathname === "/api/providers" && req.method === "POST") {
|
|
541
|
-
let body: { name?: unknown; provider?: unknown; setDefault?: boolean };
|
|
542
|
-
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
543
|
-
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
544
|
-
const providerError = providerManagementConfigError(name, body.provider);
|
|
545
|
-
if (providerError) return jsonResponse({ error: providerError }, 400);
|
|
546
|
-
const prov = body.provider ? stripCodexRuntimeProviderFields(body.provider as OcxProviderConfig) : undefined;
|
|
547
|
-
if (!name || !prov?.adapter || !prov?.baseUrl) {
|
|
548
|
-
return jsonResponse({ error: "name, provider.adapter and provider.baseUrl are required" }, 400);
|
|
549
|
-
}
|
|
550
|
-
if (!isValidProviderName(name)) {
|
|
551
|
-
return jsonResponse({ error: "provider name must use letters, numbers, dot, underscore, or hyphen and cannot be a reserved object key" }, 400);
|
|
552
|
-
}
|
|
553
|
-
// Hostname destinations additionally get a DNS-resolved SSRF check at write time —
|
|
554
|
-
// the sync check above only classifies literal IPs (review finding, PR #96).
|
|
555
|
-
const resolvedError = await providerDestinationResolvedError(name, prov);
|
|
556
|
-
if (resolvedError) return jsonResponse({ error: resolvedError }, 400);
|
|
557
|
-
// Catalog providers (e.g. ollama-cloud) carry a models + vision/reasoning classification the GUI
|
|
558
|
-
// doesn't send — merge it in so the sidecars are gated correctly.
|
|
559
|
-
enrichProviderFromCatalog(name, prov);
|
|
560
|
-
const { saveConfig: save } = await import("../config");
|
|
561
|
-
// Overwriting an existing provider must not drop its multi-key pool: carry it over, then
|
|
562
|
-
// let the (possibly new) apiKey join the pool as the active entry.
|
|
563
|
-
const existingPool = config.providers[name]?.apiKeyPool;
|
|
564
|
-
if (existingPool && !prov.apiKeyPool) prov.apiKeyPool = existingPool;
|
|
565
|
-
config.providers[name] = stripRegistryOnlyStaticHeaders(name, prov);
|
|
566
|
-
if (body.setDefault) config.defaultProvider = name;
|
|
567
|
-
save(config);
|
|
568
|
-
if (prov.apiKey && prov.apiKeyPool) {
|
|
569
|
-
const { addProviderApiKey } = await import("../providers/api-keys");
|
|
570
|
-
addProviderApiKey(config, name, prov.apiKey);
|
|
571
|
-
}
|
|
572
|
-
const { clearModelCache } = await import("../codex/model-cache");
|
|
573
|
-
clearModelCache(name);
|
|
574
|
-
await refreshCodexCatalogBestEffort();
|
|
575
|
-
return jsonResponse({ success: true, name });
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
if (url.pathname === "/api/providers" && req.method === "PATCH") {
|
|
579
|
-
const name = url.searchParams.get("name")?.trim();
|
|
580
|
-
if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
|
|
581
|
-
let rawBody: unknown;
|
|
582
|
-
try { rawBody = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
583
|
-
if (!isPlainRecord(rawBody)) return jsonResponse({ error: "provider patch body must be a plain object" }, 400);
|
|
584
|
-
const keys = Object.keys(rawBody);
|
|
585
|
-
const hasMode = Object.hasOwn(rawBody, "codexAccountMode");
|
|
586
|
-
|
|
587
|
-
// codexAccountMode keeps its dedicated side-effect path (quota cache clear, thread map
|
|
588
|
-
// clear, pool prime) and is mutually exclusive with every other patch field.
|
|
589
|
-
if (hasMode) {
|
|
590
|
-
if (keys.length !== 1) {
|
|
591
|
-
return jsonResponse({ error: "codexAccountMode cannot be combined with other patch fields" }, 400);
|
|
592
|
-
}
|
|
593
|
-
if (name !== "openai") return jsonResponse({ error: "codexAccountMode is valid only for provider openai" }, 400);
|
|
594
|
-
const mode = rawBody.codexAccountMode;
|
|
595
|
-
if (mode !== "pool" && mode !== "direct") {
|
|
596
|
-
return jsonResponse({ error: "codexAccountMode must be pool or direct" }, 400);
|
|
597
|
-
}
|
|
598
|
-
const provider = config.providers.openai;
|
|
599
|
-
if (!provider || !isCanonicalOpenAiForwardProvider(provider)) {
|
|
600
|
-
return jsonResponse({ error: "provider openai must be the canonical built-in provider" }, 400);
|
|
601
|
-
}
|
|
602
|
-
const { saveConfig: save } = await import("../config");
|
|
603
|
-
config.providers.openai = { ...provider, codexAccountMode: mode };
|
|
604
|
-
save(config);
|
|
605
|
-
(deps.clearProviderQuotaCache ?? clearProviderQuotaCache)();
|
|
606
|
-
(deps.clearThreadAccountMap ?? clearThreadAccountMap)();
|
|
607
|
-
if (mode === "pool") {
|
|
608
|
-
try {
|
|
609
|
-
const prime = deps.primeCodexPoolQuotas ?? primeCodexPoolQuotas;
|
|
610
|
-
void Promise.resolve(prime(config, "mode-change")).catch(() => undefined);
|
|
611
|
-
} catch {
|
|
612
|
-
// Quota priming is best-effort; the persisted live mode is already authoritative.
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
return jsonResponse({ success: true, name: "openai", codexAccountMode: mode });
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
// Field-mask editor: apply recognized fields onto a copy, then validate the MERGED
|
|
619
|
-
// provider (canonical-seed guard covers openai; local-guard covers registry key providers).
|
|
620
|
-
// API keys are never writable here — the api-keys endpoints own pool-integrated key writes.
|
|
621
|
-
if (Object.hasOwn(rawBody, "apiKey")) {
|
|
622
|
-
return jsonResponse({ error: "apiKey cannot be patched here; use the provider API-key endpoints" }, 400);
|
|
623
|
-
}
|
|
624
|
-
const next: OcxProviderConfig = { ...config.providers[name]! };
|
|
625
|
-
let touched = false;
|
|
626
|
-
|
|
627
|
-
if (Object.hasOwn(rawBody, "disabled")) {
|
|
628
|
-
if (typeof rawBody.disabled !== "boolean") return jsonResponse({ error: "disabled must be a boolean" }, 400);
|
|
629
|
-
if (rawBody.disabled && name === config.defaultProvider) {
|
|
630
|
-
return jsonResponse({ error: "cannot disable the default provider; set another default first" }, 400);
|
|
631
|
-
}
|
|
632
|
-
next.disabled = rawBody.disabled;
|
|
633
|
-
touched = true;
|
|
634
|
-
}
|
|
635
|
-
if (Object.hasOwn(rawBody, "adapter")) {
|
|
636
|
-
if (typeof rawBody.adapter !== "string" || !rawBody.adapter.trim()) return jsonResponse({ error: "adapter must be a non-empty string" }, 400);
|
|
637
|
-
next.adapter = rawBody.adapter.trim();
|
|
638
|
-
touched = true;
|
|
639
|
-
}
|
|
640
|
-
if (Object.hasOwn(rawBody, "baseUrl")) {
|
|
641
|
-
if (typeof rawBody.baseUrl !== "string" || !rawBody.baseUrl.trim()) return jsonResponse({ error: "baseUrl must be a non-empty string" }, 400);
|
|
642
|
-
next.baseUrl = rawBody.baseUrl.trim();
|
|
643
|
-
touched = true;
|
|
644
|
-
}
|
|
645
|
-
if (Object.hasOwn(rawBody, "defaultModel")) {
|
|
646
|
-
if (typeof rawBody.defaultModel !== "string") return jsonResponse({ error: "defaultModel must be a string" }, 400);
|
|
647
|
-
const dm = rawBody.defaultModel.trim();
|
|
648
|
-
if (dm) next.defaultModel = dm;
|
|
649
|
-
else delete next.defaultModel;
|
|
650
|
-
touched = true;
|
|
651
|
-
}
|
|
652
|
-
if (Object.hasOwn(rawBody, "authMode")) {
|
|
653
|
-
if (typeof rawBody.authMode !== "string") return jsonResponse({ error: "authMode must be a string" }, 400);
|
|
654
|
-
const mode = rawBody.authMode.trim();
|
|
655
|
-
if (mode === "key" || mode === "forward" || mode === "oauth" || mode === "local") {
|
|
656
|
-
next.authMode = mode;
|
|
657
|
-
touched = true;
|
|
658
|
-
} else if (mode === "") {
|
|
659
|
-
delete next.authMode;
|
|
660
|
-
touched = true;
|
|
661
|
-
} else {
|
|
662
|
-
return jsonResponse({ error: "authMode must be key, forward, oauth, or local" }, 400);
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
if (Object.hasOwn(rawBody, "note")) {
|
|
666
|
-
if (typeof rawBody.note !== "string") return jsonResponse({ error: "note must be a string" }, 400);
|
|
667
|
-
const note = rawBody.note.trim();
|
|
668
|
-
if (note) next.note = note;
|
|
669
|
-
else delete next.note;
|
|
670
|
-
touched = true;
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
if (Object.hasOwn(rawBody, "allowPrivateNetwork")) {
|
|
674
|
-
if (typeof rawBody.allowPrivateNetwork !== "boolean") return jsonResponse({ error: "allowPrivateNetwork must be a boolean" }, 400);
|
|
675
|
-
next.allowPrivateNetwork = rawBody.allowPrivateNetwork;
|
|
676
|
-
touched = true;
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
if (Object.hasOwn(rawBody, "liveModels")) {
|
|
680
|
-
if (typeof rawBody.liveModels !== "boolean") return jsonResponse({ error: "liveModels must be a boolean" }, 400);
|
|
681
|
-
next.liveModels = rawBody.liveModels;
|
|
682
|
-
touched = true;
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
if (!touched) return jsonResponse({ error: "no recognized fields to update" }, 400);
|
|
686
|
-
|
|
687
|
-
// A disabled-only toggle preserves the v2 fast lane: it changes routing eligibility,
|
|
688
|
-
// not the provider shape, so the merged-shape validators (canonical-seed guard for
|
|
689
|
-
// openai, destination/local checks) do not apply.
|
|
690
|
-
const editorTouched = keys.some(key => key !== "disabled");
|
|
691
|
-
if (editorTouched) {
|
|
692
|
-
const providerError = providerManagementConfigError(name, next);
|
|
693
|
-
if (providerError) return jsonResponse({ error: providerError }, 400);
|
|
694
|
-
const resolvedError = await providerDestinationResolvedError(name, next);
|
|
695
|
-
if (resolvedError) return jsonResponse({ error: resolvedError }, 400);
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
const { saveConfig: save } = await import("../config");
|
|
699
|
-
config.providers[name] = stripRegistryOnlyStaticHeaders(name, next);
|
|
700
|
-
save(config);
|
|
701
|
-
if (editorTouched) {
|
|
702
|
-
const { clearModelCache } = await import("../codex/model-cache");
|
|
703
|
-
clearModelCache(name);
|
|
704
|
-
}
|
|
705
|
-
await refreshCodexCatalogBestEffort();
|
|
706
|
-
return jsonResponse({
|
|
707
|
-
success: true,
|
|
708
|
-
name,
|
|
709
|
-
disabled: config.providers[name]!.disabled === true,
|
|
710
|
-
hasApiKey: !!config.providers[name]!.apiKey,
|
|
711
|
-
});
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
// Lightweight connectivity probe: perform the provider's live /models fetch DIRECTLY and
|
|
715
|
-
// report only real upstream evidence. The catalog aggregate (fetchAllModels) deliberately
|
|
716
|
-
// hides fetch failures behind stale/static fallbacks, so a catalog-presence check would
|
|
717
|
-
// let a static-catalog provider with a fake key "pass" — this endpoint never uses it.
|
|
718
|
-
if (url.pathname === "/api/providers/test" && req.method === "POST") {
|
|
719
|
-
const name = url.searchParams.get("name")?.trim();
|
|
720
|
-
if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) {
|
|
721
|
-
return jsonResponse({ error: "unknown provider" }, 404);
|
|
722
|
-
}
|
|
723
|
-
const prov = config.providers[name]!;
|
|
724
|
-
if (prov.disabled) {
|
|
725
|
-
return jsonResponse({ ok: false, error: "Provider is disabled", latencyMs: 0 });
|
|
726
|
-
}
|
|
727
|
-
if (prov.authMode === "forward") {
|
|
728
|
-
return jsonResponse({
|
|
729
|
-
ok: true,
|
|
730
|
-
latencyMs: 0,
|
|
731
|
-
message: "Passthrough provider is configured (forwards your Codex login; no upstream /models).",
|
|
732
|
-
});
|
|
733
|
-
}
|
|
734
|
-
if (prov.liveModels === false) {
|
|
735
|
-
return jsonResponse({ ok: false, latencyMs: 0, error: "static catalog only — upstream not verified" });
|
|
736
|
-
}
|
|
737
|
-
const { resolveModelsAuthToken, buildModelsRequest } = await import("../oauth");
|
|
738
|
-
const apiKey = await resolveModelsAuthToken(name, prov);
|
|
739
|
-
if (prov.authMode === "oauth" && !apiKey) {
|
|
740
|
-
return jsonResponse({ ok: false, latencyMs: 0, error: "static catalog only — upstream not verified (not logged in)" });
|
|
741
|
-
}
|
|
742
|
-
const { url: modelsUrl, headers } = buildModelsRequest(prov, apiKey, name);
|
|
743
|
-
const started = Date.now();
|
|
744
|
-
try {
|
|
745
|
-
const res = await fetch(modelsUrl, { headers, signal: AbortSignal.timeout(8000) });
|
|
746
|
-
const latencyMs = Date.now() - started;
|
|
747
|
-
if (!res.ok) {
|
|
748
|
-
return jsonResponse({ ok: false, latencyMs, error: `upstream /models returned ${res.status}` });
|
|
749
|
-
}
|
|
750
|
-
const json = await res.json().catch(() => null) as { data?: unknown; models?: unknown } | null;
|
|
751
|
-
// OpenAI-style lists use { data: [...] }; Google's /v1beta/models (the other shape
|
|
752
|
-
// buildModelsRequest can produce) returns { models: [...] }.
|
|
753
|
-
const list = json && typeof json === "object" && !Array.isArray(json)
|
|
754
|
-
? (Array.isArray(json.data) ? json.data : Array.isArray(json.models) ? json.models : undefined)
|
|
755
|
-
: undefined;
|
|
756
|
-
if (!Array.isArray(list)) {
|
|
757
|
-
return jsonResponse({ ok: false, latencyMs, error: "upstream /models returned an unexpected shape" });
|
|
758
|
-
}
|
|
759
|
-
const models = list.length;
|
|
760
|
-
return jsonResponse({
|
|
761
|
-
ok: true,
|
|
762
|
-
latencyMs,
|
|
763
|
-
models,
|
|
764
|
-
message: `Connected — ${models} model${models === 1 ? "" : "s"} available.`,
|
|
765
|
-
});
|
|
766
|
-
} catch (err) {
|
|
767
|
-
return jsonResponse({
|
|
768
|
-
ok: false,
|
|
769
|
-
latencyMs: Date.now() - started,
|
|
770
|
-
error: err instanceof Error ? err.message : "Connection test failed",
|
|
771
|
-
});
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
if (url.pathname === "/api/providers" && req.method === "DELETE") {
|
|
776
|
-
const name = url.searchParams.get("name")?.trim();
|
|
777
|
-
if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
|
|
778
|
-
if (name === config.defaultProvider) return jsonResponse({ error: "cannot delete the default provider; set another default first" }, 400);
|
|
779
|
-
const dependentCombos = Object.entries(config.combos ?? {})
|
|
780
|
-
.filter(([, combo]) => combo.targets.some(target => target.provider === name))
|
|
781
|
-
.map(([id]) => id)
|
|
782
|
-
.sort((a, b) => a.localeCompare(b));
|
|
783
|
-
if (dependentCombos.length > 0) {
|
|
784
|
-
return jsonResponse({
|
|
785
|
-
error: `cannot delete provider "${name}" while combos depend on it`,
|
|
786
|
-
combos: dependentCombos,
|
|
787
|
-
}, 409);
|
|
788
|
-
}
|
|
789
|
-
const { saveConfig: save } = await import("../config");
|
|
790
|
-
delete config.providers[name];
|
|
791
|
-
setProviderContextCap(config, name, false);
|
|
792
|
-
save(config);
|
|
793
|
-
const { clearModelCache: clearCache } = await import("../codex/model-cache");
|
|
794
|
-
clearCache(name);
|
|
795
|
-
await refreshCodexCatalogBestEffort();
|
|
796
|
-
return jsonResponse({ success: true });
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
if (url.pathname === "/api/models" && req.method === "GET") {
|
|
800
|
-
const models = await fetchAllModels(config);
|
|
801
|
-
const disabled = new Set(config.disabledModels ?? []);
|
|
802
|
-
// Native GPT passthrough rows lead (provider "openai", bare-slug namespaced ids): sourced
|
|
803
|
-
// from the static supported set so a disabled model stays listed and re-enableable.
|
|
804
|
-
const native = nativeModelRows(config).map(row => ({
|
|
805
|
-
provider: "openai",
|
|
806
|
-
id: row.slug,
|
|
807
|
-
namespaced: row.slug,
|
|
808
|
-
disabled: row.disabled,
|
|
809
|
-
native: true,
|
|
810
|
-
...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}),
|
|
811
|
-
}));
|
|
812
|
-
const customModels = (config.customModels ?? []).map(cm => {
|
|
813
|
-
const namespaced = routedSlug(cm.provider, cm.modelId);
|
|
814
|
-
return {
|
|
815
|
-
provider: cm.provider,
|
|
816
|
-
id: cm.modelId,
|
|
817
|
-
namespaced,
|
|
818
|
-
disabled: [...disabled].some(stored => slugEquals(stored, cm.provider, cm.modelId)),
|
|
819
|
-
custom: true,
|
|
820
|
-
customId: cm.id,
|
|
821
|
-
displayName: cm.displayName,
|
|
822
|
-
...(cm.contextWindow ? { contextWindow: cm.contextWindow } : {}),
|
|
823
|
-
...(cm.inputModalities ? { inputModalities: cm.inputModalities } : {}),
|
|
824
|
-
};
|
|
825
|
-
});
|
|
826
|
-
const publicModels = uniqueCatalogModelsForPublicList(models);
|
|
827
|
-
const comboNamespaced = new Set(
|
|
828
|
-
publicModels.filter(model => model.provider === "combo").map(catalogModelSlug),
|
|
829
|
-
);
|
|
830
|
-
const visibleCustomModels = customModels.filter(model => !comboNamespaced.has(model.namespaced));
|
|
831
|
-
// Custom metadata wins when a physical live/static row resolves to the same Codex-facing
|
|
832
|
-
// slug, while a combo keeps the same precedence it has in routing and /v1/models.
|
|
833
|
-
const customNamespaced = new Set(visibleCustomModels.map(c => c.namespaced));
|
|
834
|
-
const dedupedRouted = publicModels.map(m => {
|
|
835
|
-
// Codex-facing slug (one "/", slug-codec); disabledModels compares tolerate both forms.
|
|
836
|
-
const namespaced = catalogModelSlug(m);
|
|
837
|
-
if (m.provider !== "combo" && customNamespaced.has(namespaced)) return null;
|
|
838
|
-
const contextCap = providerContextCap(config, m.provider);
|
|
839
|
-
return {
|
|
840
|
-
...m,
|
|
841
|
-
namespaced,
|
|
842
|
-
disabled: [...disabled].some(stored => (
|
|
843
|
-
stored === namespaced || slugEquals(stored, m.provider, m.id)
|
|
844
|
-
)),
|
|
845
|
-
...(contextCap !== undefined ? { contextCap, contextCapped: m.contextCapped === true } : {}),
|
|
846
|
-
};
|
|
847
|
-
}).filter(Boolean);
|
|
848
|
-
return jsonResponse([...native, ...dedupedRouted, ...visibleCustomModels]);
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
if (url.pathname === "/api/provider-context-caps" && req.method === "GET") {
|
|
852
|
-
return jsonResponse({ cap: DEFAULT_PROVIDER_CONTEXT_CAP, value: globalContextCapValue(config), caps: providerContextCaps(config) });
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
if (url.pathname === "/api/provider-context-caps" && req.method === "PUT") {
|
|
856
|
-
let body: { provider?: unknown; enabled?: unknown; value?: unknown; setAll?: unknown };
|
|
857
|
-
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
858
|
-
const { saveConfig: save } = await import("../config");
|
|
859
|
-
const { clearModelCache } = await import("../codex/model-cache");
|
|
860
|
-
const respond = () => jsonResponse({ ok: true, cap: DEFAULT_PROVIDER_CONTEXT_CAP, value: globalContextCapValue(config), caps: providerContextCaps(config) });
|
|
861
|
-
|
|
862
|
-
// Branch 1: set the global cap value and re-point every enabled provider to it.
|
|
863
|
-
if (body.value !== undefined) {
|
|
864
|
-
if (typeof body.value !== "number" || !Number.isFinite(body.value) || body.value <= 0) {
|
|
865
|
-
return jsonResponse({ error: "value must be a positive number" }, 400);
|
|
866
|
-
}
|
|
867
|
-
const affected = Object.keys(providerContextCaps(config));
|
|
868
|
-
setGlobalContextCapValue(config, body.value);
|
|
869
|
-
save(config);
|
|
870
|
-
for (const provider of affected) clearModelCache(provider);
|
|
871
|
-
await refreshCodexCatalogBestEffort();
|
|
872
|
-
return respond();
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
// Branch 2: enable/clear the cap for every provider at once.
|
|
876
|
-
if (body.setAll !== undefined) {
|
|
877
|
-
if (typeof body.setAll !== "boolean") {
|
|
878
|
-
return jsonResponse({ error: "setAll must be a boolean" }, 400);
|
|
879
|
-
}
|
|
880
|
-
const before = Object.keys(providerContextCaps(config));
|
|
881
|
-
const names = Object.keys(config.providers);
|
|
882
|
-
setAllProviderContextCaps(config, names, body.setAll);
|
|
883
|
-
save(config);
|
|
884
|
-
for (const provider of new Set([...before, ...names])) clearModelCache(provider);
|
|
885
|
-
await refreshCodexCatalogBestEffort();
|
|
886
|
-
return respond();
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
// Branch 3: existing per-provider toggle (enable writes the current global value).
|
|
890
|
-
if (typeof body.provider !== "string" || typeof body.enabled !== "boolean") {
|
|
891
|
-
return jsonResponse({ error: "provider string and enabled boolean are required" }, 400);
|
|
892
|
-
}
|
|
893
|
-
const provider = body.provider.trim();
|
|
894
|
-
if (!isValidProviderName(provider)) {
|
|
895
|
-
return jsonResponse({ error: "provider name must use letters, numbers, dot, underscore, or hyphen and cannot be a reserved object key" }, 400);
|
|
896
|
-
}
|
|
897
|
-
if (!hasOwnProvider(config.providers, provider)) {
|
|
898
|
-
return jsonResponse({ error: "unknown provider" }, 404);
|
|
899
|
-
}
|
|
900
|
-
setProviderContextCap(config, provider, body.enabled);
|
|
901
|
-
save(config);
|
|
902
|
-
clearModelCache(provider);
|
|
903
|
-
await refreshCodexCatalogBestEffort();
|
|
904
|
-
return respond();
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
// Enable/disable models: which routed models Codex sees. PUT hides them from the catalog +
|
|
908
|
-
// /v1/models and invalidates Codex's 5-min models cache so it applies on the next turn.
|
|
909
|
-
if (url.pathname === "/api/disabled-models" && req.method === "PUT") {
|
|
910
|
-
let body: { models?: unknown };
|
|
911
|
-
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
912
|
-
const disabled = Array.isArray(body.models) ? body.models.filter((m): m is string => typeof m === "string") : [];
|
|
913
|
-
config.disabledModels = disabled;
|
|
914
|
-
const { saveConfig: save } = await import("../config");
|
|
915
|
-
save(config);
|
|
916
|
-
await refreshCodexCatalogBestEffort();
|
|
917
|
-
return jsonResponse({ ok: true, disabled });
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
if (url.pathname === "/api/custom-models" && req.method === "GET") {
|
|
921
|
-
return jsonResponse(config.customModels ?? []);
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
if (url.pathname === "/api/custom-models" && req.method === "POST") {
|
|
925
|
-
let body: { provider?: unknown; modelId?: unknown; displayName?: unknown; contextWindow?: unknown; inputModalities?: unknown };
|
|
926
|
-
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
927
|
-
const provider = typeof body.provider === "string" ? body.provider.trim() : "";
|
|
928
|
-
const modelId = typeof body.modelId === "string" ? body.modelId.trim() : "";
|
|
929
|
-
if (!provider || !modelId) return jsonResponse({ error: "provider and modelId are required" }, 400);
|
|
930
|
-
if (modelId.includes("/")) return jsonResponse({ error: "modelId must not contain /" }, 400);
|
|
931
|
-
if (!isValidProviderName(provider)) return jsonResponse({ error: "invalid provider name" }, 400);
|
|
932
|
-
if (!hasOwnProvider(config.providers, provider)) return jsonResponse({ error: "provider not configured" }, 404);
|
|
933
|
-
const displayName = typeof body.displayName === "string" && body.displayName.trim() ? body.displayName.trim() : undefined;
|
|
934
|
-
if (displayName?.includes("/")) return jsonResponse({ error: "displayName must not contain /" }, 400);
|
|
935
|
-
const contextWindow = typeof body.contextWindow === "number" && body.contextWindow > 0 ? Math.floor(body.contextWindow) : undefined;
|
|
936
|
-
const inputModalities = Array.isArray(body.inputModalities) ? body.inputModalities.filter((m): m is string => typeof m === "string") : undefined;
|
|
937
|
-
const existing = config.customModels ?? [];
|
|
938
|
-
const newSlug = routedSlug(provider, modelId);
|
|
939
|
-
if (existing.some(cm => routedSlug(cm.provider, cm.modelId) === newSlug)) {
|
|
940
|
-
return jsonResponse({ error: "duplicate model" }, 409);
|
|
941
|
-
}
|
|
942
|
-
const entry: OcxCustomModel = {
|
|
943
|
-
id: randomUUID(),
|
|
944
|
-
provider,
|
|
945
|
-
modelId,
|
|
946
|
-
...(displayName ? { displayName } : {}),
|
|
947
|
-
...(contextWindow ? { contextWindow } : {}),
|
|
948
|
-
...(inputModalities && inputModalities.length > 0 ? { inputModalities } : {}),
|
|
949
|
-
addedAt: new Date().toISOString(),
|
|
950
|
-
};
|
|
951
|
-
config.customModels = [...existing, entry];
|
|
952
|
-
const { saveConfig: save } = await import("../config");
|
|
953
|
-
save(config);
|
|
954
|
-
await refreshCodexCatalogBestEffort();
|
|
955
|
-
return jsonResponse(entry, 201);
|
|
956
|
-
}
|
|
957
|
-
|
|
958
|
-
const customPutMatch = url.pathname.match(/^\/api\/custom-models\/([^/]+)$/);
|
|
959
|
-
if (customPutMatch && req.method === "PUT") {
|
|
960
|
-
let id: string;
|
|
961
|
-
try { id = decodeURIComponent(customPutMatch[1]); } catch { return jsonResponse({ error: "invalid id encoding" }, 400); }
|
|
962
|
-
let body: { displayName?: unknown; contextWindow?: unknown; inputModalities?: unknown; modelId?: unknown };
|
|
963
|
-
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
964
|
-
const list = config.customModels ?? [];
|
|
965
|
-
const idx = list.findIndex(cm => cm.id === id);
|
|
966
|
-
if (idx === -1) return jsonResponse({ error: "not found" }, 404);
|
|
967
|
-
const cm = { ...list[idx] };
|
|
968
|
-
if (typeof body.modelId === "string" && body.modelId.trim()) {
|
|
969
|
-
if (body.modelId.includes("/")) return jsonResponse({ error: "modelId must not contain /" }, 400);
|
|
970
|
-
cm.modelId = body.modelId.trim();
|
|
971
|
-
}
|
|
972
|
-
if (body.displayName !== undefined) {
|
|
973
|
-
const dn = typeof body.displayName === "string" ? body.displayName.trim() : "";
|
|
974
|
-
if (dn.includes("/")) return jsonResponse({ error: "displayName must not contain /" }, 400);
|
|
975
|
-
cm.displayName = dn || undefined;
|
|
976
|
-
}
|
|
977
|
-
if (body.contextWindow !== undefined) {
|
|
978
|
-
cm.contextWindow = typeof body.contextWindow === "number" && body.contextWindow > 0 ? Math.floor(body.contextWindow) : undefined;
|
|
979
|
-
}
|
|
980
|
-
if (body.inputModalities !== undefined) {
|
|
981
|
-
cm.inputModalities = Array.isArray(body.inputModalities) ? body.inputModalities.filter((m): m is string => typeof m === "string") : undefined;
|
|
982
|
-
}
|
|
983
|
-
const updatedSlug = routedSlug(cm.provider, cm.modelId);
|
|
984
|
-
if (list.some((other, i) => i !== idx && routedSlug(other.provider, other.modelId) === updatedSlug)) {
|
|
985
|
-
return jsonResponse({ error: "duplicate model" }, 409);
|
|
986
|
-
}
|
|
987
|
-
list[idx] = cm;
|
|
988
|
-
config.customModels = list;
|
|
989
|
-
const { saveConfig: save } = await import("../config");
|
|
990
|
-
save(config);
|
|
991
|
-
await refreshCodexCatalogBestEffort();
|
|
992
|
-
return jsonResponse(cm);
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
const customDelMatch = url.pathname.match(/^\/api\/custom-models\/([^/]+)$/);
|
|
996
|
-
if (customDelMatch && req.method === "DELETE") {
|
|
997
|
-
let id: string;
|
|
998
|
-
try { id = decodeURIComponent(customDelMatch[1]); } catch { return jsonResponse({ error: "invalid id encoding" }, 400); }
|
|
999
|
-
const list = config.customModels ?? [];
|
|
1000
|
-
const idx = list.findIndex(cm => cm.id === id);
|
|
1001
|
-
if (idx === -1) return jsonResponse({ error: "not found" }, 404);
|
|
1002
|
-
list.splice(idx, 1);
|
|
1003
|
-
config.customModels = list.length > 0 ? list : undefined;
|
|
1004
|
-
const { saveConfig: save } = await import("../config");
|
|
1005
|
-
save(config);
|
|
1006
|
-
await refreshCodexCatalogBestEffort();
|
|
1007
|
-
return jsonResponse({ ok: true });
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
// multi_agent_v2 surface toggle. GET reports the flag + the agents.max_threads
|
|
1011
|
-
// boot conflict; PUT flips it via the official `codex features` CLI and RESYNCS
|
|
1012
|
-
// the catalog so multi-agent surface metadata stays fresh. The catalog build
|
|
1013
|
-
// itself never writes config — this endpoint is the only server-side mutation
|
|
1014
|
-
// surface for the flag.
|
|
1015
|
-
if (url.pathname === "/api/v2" && req.method === "GET") {
|
|
1016
|
-
const { isMultiAgentV2Enabled, hasAgentsMaxThreads, getLogicalMaxThreads } = await import("../codex/features");
|
|
1017
|
-
const enabled = isMultiAgentV2Enabled();
|
|
1018
|
-
return jsonResponse({
|
|
1019
|
-
enabled,
|
|
1020
|
-
agentsMaxThreadsConflict: enabled && hasAgentsMaxThreads(),
|
|
1021
|
-
maxConcurrentThreadsPerSession: getLogicalMaxThreads(),
|
|
1022
|
-
multiAgentMode: config.multiAgentMode ?? "default",
|
|
1023
|
-
});
|
|
1024
|
-
}
|
|
1025
|
-
if (url.pathname === "/api/v2" && req.method === "PUT") {
|
|
1026
|
-
let body: { enabled?: unknown; maxConcurrentThreadsPerSession?: unknown; multiAgentMode?: unknown };
|
|
1027
|
-
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
1028
|
-
const wantsFlag = body.enabled !== undefined;
|
|
1029
|
-
const wantsThreads = body.maxConcurrentThreadsPerSession !== undefined;
|
|
1030
|
-
const wantsMode = body.multiAgentMode !== undefined;
|
|
1031
|
-
if (!wantsFlag && !wantsThreads && !wantsMode) return jsonResponse({ error: "body must set enabled, multiAgentMode, and/or maxConcurrentThreadsPerSession" }, 400);
|
|
1032
|
-
if (wantsFlag && typeof body.enabled !== "boolean") return jsonResponse({ error: "body.enabled must be a boolean" }, 400);
|
|
1033
|
-
if (wantsMode && body.multiAgentMode !== "v1" && body.multiAgentMode !== "default" && body.multiAgentMode !== "v2") {
|
|
1034
|
-
return jsonResponse({ error: "body.multiAgentMode must be 'v1', 'default', or 'v2'" }, 400);
|
|
1035
|
-
}
|
|
1036
|
-
if (wantsThreads && (typeof body.maxConcurrentThreadsPerSession !== "number" || !Number.isInteger(body.maxConcurrentThreadsPerSession) || body.maxConcurrentThreadsPerSession < 1)) {
|
|
1037
|
-
return jsonResponse({ error: "body.maxConcurrentThreadsPerSession must be an integer >= 1" }, 400);
|
|
1038
|
-
}
|
|
1039
|
-
const mode = wantsMode ? body.multiAgentMode as "v1" | "default" | "v2" : undefined;
|
|
1040
|
-
const modeFlag = mode === "v2" ? true : mode === "v1" ? false : undefined;
|
|
1041
|
-
if (wantsFlag && modeFlag !== undefined && body.enabled !== modeFlag) {
|
|
1042
|
-
return jsonResponse({ error: `body.enabled conflicts with multiAgentMode '${mode}'` }, 400);
|
|
1043
|
-
}
|
|
1044
|
-
const { isMultiAgentV2Enabled, hasAgentsMaxThreads, getLogicalMaxThreads, transitionMultiAgentV2 } = await import("../codex/features");
|
|
1045
|
-
const warnings: string[] = [];
|
|
1046
|
-
const requestedFlag = wantsFlag ? body.enabled as boolean : modeFlag;
|
|
1047
|
-
if (requestedFlag !== undefined || wantsThreads) {
|
|
1048
|
-
const targetFlag = requestedFlag ?? isMultiAgentV2Enabled();
|
|
1049
|
-
let toggle = deps.toggleCodexMultiAgentV2;
|
|
1050
|
-
if (!toggle) {
|
|
1051
|
-
const { execFileSync } = await import("node:child_process");
|
|
1052
|
-
const { codexFeaturesInvocation } = await import("../cli/v2");
|
|
1053
|
-
toggle = (enabled: boolean) => {
|
|
1054
|
-
const inv = codexFeaturesInvocation(enabled ? "enable" : "disable");
|
|
1055
|
-
execFileSync(inv.file, inv.args,
|
|
1056
|
-
{ stdio: ["ignore", "pipe", "pipe"], timeout: 15_000, windowsHide: true, ...inv.options });
|
|
1057
|
-
};
|
|
1058
|
-
}
|
|
1059
|
-
const result = transitionMultiAgentV2(targetFlag, toggle, {
|
|
1060
|
-
...(wantsThreads ? { threadLimit: body.maxConcurrentThreadsPerSession as number } : {}),
|
|
1061
|
-
});
|
|
1062
|
-
if (!result.ok) return jsonResponse({ error: `multi_agent_v2 transition failed: ${result.error}` }, 502);
|
|
1063
|
-
if (result.changed && result.threadLimit !== null) warnings.push(`Thread limit ${result.threadLimit} preserved for ${targetFlag ? "v2" : "v1"}.`);
|
|
1064
|
-
}
|
|
1065
|
-
if (wantsMode) {
|
|
1066
|
-
if (mode === "default") delete config.multiAgentMode;
|
|
1067
|
-
else config.multiAgentMode = mode;
|
|
1068
|
-
saveConfig(config);
|
|
1069
|
-
warnings.push(`Multi-agent mode set to '${mode}'. Applies to new sessions.`);
|
|
1070
|
-
}
|
|
1071
|
-
await refreshCodexCatalogBestEffort();
|
|
1072
|
-
if (requestedFlag !== undefined) warnings.push("Applies to new sessions; restart the Codex app or wait out its picker cache to see the ladder change.");
|
|
1073
|
-
const enabled = isMultiAgentV2Enabled();
|
|
1074
|
-
return jsonResponse({
|
|
1075
|
-
ok: true,
|
|
1076
|
-
enabled,
|
|
1077
|
-
agentsMaxThreadsConflict: enabled && hasAgentsMaxThreads(),
|
|
1078
|
-
maxConcurrentThreadsPerSession: getLogicalMaxThreads(),
|
|
1079
|
-
multiAgentMode: config.multiAgentMode ?? "default",
|
|
1080
|
-
warnings,
|
|
1081
|
-
});
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
// Which providers support real OAuth login (drives the GUI's "Log in with …" buttons).
|
|
1085
|
-
if (url.pathname === "/api/oauth/providers" && req.method === "GET") {
|
|
1086
|
-
return jsonResponse({ providers: listOAuthProviders() });
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
|
-
// API-key "login" providers (open dashboard → paste key). Drives the GUI's key-provider picker.
|
|
1090
|
-
if (url.pathname === "/api/key-providers" && req.method === "GET") {
|
|
1091
|
-
return jsonResponse({ providers: listKeyLoginProviders() });
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
// Complete GUI picker presets, derived from the canonical provider registry. The GUI is a
|
|
1095
|
-
// standalone Vite package, so it consumes this runtime view instead of importing repo-root src.
|
|
1096
|
-
if (url.pathname === "/api/provider-presets" && req.method === "GET") {
|
|
1097
|
-
return jsonResponse({ providers: deriveProviderPresets() });
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
// Subagent prompt injection model: single native or routed model whose info is
|
|
1101
|
-
// dynamically injected into the v1 proactive prompt, plus an optional reasoning
|
|
1102
|
-
// effort the prompt tells the agent to pass to spawn_agent. GET returns the current
|
|
1103
|
-
// picks + available models/efforts; PUT sets or clears them.
|
|
1104
|
-
if (url.pathname === "/api/injection-model" && req.method === "GET") {
|
|
1105
|
-
const models = await fetchAllModels(config);
|
|
1106
|
-
const disabled = new Set(config.disabledModels ?? []);
|
|
1107
|
-
const { listCatalogNativeSlugs } = await import("../codex/catalog");
|
|
1108
|
-
const { CODEX_REASONING_LEVELS } = await import("../reasoning-effort");
|
|
1109
|
-
const nativeModels = listCatalogNativeSlugs()
|
|
1110
|
-
.filter(slug => !disabled.has(slug))
|
|
1111
|
-
.map(slug => ({ provider: "openai", model: slug, namespaced: slug }));
|
|
1112
|
-
const routedModels = uniqueCatalogModelsForPublicList(models)
|
|
1113
|
-
.map(m => ({ provider: m.provider, model: m.id, namespaced: catalogModelSlug(m) }))
|
|
1114
|
-
.filter(m => ![...disabled].some(stored => (
|
|
1115
|
-
stored === m.namespaced || slugEquals(stored, m.provider, m.model)
|
|
1116
|
-
)));
|
|
1117
|
-
return jsonResponse({
|
|
1118
|
-
multiAgentGuidanceEnabled: multiAgentGuidanceEnabled(config),
|
|
1119
|
-
model: config.injectionModel ?? null,
|
|
1120
|
-
effort: config.injectionEffort ?? null,
|
|
1121
|
-
prompt: config.injectionPrompt ?? null,
|
|
1122
|
-
efforts: CODEX_REASONING_LEVELS.map(l => l.effort),
|
|
1123
|
-
available: [...nativeModels, ...routedModels],
|
|
1124
|
-
});
|
|
1125
|
-
}
|
|
1126
|
-
if (url.pathname === "/api/injection-model" && req.method === "PUT") {
|
|
1127
|
-
let parsedBody: unknown;
|
|
1128
|
-
try { parsedBody = await req.json(); } catch {
|
|
1129
|
-
return jsonResponse({ error: "invalid JSON body" }, 400);
|
|
1130
|
-
}
|
|
1131
|
-
if (!parsedBody || typeof parsedBody !== "object" || Array.isArray(parsedBody)) {
|
|
1132
|
-
return jsonResponse({ error: "body must be a JSON object" }, 400);
|
|
1133
|
-
}
|
|
1134
|
-
const body = parsedBody as {
|
|
1135
|
-
multiAgentGuidanceEnabled?: unknown;
|
|
1136
|
-
model?: unknown;
|
|
1137
|
-
effort?: unknown;
|
|
1138
|
-
prompt?: unknown;
|
|
1139
|
-
};
|
|
1140
|
-
const { isCodexReasoningEffort } = await import("../reasoning-effort");
|
|
1141
|
-
|
|
1142
|
-
let nextEnabled = config.multiAgentGuidanceEnabled;
|
|
1143
|
-
let nextModel = config.injectionModel;
|
|
1144
|
-
let nextEffort = config.injectionEffort;
|
|
1145
|
-
let nextPrompt = config.injectionPrompt;
|
|
1146
|
-
|
|
1147
|
-
if ("multiAgentGuidanceEnabled" in body) {
|
|
1148
|
-
if (typeof body.multiAgentGuidanceEnabled !== "boolean") {
|
|
1149
|
-
return jsonResponse({ error: "multiAgentGuidanceEnabled must be a boolean" }, 400);
|
|
1150
|
-
}
|
|
1151
|
-
nextEnabled = body.multiAgentGuidanceEnabled;
|
|
1152
|
-
}
|
|
1153
|
-
if ("model" in body) {
|
|
1154
|
-
if (body.model === null || body.model === "") nextModel = undefined;
|
|
1155
|
-
else if (typeof body.model === "string" && body.model.length > 0) nextModel = body.model;
|
|
1156
|
-
else return jsonResponse({ error: "model must be a non-empty string or null" }, 400);
|
|
1157
|
-
}
|
|
1158
|
-
if ("effort" in body) {
|
|
1159
|
-
if (body.effort === null || body.effort === "") nextEffort = undefined;
|
|
1160
|
-
else if (typeof body.effort === "string" && isCodexReasoningEffort(body.effort)) {
|
|
1161
|
-
nextEffort = body.effort;
|
|
1162
|
-
} else {
|
|
1163
|
-
return jsonResponse({ error: `unknown reasoning effort "${String(body.effort)}"` }, 400);
|
|
1164
|
-
}
|
|
1165
|
-
}
|
|
1166
|
-
if ("prompt" in body) {
|
|
1167
|
-
if (typeof body.prompt === "string" && body.prompt.trim().length > 0) nextPrompt = body.prompt;
|
|
1168
|
-
else if (body.prompt === null || body.prompt === "") nextPrompt = undefined;
|
|
1169
|
-
else return jsonResponse({ error: "prompt must be a string or null" }, 400);
|
|
1170
|
-
}
|
|
1171
|
-
// Clearing the model always clears the effort (it is meaningless alone).
|
|
1172
|
-
if (!nextModel) nextEffort = undefined;
|
|
1173
|
-
|
|
1174
|
-
config.multiAgentGuidanceEnabled = nextEnabled;
|
|
1175
|
-
if (nextModel) config.injectionModel = nextModel;
|
|
1176
|
-
else delete config.injectionModel;
|
|
1177
|
-
if (nextEffort) config.injectionEffort = nextEffort;
|
|
1178
|
-
else delete config.injectionEffort;
|
|
1179
|
-
if (nextPrompt) config.injectionPrompt = nextPrompt;
|
|
1180
|
-
else delete config.injectionPrompt;
|
|
1181
|
-
|
|
1182
|
-
saveConfig(config);
|
|
1183
|
-
return jsonResponse({
|
|
1184
|
-
ok: true,
|
|
1185
|
-
multiAgentGuidanceEnabled: multiAgentGuidanceEnabled(config),
|
|
1186
|
-
model: config.injectionModel ?? null,
|
|
1187
|
-
effort: config.injectionEffort ?? null,
|
|
1188
|
-
prompt: config.injectionPrompt ?? null,
|
|
1189
|
-
});
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
|
-
// Hard reasoning-effort caps (devlog/260710_subagent_effort_intercept): a global ceiling and a
|
|
1193
|
-
// sub-agent-only ceiling, enforced per-request in handleResponses (src/server/effort-policy.ts).
|
|
1194
|
-
// Key semantics per field: absent -> unchanged; null/"" -> clear; ladder value -> set; else 400.
|
|
1195
|
-
if (url.pathname === "/api/effort-caps" && req.method === "GET") {
|
|
1196
|
-
const { CODEX_REASONING_LEVELS } = await import("../reasoning-effort");
|
|
1197
|
-
return jsonResponse({
|
|
1198
|
-
effortCap: config.effortCap ?? null,
|
|
1199
|
-
subagentEffortCap: config.subagentEffortCap ?? null,
|
|
1200
|
-
efforts: CODEX_REASONING_LEVELS.map(l => l.effort),
|
|
1201
|
-
});
|
|
1202
|
-
}
|
|
1203
|
-
if (url.pathname === "/api/effort-caps" && req.method === "PUT") {
|
|
1204
|
-
let body: { effortCap?: unknown; subagentEffortCap?: unknown };
|
|
1205
|
-
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
1206
|
-
const { isCodexReasoningEffort } = await import("../reasoning-effort");
|
|
1207
|
-
for (const key of ["effortCap", "subagentEffortCap"] as const) {
|
|
1208
|
-
if (!(key in body)) continue;
|
|
1209
|
-
const value = body[key];
|
|
1210
|
-
if (value === null || value === "") { delete config[key]; continue; }
|
|
1211
|
-
if (typeof value !== "string" || !isCodexReasoningEffort(value)) {
|
|
1212
|
-
return jsonResponse({ error: `unknown reasoning effort "${String(value)}"` }, 400);
|
|
1213
|
-
}
|
|
1214
|
-
config[key] = value;
|
|
1215
|
-
}
|
|
1216
|
-
saveConfig(config);
|
|
1217
|
-
return jsonResponse({ ok: true, effortCap: config.effortCap ?? null, subagentEffortCap: config.subagentEffortCap ?? null });
|
|
1218
|
-
}
|
|
1219
|
-
|
|
1220
|
-
// Subagent model picker: which ≤5 routed models Codex's spawn_agent advertises (it shows the
|
|
1221
|
-
// first 5 routed catalog entries). PUT reorders the injected catalog so the chosen ones lead.
|
|
1222
|
-
if (url.pathname === "/api/subagent-models" && req.method === "GET") {
|
|
1223
|
-
const models = await fetchAllModels(config);
|
|
1224
|
-
const disabled = new Set(config.disabledModels ?? []);
|
|
1225
|
-
// Native gpt (passthrough) are also valid subagent picks — they're picker-visible models in the
|
|
1226
|
-
// catalog, just buried by priority. List them first so the user can feature them over routed.
|
|
1227
|
-
const { listCatalogNativeSlugs } = await import("../codex/catalog");
|
|
1228
|
-
const visibleRouted = [...new Set(models
|
|
1229
|
-
.filter(m => ![...disabled].some(stored =>
|
|
1230
|
-
stored === catalogModelSlug(m) || slugEquals(stored, m.provider, m.id)
|
|
1231
|
-
))
|
|
1232
|
-
.map(catalogModelSlug))];
|
|
1233
|
-
const available = [
|
|
1234
|
-
...listCatalogNativeSlugs().filter(ns => !disabled.has(ns)),
|
|
1235
|
-
...visibleRouted,
|
|
1236
|
-
];
|
|
1237
|
-
return jsonResponse({ chosen: config.subagentModels ?? [], available });
|
|
1238
|
-
}
|
|
1239
|
-
if (url.pathname === "/api/subagent-models" && req.method === "PUT") {
|
|
1240
|
-
let body: { models?: unknown };
|
|
1241
|
-
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
1242
|
-
const chosen = Array.isArray(body.models) ? body.models.filter((m): m is string => typeof m === "string").slice(0, 5) : [];
|
|
1243
|
-
config.subagentModels = chosen;
|
|
1244
|
-
const { saveConfig: save } = await import("../config");
|
|
1245
|
-
save(config);
|
|
1246
|
-
await refreshCodexCatalogBestEffort();
|
|
1247
|
-
await syncClaudeAgentDefsBestEffort();
|
|
1248
|
-
return jsonResponse({ ok: true, applied: chosen });
|
|
1249
|
-
}
|
|
1250
|
-
|
|
1251
|
-
// Claude Code inbound settings (GUI "Claude ON" toggle + Claude page).
|
|
1252
|
-
if (url.pathname === "/api/claude-code" && req.method === "GET") {
|
|
1253
|
-
const models = await fetchAllModels(config);
|
|
1254
|
-
const { listCatalogNativeSlugs } = await import("../codex/catalog");
|
|
1255
|
-
const { claudeCodeAlias, claudeCodeNativeAlias } = await import("../claude/alias");
|
|
1256
|
-
const { buildClaudeContextWindows, effectiveModelEnv } = await import("../claude/context-windows");
|
|
1257
|
-
const { visibleNativeSlugs } = await import("../codex/catalog");
|
|
1258
|
-
const disabled = new Set(config.disabledModels ?? []);
|
|
1259
|
-
const isDisabled = (provider: string, id: string) =>
|
|
1260
|
-
[...disabled].some(stored => slugEquals(stored, provider, id));
|
|
1261
|
-
const available = [
|
|
1262
|
-
...listCatalogNativeSlugs().filter(ns => !disabled.has(ns)),
|
|
1263
|
-
// Claude-facing values stay RAW native selectors (resolved inbound via routeModel,
|
|
1264
|
-
// which accepts the raw full-slash form); only the disabled check goes tolerant.
|
|
1265
|
-
...models.filter(m => !isDisabled(m.provider, m.id)).map(m => `${m.provider}/${m.id}`),
|
|
1266
|
-
];
|
|
1267
|
-
const aliases: { id: string; display_name: string }[] = [];
|
|
1268
|
-
for (const slug of listCatalogNativeSlugs()) {
|
|
1269
|
-
// Readable CLI-surface alias with hash fallback (devlog 050 / audit 051 #2) —
|
|
1270
|
-
// the same shared helper the /v1/models ?ids=cli path uses.
|
|
1271
|
-
if (!disabled.has(slug)) aliases.push({ id: claudeCodeNativeAlias(slug), display_name: `${slug} (native)` });
|
|
1272
|
-
}
|
|
1273
|
-
for (const m of models) {
|
|
1274
|
-
if (isDisabled(m.provider, m.id)) continue;
|
|
1275
|
-
aliases.push({ id: claudeCodeAlias(m.provider, m.id), display_name: `${m.id} (${m.provider})` });
|
|
1276
|
-
}
|
|
1277
|
-
const contextWindows = buildClaudeContextWindows([...visibleNativeSlugs(config)], models);
|
|
1278
|
-
const webSearchOverride = config.claudeCode?.webSearchSidecar;
|
|
1279
|
-
const visionOverride = config.claudeCode?.visionSidecar;
|
|
1280
|
-
return jsonResponse({
|
|
1281
|
-
enabled: config.claudeCode?.enabled !== false,
|
|
1282
|
-
// Round-trip contract with the GUI auth-mode select (devlog 260720_claude_authmode_persist):
|
|
1283
|
-
// absent config key = subscription (OcxClaudeCodeConfig.authMode is typed `"proxy"` only).
|
|
1284
|
-
authMode: config.claudeCode?.authMode === "proxy" ? "proxy" : "subscription",
|
|
1285
|
-
model: config.claudeCode?.model ?? "",
|
|
1286
|
-
smallFastModel: config.claudeCode?.smallFastModel ?? "",
|
|
1287
|
-
tierModels: config.claudeCode?.tierModels ?? {},
|
|
1288
|
-
modelMap: config.claudeCode?.modelMap ?? {},
|
|
1289
|
-
systemEnv: config.claudeCode?.systemEnv === true,
|
|
1290
|
-
autoConnectSupported: process.platform === "darwin",
|
|
1291
|
-
maxContextTokens: config.claudeCode?.maxContextTokens ?? null,
|
|
1292
|
-
alwaysEnableEffort: config.claudeCode?.alwaysEnableEffort === true,
|
|
1293
|
-
autoContext: config.claudeCode?.autoContext !== false,
|
|
1294
|
-
autoCompactWindow: config.claudeCode?.autoCompactWindow ?? null,
|
|
1295
|
-
blockedSkills: config.claudeCode?.blockedSkills ?? null,
|
|
1296
|
-
injectAgents: config.claudeCode?.injectAgents !== false,
|
|
1297
|
-
...(webSearchOverride && Object.keys(webSearchOverride).length > 0
|
|
1298
|
-
? { webSearchSidecar: { backend: webSearchOverride.backend, model: webSearchOverride.model } }
|
|
1299
|
-
: {}),
|
|
1300
|
-
...(visionOverride && Object.keys(visionOverride).length > 0
|
|
1301
|
-
? { visionSidecar: { backend: visionOverride.backend, model: visionOverride.model } }
|
|
1302
|
-
: {}),
|
|
1303
|
-
fastMode: config.fastMode,
|
|
1304
|
-
contextWindows,
|
|
1305
|
-
effectiveModelEnv: effectiveModelEnv(config.claudeCode, contextWindows),
|
|
1306
|
-
available,
|
|
1307
|
-
aliases,
|
|
1308
|
-
port: config.port,
|
|
1309
|
-
});
|
|
1310
|
-
}
|
|
1311
|
-
if (url.pathname === "/api/claude-code" && req.method === "PUT") {
|
|
1312
|
-
// NOTE: model / tierModels / maxContextTokens / alwaysEnableEffort are
|
|
1313
|
-
// CONFIG-ONLY back-compat fields — the GUI no longer offers controls for them
|
|
1314
|
-
// (default model is owned by Claude Code's /model picker; roster agents
|
|
1315
|
-
// supersede tiers; auto-context supersedes the max-context pair; effort rides
|
|
1316
|
-
// regardless on 2.1.207). PUT keeps validating them so hand-written configs
|
|
1317
|
-
// and older GUIs stay safe; GUI saves omit them and the spread preserves them.
|
|
1318
|
-
let parsedBody: unknown;
|
|
1319
|
-
try { parsedBody = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
1320
|
-
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
|
1321
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
|
|
1322
|
-
const prototype = Object.getPrototypeOf(value);
|
|
1323
|
-
return prototype === Object.prototype || prototype === null;
|
|
1324
|
-
};
|
|
1325
|
-
if (!isPlainObject(parsedBody)) return jsonResponse({ error: "body must be an object" }, 400);
|
|
1326
|
-
const body = parsedBody as { enabled?: unknown; authMode?: unknown; model?: unknown; smallFastModel?: unknown; modelMap?: unknown; systemEnv?: unknown; fastMode?: unknown; maxContextTokens?: unknown; alwaysEnableEffort?: unknown; tierModels?: unknown; autoContext?: unknown; autoCompactWindow?: unknown; blockedSkills?: unknown; injectAgents?: unknown; webSearchSidecar?: unknown; visionSidecar?: unknown };
|
|
1327
|
-
for (const field of ["webSearchSidecar", "visionSidecar"] as const) {
|
|
1328
|
-
const section = body[field];
|
|
1329
|
-
if (section === undefined || section === null) continue;
|
|
1330
|
-
if (!isPlainObject(section)) return jsonResponse({ error: `${field} must be an object or null` }, 400);
|
|
1331
|
-
if (section.backend !== undefined && section.backend !== null
|
|
1332
|
-
&& section.backend !== "openai" && section.backend !== "anthropic") {
|
|
1333
|
-
return jsonResponse({ error: `${field}.backend must be openai, anthropic, or null` }, 400);
|
|
1334
|
-
}
|
|
1335
|
-
if (section.model !== undefined && typeof section.model !== "string") {
|
|
1336
|
-
return jsonResponse({ error: `${field}.model must be a string` }, 400);
|
|
1337
|
-
}
|
|
1338
|
-
}
|
|
1339
|
-
const next = { ...(config.claudeCode ?? {}) };
|
|
1340
|
-
for (const field of ["webSearchSidecar", "visionSidecar"] as const) {
|
|
1341
|
-
const section = body[field];
|
|
1342
|
-
if (section === undefined) continue;
|
|
1343
|
-
if (section === null || Object.keys(section as Record<string, unknown>).length === 0) {
|
|
1344
|
-
delete next[field];
|
|
1345
|
-
continue;
|
|
1346
|
-
}
|
|
1347
|
-
const requested = section as { backend?: "openai" | "anthropic" | null; model?: string };
|
|
1348
|
-
const override: NonNullable<OcxClaudeCodeConfig[typeof field]> = { ...next[field] };
|
|
1349
|
-
if (requested.backend === null) delete override.backend;
|
|
1350
|
-
else if (requested.backend !== undefined) override.backend = requested.backend;
|
|
1351
|
-
if (requested.model === "") delete override.model;
|
|
1352
|
-
else if (requested.model !== undefined) override.model = requested.model;
|
|
1353
|
-
if (Object.keys(override).length > 0) next[field] = override;
|
|
1354
|
-
else delete next[field];
|
|
1355
|
-
}
|
|
1356
|
-
if (body.enabled !== undefined) {
|
|
1357
|
-
if (typeof body.enabled !== "boolean") return jsonResponse({ error: "enabled must be a boolean" }, 400);
|
|
1358
|
-
next.enabled = body.enabled;
|
|
1359
|
-
}
|
|
1360
|
-
if (body.authMode !== undefined) {
|
|
1361
|
-
// "proxy" stores the key; "subscription" (the default) deletes it —
|
|
1362
|
-
// OcxClaudeCodeConfig.authMode is typed `"proxy"` only (src/types.ts).
|
|
1363
|
-
// Previously this field was silently dropped, so the GUI select reverted to
|
|
1364
|
-
// Subscription on every reload (devlog 260720_claude_authmode_persist).
|
|
1365
|
-
if (body.authMode !== "proxy" && body.authMode !== "subscription") {
|
|
1366
|
-
return jsonResponse({ error: "authMode must be \"proxy\" or \"subscription\"" }, 400);
|
|
1367
|
-
}
|
|
1368
|
-
if (body.authMode === "proxy") next.authMode = "proxy";
|
|
1369
|
-
else delete next.authMode;
|
|
1370
|
-
}
|
|
1371
|
-
if (body.systemEnv !== undefined) {
|
|
1372
|
-
if (typeof body.systemEnv !== "boolean") return jsonResponse({ error: "systemEnv must be a boolean" }, 400);
|
|
1373
|
-
next.systemEnv = body.systemEnv;
|
|
1374
|
-
}
|
|
1375
|
-
if (body.alwaysEnableEffort !== undefined) {
|
|
1376
|
-
if (typeof body.alwaysEnableEffort !== "boolean") return jsonResponse({ error: "alwaysEnableEffort must be a boolean" }, 400);
|
|
1377
|
-
if (body.alwaysEnableEffort) next.alwaysEnableEffort = true;
|
|
1378
|
-
else delete next.alwaysEnableEffort;
|
|
1379
|
-
}
|
|
1380
|
-
if (body.maxContextTokens !== undefined) {
|
|
1381
|
-
// CONFIG-ONLY back-compat (GUI control removed — superseded by auto-context):
|
|
1382
|
-
// null clears; otherwise a positive integer (devlog 136 B6).
|
|
1383
|
-
if (body.maxContextTokens === null) {
|
|
1384
|
-
delete next.maxContextTokens;
|
|
1385
|
-
} else if (typeof body.maxContextTokens !== "number" || !Number.isInteger(body.maxContextTokens) || body.maxContextTokens <= 0) {
|
|
1386
|
-
return jsonResponse({ error: "maxContextTokens must be a positive integer or null" }, 400);
|
|
1387
|
-
} else {
|
|
1388
|
-
next.maxContextTokens = body.maxContextTokens;
|
|
1389
|
-
}
|
|
1390
|
-
}
|
|
1391
|
-
if (body.autoContext !== undefined) {
|
|
1392
|
-
// Default-on boolean (devlog 260712 020): true = drop the key, false = store.
|
|
1393
|
-
if (typeof body.autoContext !== "boolean") return jsonResponse({ error: "autoContext must be a boolean" }, 400);
|
|
1394
|
-
if (body.autoContext) delete next.autoContext;
|
|
1395
|
-
else next.autoContext = false;
|
|
1396
|
-
}
|
|
1397
|
-
if (body.injectAgents !== undefined) {
|
|
1398
|
-
// Default-on boolean (devlog 260712 070): true = drop the key, false = store.
|
|
1399
|
-
if (typeof body.injectAgents !== "boolean") return jsonResponse({ error: "injectAgents must be a boolean" }, 400);
|
|
1400
|
-
if (body.injectAgents) delete next.injectAgents;
|
|
1401
|
-
else next.injectAgents = false;
|
|
1402
|
-
}
|
|
1403
|
-
if (body.autoCompactWindow !== undefined) {
|
|
1404
|
-
// null resets to the 350k default; otherwise the binary-accepted range
|
|
1405
|
-
// 100_000..1_000_000 (2.1.207 pSo/yDs — audit 021 #1).
|
|
1406
|
-
if (body.autoCompactWindow === null) {
|
|
1407
|
-
delete next.autoCompactWindow;
|
|
1408
|
-
} else if (typeof body.autoCompactWindow !== "number" || !Number.isInteger(body.autoCompactWindow) || body.autoCompactWindow < 100_000 || body.autoCompactWindow > 1_000_000) {
|
|
1409
|
-
return jsonResponse({ error: "autoCompactWindow must be an integer between 100000 and 1000000, or null" }, 400);
|
|
1410
|
-
} else {
|
|
1411
|
-
next.autoCompactWindow = body.autoCompactWindow;
|
|
1412
|
-
}
|
|
1413
|
-
}
|
|
1414
|
-
if (body.blockedSkills !== undefined) {
|
|
1415
|
-
// null resets to the default (["claude-api"]); an array (possibly empty = off)
|
|
1416
|
-
// must contain non-empty strings (devlog 060).
|
|
1417
|
-
if (body.blockedSkills === null) {
|
|
1418
|
-
delete next.blockedSkills;
|
|
1419
|
-
} else if (!Array.isArray(body.blockedSkills) || body.blockedSkills.some(s => typeof s !== "string" || s.trim() === "")) {
|
|
1420
|
-
return jsonResponse({ error: "blockedSkills must be an array of non-empty strings, or null" }, 400);
|
|
1421
|
-
} else {
|
|
1422
|
-
next.blockedSkills = (body.blockedSkills as string[]).map(s => s.trim());
|
|
1423
|
-
}
|
|
1424
|
-
}
|
|
1425
|
-
if (body.tierModels !== undefined) {
|
|
1426
|
-
// CONFIG-ONLY back-compat (GUI pickers removed — roster agents supersede tiers).
|
|
1427
|
-
if (body.tierModels === null) {
|
|
1428
|
-
delete next.tierModels;
|
|
1429
|
-
} else if (!isPlainObject(body.tierModels)) {
|
|
1430
|
-
return jsonResponse({ error: "tierModels must be an object with string values, or null" }, 400);
|
|
1431
|
-
} else {
|
|
1432
|
-
for (const [tier, value] of Object.entries(body.tierModels)) {
|
|
1433
|
-
if (typeof value !== "string") return jsonResponse({ error: `tierModels.${tier} must be a string` }, 400);
|
|
1434
|
-
}
|
|
1435
|
-
const tierModels = body.tierModels as Record<string, string>;
|
|
1436
|
-
const tiers: Record<string, string> = {};
|
|
1437
|
-
for (const tier of ["opus", "sonnet", "haiku", "fable"] as const) {
|
|
1438
|
-
const value = tierModels[tier];
|
|
1439
|
-
if (value !== undefined && value.trim() !== "") tiers[tier] = value.trim();
|
|
1440
|
-
}
|
|
1441
|
-
if (Object.keys(tiers).length > 0) next.tierModels = tiers;
|
|
1442
|
-
else delete next.tierModels;
|
|
1443
|
-
}
|
|
1444
|
-
}
|
|
1445
|
-
if (body.fastMode !== undefined) {
|
|
1446
|
-
if (body.fastMode !== true && body.fastMode !== false && body.fastMode !== null) {
|
|
1447
|
-
return jsonResponse({ error: "fastMode must be true, false, or null" }, 400);
|
|
1448
|
-
}
|
|
1449
|
-
config.fastMode = body.fastMode === null ? undefined : body.fastMode;
|
|
1450
|
-
}
|
|
1451
|
-
for (const field of ["model", "smallFastModel"] as const) {
|
|
1452
|
-
const value = body[field];
|
|
1453
|
-
if (value === undefined) continue;
|
|
1454
|
-
if (typeof value !== "string") return jsonResponse({ error: `${field} must be a string` }, 400);
|
|
1455
|
-
if (value.trim() === "") delete next[field];
|
|
1456
|
-
else next[field] = value.trim();
|
|
1457
|
-
}
|
|
1458
|
-
if (body.modelMap !== undefined) {
|
|
1459
|
-
if (body.modelMap === null) {
|
|
1460
|
-
delete next.modelMap;
|
|
1461
|
-
} else {
|
|
1462
|
-
if (!isPlainObject(body.modelMap)) {
|
|
1463
|
-
return jsonResponse({ error: "modelMap must be an object of string->string, or null" }, 400);
|
|
1464
|
-
}
|
|
1465
|
-
const map: Record<string, string> = {};
|
|
1466
|
-
for (const [k, v] of Object.entries(body.modelMap)) {
|
|
1467
|
-
if (typeof v !== "string" || k.trim() === "" || v.trim() === "") {
|
|
1468
|
-
return jsonResponse({ error: "modelMap entries must be non-empty strings" }, 400);
|
|
1469
|
-
}
|
|
1470
|
-
map[k.trim()] = v.trim();
|
|
1471
|
-
}
|
|
1472
|
-
if (Object.keys(map).length > 0) next.modelMap = map;
|
|
1473
|
-
else delete next.modelMap;
|
|
1474
|
-
}
|
|
1475
|
-
}
|
|
1476
|
-
config.claudeCode = next;
|
|
1477
|
-
const { saveConfig: save } = await import("../config");
|
|
1478
|
-
save(config);
|
|
1479
|
-
const warnings: string[] = [];
|
|
1480
|
-
// authMode changes must reconcile the injected system env too: switching back to
|
|
1481
|
-
// Subscription has to remove the opencodex-owned dummy ANTHROPIC_AUTH_TOKEN
|
|
1482
|
-
// (audit R1 blocker #1/#2, devlog 260720_claude_authmode_persist).
|
|
1483
|
-
if (body.systemEnv !== undefined || body.authMode !== undefined) {
|
|
1484
|
-
try {
|
|
1485
|
-
await applySystemEnvToggle(config, config.port);
|
|
1486
|
-
} catch (err) {
|
|
1487
|
-
warnings.push(`Failed to apply system environment setting: ${err instanceof Error ? err.message : String(err)}`);
|
|
1488
|
-
}
|
|
1489
|
-
}
|
|
1490
|
-
// Keep the file-backed live registry symmetric: OFF prunes immediately, while
|
|
1491
|
-
// ON and config changes restore definitions without requiring a restart.
|
|
1492
|
-
await syncClaudeAgentDefsBestEffort();
|
|
1493
|
-
return jsonResponse({ ok: true, enabled: next.enabled !== false, warnings });
|
|
1494
|
-
}
|
|
1495
|
-
|
|
1496
|
-
// Per-provider catalog allowlist (issue #52): when a provider has a non-empty selectedModels list,
|
|
1497
|
-
// only those ids ship to Codex's catalog / /v1/models. GET returns the CURRENT selection plus the
|
|
1498
|
-
// FULL available set per provider (unfiltered — the picker needs everything to choose from).
|
|
1499
|
-
if (url.pathname === "/api/selected-models" && req.method === "GET") {
|
|
1500
|
-
const models = await fetchAllModels(config);
|
|
1501
|
-
const available: Record<string, string[]> = {};
|
|
1502
|
-
for (const m of models) (available[m.provider] ??= []).push(m.id);
|
|
1503
|
-
const selected: Record<string, string[]> = {};
|
|
1504
|
-
for (const [name, prov] of Object.entries(config.providers)) {
|
|
1505
|
-
if (Array.isArray(prov.selectedModels) && prov.selectedModels.length > 0) selected[name] = [...prov.selectedModels];
|
|
1506
|
-
}
|
|
1507
|
-
return jsonResponse({ selected, available });
|
|
1508
|
-
}
|
|
1509
|
-
if (url.pathname === "/api/selected-models" && req.method === "PUT") {
|
|
1510
|
-
let body: { provider?: unknown; models?: unknown };
|
|
1511
|
-
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
1512
|
-
const provider = typeof body.provider === "string" ? body.provider : "";
|
|
1513
|
-
if (!provider || !hasOwnProvider(config.providers, provider)) {
|
|
1514
|
-
return jsonResponse({ error: "unknown provider" }, provider ? 404 : 400);
|
|
1515
|
-
}
|
|
1516
|
-
const models = Array.isArray(body.models)
|
|
1517
|
-
? [...new Set(body.models.filter((m): m is string => typeof m === "string"))]
|
|
1518
|
-
: [];
|
|
1519
|
-
// Empty list clears the allowlist (provider reverts to exposing all models).
|
|
1520
|
-
if (models.length > 0) config.providers[provider].selectedModels = models;
|
|
1521
|
-
else delete config.providers[provider].selectedModels;
|
|
1522
|
-
const { saveConfig: save } = await import("../config");
|
|
1523
|
-
save(config);
|
|
1524
|
-
await refreshCodexCatalogBestEffort();
|
|
1525
|
-
return jsonResponse({ ok: true, provider, selected: models });
|
|
1526
|
-
}
|
|
1527
|
-
|
|
1528
|
-
// OAuth login (xai now; anthropic/kimi in cycle 2). Starts the flow and returns the auth URL;
|
|
1529
|
-
// the provider's loopback callback server (inside this process) captures the redirect in the
|
|
1530
|
-
// background, then the credential is persisted. The GUI opens the URL and polls /api/oauth/status.
|
|
1531
|
-
if (url.pathname === "/api/oauth/login" && req.method === "POST") {
|
|
1532
|
-
const body = await req.json().catch(() => ({})) as { provider?: string; addAccount?: boolean; accountId?: string; reauth?: boolean };
|
|
1533
|
-
const provider = (body.provider ?? "").trim().toLowerCase();
|
|
1534
|
-
if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
|
|
1535
|
-
const accountId = body.accountId?.trim();
|
|
1536
|
-
const reauth = body.reauth === true || Boolean(accountId);
|
|
1537
|
-
try {
|
|
1538
|
-
if (accountId) {
|
|
1539
|
-
const { getAccountSet } = await import("../oauth/store");
|
|
1540
|
-
const set = getAccountSet(provider);
|
|
1541
|
-
if (!set?.accounts.some(a => a.id === accountId)) {
|
|
1542
|
-
return jsonResponse({ error: "Unknown account for reauth" }, 404);
|
|
1543
|
-
}
|
|
1544
|
-
}
|
|
1545
|
-
// addAccount / reauth forces a fresh browser identity (skips local-CLI token import).
|
|
1546
|
-
const { url: authUrl, instructions, deviceCode } = await startLoginFlow(provider, {
|
|
1547
|
-
forceLogin: body.addAccount === true || reauth,
|
|
1548
|
-
...(accountId ? { reauthAccountId: accountId } : {}),
|
|
1549
|
-
});
|
|
1550
|
-
upsertOAuthProvider(config, provider); // mutate LIVE config — routing sees it without restart
|
|
1551
|
-
if (authUrl && !deviceCode) {
|
|
1552
|
-
// Open the browser server-side (the proxy runs on the user's machine) — the GUI's
|
|
1553
|
-
// window.open is popup-blocked because it runs after an await, not a direct click.
|
|
1554
|
-
const { openUrl } = await import("../lib/open-url");
|
|
1555
|
-
openUrl(authUrl);
|
|
1556
|
-
}
|
|
1557
|
-
return jsonResponse({ url: authUrl, instructions, deviceCode });
|
|
1558
|
-
} catch (err) {
|
|
1559
|
-
return jsonResponse({ error: err instanceof Error ? err.message : String(err) }, 409);
|
|
1560
|
-
}
|
|
1561
|
-
}
|
|
1562
|
-
|
|
1563
|
-
// Cancel an in-progress browser/device OAuth login (GUI "Cancel" / modal close). Guarded by
|
|
1564
|
-
// the same public predicate as /api/oauth/login — only publicly startable flows are cancellable.
|
|
1565
|
-
if (url.pathname === "/api/oauth/login/cancel" && req.method === "POST") {
|
|
1566
|
-
const body = await req.json().catch(() => ({})) as { provider?: string };
|
|
1567
|
-
const provider = (body.provider ?? "").trim().toLowerCase();
|
|
1568
|
-
if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
|
|
1569
|
-
const { cancelLoginFlow } = await import("../oauth");
|
|
1570
|
-
const cancelled = cancelLoginFlow(provider);
|
|
1571
|
-
return jsonResponse({ ok: true, cancelled });
|
|
1572
|
-
}
|
|
1573
|
-
|
|
1574
|
-
// Manual fallback for browser OAuth: paste the final redirect URL (or authorization code)
|
|
1575
|
-
// when the browser cannot reach the loopback callback (remote/SSH/blocked localhost).
|
|
1576
|
-
if (url.pathname === "/api/oauth/login/code" && req.method === "POST") {
|
|
1577
|
-
const body = await req.json().catch(() => ({})) as { provider?: string; input?: string; code?: string };
|
|
1578
|
-
const provider = (body.provider ?? "").trim().toLowerCase();
|
|
1579
|
-
if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
|
|
1580
|
-
const input = typeof body.input === "string" ? body.input : typeof body.code === "string" ? body.code : "";
|
|
1581
|
-
// Authorization responses are measured in hundreds of bytes; never accept the
|
|
1582
|
-
// generic management-body allowance here.
|
|
1583
|
-
if (input.length > 4096) return jsonResponse({ error: "input too long" }, 400);
|
|
1584
|
-
const result = submitManualLoginCode(provider, input);
|
|
1585
|
-
if (!result.ok) return jsonResponse({ error: result.error }, 409);
|
|
1586
|
-
return jsonResponse({ ok: true });
|
|
1587
|
-
}
|
|
1588
|
-
|
|
1589
|
-
if (url.pathname === "/api/oauth/status" && req.method === "GET") {
|
|
1590
|
-
const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
|
|
1591
|
-
if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
|
|
1592
|
-
return jsonResponse(getLoginStatus(provider));
|
|
1593
|
-
}
|
|
1594
|
-
|
|
1595
|
-
if (url.pathname === "/api/oauth/logout" && req.method === "POST") {
|
|
1596
|
-
const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
|
|
1597
|
-
if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
|
|
1598
|
-
await removeCredential(provider);
|
|
1599
|
-
clearLoginState(provider);
|
|
1600
|
-
// Drop cached/last-good quota rows tied to the removed credential.
|
|
1601
|
-
const { clearProviderQuotaCache } = await import("../providers/quota");
|
|
1602
|
-
clearProviderQuotaCache();
|
|
1603
|
-
return jsonResponse({ success: true });
|
|
1604
|
-
}
|
|
1605
|
-
|
|
1606
|
-
// Multiauth account management: list a provider's logged-in accounts, switch the active
|
|
1607
|
-
// one, or remove one. Emails are masked; tokens never leave the store.
|
|
1608
|
-
if (url.pathname === "/api/oauth/accounts" && req.method === "GET") {
|
|
1609
|
-
const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
|
|
1610
|
-
if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
|
|
1611
|
-
const status = getLoginStatus(provider);
|
|
1612
|
-
return jsonResponse({ activeAccountId: status.activeAccountId ?? null, accounts: status.accounts ?? [] });
|
|
1613
|
-
}
|
|
1614
|
-
if (url.pathname === "/api/oauth/accounts/active" && req.method === "PUT") {
|
|
1615
|
-
const body = await req.json().catch(() => ({})) as { provider?: string; accountId?: string };
|
|
1616
|
-
const provider = (body.provider ?? "").trim().toLowerCase();
|
|
1617
|
-
if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
|
|
1618
|
-
if (!body.accountId) return jsonResponse({ error: "missing accountId" }, 400);
|
|
1619
|
-
const { setActiveAccount } = await import("../oauth/store");
|
|
1620
|
-
if (!(await setActiveAccount(provider, body.accountId))) return jsonResponse({ error: "account not found" }, 404);
|
|
1621
|
-
const { clearProviderQuotaCache } = await import("../providers/quota");
|
|
1622
|
-
clearProviderQuotaCache();
|
|
1623
|
-
return jsonResponse({ ok: true, provider, activeAccountId: body.accountId });
|
|
1624
|
-
}
|
|
1625
|
-
if (url.pathname === "/api/oauth/accounts/alias" && req.method === "PUT") {
|
|
1626
|
-
const body = await req.json().catch(() => ({})) as { provider?: unknown; accountId?: unknown; alias?: unknown };
|
|
1627
|
-
const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : "";
|
|
1628
|
-
const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
|
|
1629
|
-
const alias = typeof body.alias === "string" ? body.alias.trim() : "";
|
|
1630
|
-
if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
|
|
1631
|
-
if (!accountId) return jsonResponse({ error: "missing accountId" }, 400);
|
|
1632
|
-
if (typeof body.alias !== "string" || alias.length > 80 || /[\x00-\x1f\x7f]/.test(alias)) {
|
|
1633
|
-
return jsonResponse({ error: "alias must be at most 80 printable characters" }, 400);
|
|
1634
|
-
}
|
|
1635
|
-
const { setAccountAlias } = await import("../oauth/store");
|
|
1636
|
-
if (!(await setAccountAlias(provider, accountId, alias || undefined))) return jsonResponse({ error: "account not found" }, 404);
|
|
1637
|
-
return jsonResponse({ ok: true, provider, accountId, alias: alias || null });
|
|
1638
|
-
}
|
|
1639
|
-
if (url.pathname === "/api/oauth/accounts" && req.method === "DELETE") {
|
|
1640
|
-
const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
|
|
1641
|
-
const id = url.searchParams.get("id") ?? "";
|
|
1642
|
-
if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
|
|
1643
|
-
if (!id) return jsonResponse({ error: "missing id" }, 400);
|
|
1644
|
-
const { removeAccount, getAccountSet } = await import("../oauth/store");
|
|
1645
|
-
if (!(await removeAccount(provider, id))) return jsonResponse({ error: "account not found" }, 404);
|
|
1646
|
-
if (!getAccountSet(provider)) clearLoginState(provider);
|
|
1647
|
-
const { clearProviderQuotaCache } = await import("../providers/quota");
|
|
1648
|
-
clearProviderQuotaCache();
|
|
1649
|
-
return jsonResponse({ ok: true });
|
|
1650
|
-
}
|
|
1651
|
-
|
|
1652
|
-
// Multi-key pool for API-key providers (same GUI dropdown as OAuth multiauth): list masked
|
|
1653
|
-
// keys, add one (upserts + activates), switch the active key, or remove one. `apiKey` always
|
|
1654
|
-
// mirrors the active entry so routing is untouched.
|
|
1655
|
-
if (url.pathname === "/api/providers/keys" && req.method === "GET") {
|
|
1656
|
-
const name = (url.searchParams.get("name") ?? "").trim();
|
|
1657
|
-
if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
|
|
1658
|
-
const { listProviderApiKeys } = await import("../providers/api-keys");
|
|
1659
|
-
return jsonResponse(listProviderApiKeys(config, name));
|
|
1660
|
-
}
|
|
1661
|
-
if (url.pathname === "/api/providers/keys" && req.method === "POST") {
|
|
1662
|
-
const body = await req.json().catch(() => ({})) as { name?: string; key?: string; label?: string };
|
|
1663
|
-
const name = (body.name ?? "").trim();
|
|
1664
|
-
if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
|
|
1665
|
-
if (typeof body.key !== "string" || !body.key.trim()) return jsonResponse({ error: "key is required" }, 400);
|
|
1666
|
-
const { addProviderApiKey } = await import("../providers/api-keys");
|
|
1667
|
-
const result = addProviderApiKey(config, name, body.key, body.label);
|
|
1668
|
-
if ("error" in result) return jsonResponse({ error: result.error }, 400);
|
|
1669
|
-
const { clearModelCache } = await import("../codex/model-cache");
|
|
1670
|
-
clearModelCache(name);
|
|
1671
|
-
const { clearProviderQuotaCache } = await import("../providers/quota");
|
|
1672
|
-
clearProviderQuotaCache();
|
|
1673
|
-
const { clearKeyCooldowns } = await import("../providers/key-failover");
|
|
1674
|
-
clearKeyCooldowns(name); // manual key management resets 429 cooldown state
|
|
1675
|
-
return jsonResponse({ ok: true, id: result.id }, 201);
|
|
1676
|
-
}
|
|
1677
|
-
if (url.pathname === "/api/providers/keys/active" && req.method === "PUT") {
|
|
1678
|
-
const body = await req.json().catch(() => ({})) as { name?: string; id?: string };
|
|
1679
|
-
const name = (body.name ?? "").trim();
|
|
1680
|
-
if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
|
|
1681
|
-
if (!body.id) return jsonResponse({ error: "missing id" }, 400);
|
|
1682
|
-
const { setActiveProviderApiKey } = await import("../providers/api-keys");
|
|
1683
|
-
if (!setActiveProviderApiKey(config, name, body.id)) return jsonResponse({ error: "key not found" }, 404);
|
|
1684
|
-
const { clearModelCache } = await import("../codex/model-cache");
|
|
1685
|
-
clearModelCache(name);
|
|
1686
|
-
const { clearProviderQuotaCache } = await import("../providers/quota");
|
|
1687
|
-
clearProviderQuotaCache();
|
|
1688
|
-
const { clearKeyCooldowns } = await import("../providers/key-failover");
|
|
1689
|
-
clearKeyCooldowns(name); // manual key management resets 429 cooldown state
|
|
1690
|
-
return jsonResponse({ ok: true, name, activeId: body.id });
|
|
1691
|
-
}
|
|
1692
|
-
if (url.pathname === "/api/providers/keys/alias" && req.method === "PUT") {
|
|
1693
|
-
const body = await req.json().catch(() => ({})) as { name?: unknown; id?: unknown; alias?: unknown };
|
|
1694
|
-
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
1695
|
-
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
1696
|
-
const alias = typeof body.alias === "string" ? body.alias.trim() : "";
|
|
1697
|
-
if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
|
|
1698
|
-
if (!id) return jsonResponse({ error: "missing id" }, 400);
|
|
1699
|
-
if (typeof body.alias !== "string" || alias.length > 80 || /[\x00-\x1f\x7f]/.test(alias)) {
|
|
1700
|
-
return jsonResponse({ error: "alias must be at most 80 printable characters" }, 400);
|
|
1701
|
-
}
|
|
1702
|
-
const { setProviderApiKeyLabel } = await import("../providers/api-keys");
|
|
1703
|
-
if (!setProviderApiKeyLabel(config, name, id, alias || undefined)) return jsonResponse({ error: "key not found" }, 404);
|
|
1704
|
-
return jsonResponse({ ok: true, name, id, alias: alias || null });
|
|
1705
|
-
}
|
|
1706
|
-
if (url.pathname === "/api/providers/keys" && req.method === "DELETE") {
|
|
1707
|
-
const name = (url.searchParams.get("name") ?? "").trim();
|
|
1708
|
-
const id = url.searchParams.get("id") ?? "";
|
|
1709
|
-
if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
|
|
1710
|
-
if (!id) return jsonResponse({ error: "missing id" }, 400);
|
|
1711
|
-
const { removeProviderApiKey } = await import("../providers/api-keys");
|
|
1712
|
-
if (!removeProviderApiKey(config, name, id)) return jsonResponse({ error: "key not found" }, 404);
|
|
1713
|
-
const { clearModelCache } = await import("../codex/model-cache");
|
|
1714
|
-
clearModelCache(name);
|
|
1715
|
-
const { clearProviderQuotaCache } = await import("../providers/quota");
|
|
1716
|
-
clearProviderQuotaCache();
|
|
1717
|
-
const { clearKeyCooldowns } = await import("../providers/key-failover");
|
|
1718
|
-
clearKeyCooldowns(name); // manual key management resets 429 cooldown state
|
|
1719
|
-
return jsonResponse({ ok: true });
|
|
1720
|
-
}
|
|
1721
|
-
|
|
1722
|
-
// ---------------------------------------------------------------------------
|
|
1723
|
-
// API Keys management
|
|
1724
|
-
// ---------------------------------------------------------------------------
|
|
1725
|
-
if (url.pathname === "/api/keys" && req.method === "GET") {
|
|
1726
|
-
const keys = config.apiKeys ?? [];
|
|
1727
|
-
return jsonResponse({ keys: keys.map(k => ({ id: k.id, name: k.name, prefix: k.key.slice(0, 8) + "...", createdAt: k.createdAt })), endpoint: `http://${config.hostname ?? "127.0.0.1"}:${config.port ?? 10100}/v1/responses` }, 200, req, config);
|
|
1728
|
-
}
|
|
1729
|
-
|
|
1730
|
-
if (url.pathname === "/api/keys" && req.method === "POST") {
|
|
1731
|
-
const body = await req.json() as { name?: string };
|
|
1732
|
-
const name = (body.name ?? "").trim() || "default";
|
|
1733
|
-
// Generate key from provider keys hash + random salt
|
|
1734
|
-
const providerKeys = Object.values(config.providers).map(p => p.apiKey ?? "").filter(Boolean).join("|");
|
|
1735
|
-
const salt = crypto.randomUUID();
|
|
1736
|
-
const hashInput = `${providerKeys}|${salt}|${Date.now()}`;
|
|
1737
|
-
const hashBuf = new Bun.CryptoHasher("sha256").update(hashInput).digest();
|
|
1738
|
-
const key = "ocx_" + Buffer.from(hashBuf).toString("hex").slice(0, 40);
|
|
1739
|
-
const entry = { id: crypto.randomUUID(), name, key, createdAt: new Date().toISOString() };
|
|
1740
|
-
config.apiKeys = [...(config.apiKeys ?? []), entry];
|
|
1741
|
-
saveConfig(config);
|
|
1742
|
-
return jsonResponse({ id: entry.id, name: entry.name, key: entry.key, createdAt: entry.createdAt }, 201, req, config);
|
|
1743
|
-
}
|
|
1744
|
-
|
|
1745
|
-
if (url.pathname === "/api/keys" && req.method === "DELETE") {
|
|
1746
|
-
const body = await req.json() as { id?: string };
|
|
1747
|
-
if (!body.id) return jsonResponse({ error: "id required" }, 400, req, config);
|
|
1748
|
-
config.apiKeys = (config.apiKeys ?? []).filter(k => k.id !== body.id);
|
|
1749
|
-
saveConfig(config);
|
|
1750
|
-
return jsonResponse({ success: true }, 200, req, config);
|
|
1751
|
-
}
|
|
1752
|
-
|
|
1753
|
-
if (url.pathname === "/api/combos" && req.method === "GET") {
|
|
1754
|
-
const { comboPublicModelId, getCombo, listComboIds } = await import("../combos");
|
|
1755
|
-
return jsonResponse({ combos: listComboIds(config).map(id => {
|
|
1756
|
-
const combo = getCombo(config, id)!;
|
|
1757
|
-
return {
|
|
1758
|
-
id,
|
|
1759
|
-
model: comboPublicModelId(id, combo),
|
|
1760
|
-
...combo,
|
|
1761
|
-
};
|
|
1762
|
-
}) });
|
|
1763
|
-
}
|
|
1764
|
-
|
|
1765
|
-
if (url.pathname === "/api/combos" && req.method === "PUT") {
|
|
1766
|
-
let rawBody: unknown;
|
|
1767
|
-
try { rawBody = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
1768
|
-
if (!isPlainRecord(rawBody)) {
|
|
1769
|
-
return jsonResponse({ error: "request body must be an object" }, 400);
|
|
1770
|
-
}
|
|
1771
|
-
const body = rawBody;
|
|
1772
|
-
if (typeof body.id !== "string" || !body.id.trim()) {
|
|
1773
|
-
return jsonResponse({ error: "id is required and must be a string" }, 400);
|
|
1774
|
-
}
|
|
1775
|
-
const id = body.id.trim();
|
|
1776
|
-
let renameFrom: string | undefined;
|
|
1777
|
-
if (body.renameFrom !== undefined) {
|
|
1778
|
-
if (typeof body.renameFrom !== "string" || !body.renameFrom.trim()) {
|
|
1779
|
-
return jsonResponse({ error: "renameFrom must be a non-empty string" }, 400);
|
|
1780
|
-
}
|
|
1781
|
-
renameFrom = body.renameFrom.trim();
|
|
1782
|
-
if (renameFrom === id) {
|
|
1783
|
-
return jsonResponse({ error: "renameFrom must differ from id" }, 400);
|
|
1784
|
-
}
|
|
1785
|
-
if (!Object.hasOwn(config.combos ?? {}, renameFrom)) {
|
|
1786
|
-
return jsonResponse({ error: `combo "${renameFrom}" does not exist` }, 400);
|
|
1787
|
-
}
|
|
1788
|
-
if (Object.hasOwn(config.combos ?? {}, id)) {
|
|
1789
|
-
return jsonResponse({ error: `combo "${id}" already exists` }, 400);
|
|
1790
|
-
}
|
|
1791
|
-
}
|
|
1792
|
-
const {
|
|
1793
|
-
clearComboSelectionState,
|
|
1794
|
-
clearComboTargetCooldowns,
|
|
1795
|
-
comboConfigError,
|
|
1796
|
-
comboModelId,
|
|
1797
|
-
comboPublicModelId,
|
|
1798
|
-
normalizeComboConfig,
|
|
1799
|
-
} = await import("../combos");
|
|
1800
|
-
const error = comboConfigError(id, body.combo, config.providers, {
|
|
1801
|
-
requireEnabledTarget: true,
|
|
1802
|
-
combos: config.combos,
|
|
1803
|
-
excludeComboId: renameFrom ?? id,
|
|
1804
|
-
});
|
|
1805
|
-
if (error) return jsonResponse({ error }, 400);
|
|
1806
|
-
const normalized = normalizeComboConfig(body.combo as import("../types").OcxComboConfig);
|
|
1807
|
-
const stored: import("../types").OcxComboConfig = normalized.alias === null
|
|
1808
|
-
? (({ alias: _alias, ...rest }) => rest)(normalized)
|
|
1809
|
-
: normalized;
|
|
1810
|
-
const sourceId = renameFrom ?? id;
|
|
1811
|
-
const previous = config.combos?.[sourceId];
|
|
1812
|
-
const oldPublicModel = previous ? comboPublicModelId(sourceId, previous) : null;
|
|
1813
|
-
const newPublicModel = comboPublicModelId(id, normalized);
|
|
1814
|
-
const nextCombos = { ...(config.combos ?? {}) };
|
|
1815
|
-
if (renameFrom) delete nextCombos[renameFrom];
|
|
1816
|
-
nextCombos[id] = stored;
|
|
1817
|
-
config.combos = nextCombos;
|
|
1818
|
-
let shouldSyncClaudeAgentDefs = false;
|
|
1819
|
-
const migratedModels = new Set<string>();
|
|
1820
|
-
if (oldPublicModel && oldPublicModel !== newPublicModel) {
|
|
1821
|
-
migratedModels.add(oldPublicModel);
|
|
1822
|
-
}
|
|
1823
|
-
if (renameFrom) migratedModels.add(comboModelId(renameFrom));
|
|
1824
|
-
if (migratedModels.size > 0) {
|
|
1825
|
-
const migrateReference = (model: string): string => (
|
|
1826
|
-
migratedModels.has(model) ? newPublicModel : model
|
|
1827
|
-
);
|
|
1828
|
-
const migrateAgentReference = (model: string): string => {
|
|
1829
|
-
const migrated = migrateReference(model);
|
|
1830
|
-
if (migrated !== model) shouldSyncClaudeAgentDefs = true;
|
|
1831
|
-
return migrated;
|
|
1832
|
-
};
|
|
1833
|
-
const migrateReferences = (models: string[]): string[] => [
|
|
1834
|
-
...new Set(models.map(migrateReference)),
|
|
1835
|
-
];
|
|
1836
|
-
if (config.disabledModels) {
|
|
1837
|
-
config.disabledModels = migrateReferences(config.disabledModels);
|
|
1838
|
-
}
|
|
1839
|
-
if (config.subagentModels) {
|
|
1840
|
-
config.subagentModels = [...new Set(config.subagentModels.map(migrateAgentReference))];
|
|
1841
|
-
}
|
|
1842
|
-
if (config.injectionModel && migratedModels.has(config.injectionModel)) {
|
|
1843
|
-
config.injectionModel = newPublicModel;
|
|
1844
|
-
}
|
|
1845
|
-
if (config.shadowCallIntercept?.model && migratedModels.has(config.shadowCallIntercept.model)) {
|
|
1846
|
-
config.shadowCallIntercept = {
|
|
1847
|
-
...config.shadowCallIntercept,
|
|
1848
|
-
model: newPublicModel,
|
|
1849
|
-
};
|
|
1850
|
-
}
|
|
1851
|
-
if (config.claudeCode) {
|
|
1852
|
-
const claudeCode = { ...config.claudeCode };
|
|
1853
|
-
for (const field of ["model", "smallFastModel"] as const) {
|
|
1854
|
-
if (claudeCode[field]) claudeCode[field] = migrateAgentReference(claudeCode[field]);
|
|
1855
|
-
}
|
|
1856
|
-
if (claudeCode.tierModels) {
|
|
1857
|
-
claudeCode.tierModels = Object.fromEntries(
|
|
1858
|
-
Object.entries(claudeCode.tierModels).map(([tier, model]) => [tier, migrateAgentReference(model)]),
|
|
1859
|
-
);
|
|
1860
|
-
}
|
|
1861
|
-
if (claudeCode.modelMap) {
|
|
1862
|
-
claudeCode.modelMap = Object.fromEntries(
|
|
1863
|
-
Object.entries(claudeCode.modelMap).map(([source, model]) => [source, migrateAgentReference(model)]),
|
|
1864
|
-
);
|
|
1865
|
-
}
|
|
1866
|
-
config.claudeCode = claudeCode;
|
|
1867
|
-
}
|
|
1868
|
-
}
|
|
1869
|
-
saveConfig(config);
|
|
1870
|
-
clearComboSelectionState(id);
|
|
1871
|
-
clearComboTargetCooldowns(id);
|
|
1872
|
-
if (renameFrom) {
|
|
1873
|
-
clearComboSelectionState(renameFrom);
|
|
1874
|
-
clearComboTargetCooldowns(renameFrom);
|
|
1875
|
-
}
|
|
1876
|
-
await refreshCodexCatalogBestEffort();
|
|
1877
|
-
if (shouldSyncClaudeAgentDefs) await syncClaudeAgentDefsBestEffort();
|
|
1878
|
-
return jsonResponse({ success: true, id, model: newPublicModel, combo: normalized });
|
|
1879
|
-
}
|
|
1880
|
-
|
|
1881
|
-
if (url.pathname === "/api/combos" && req.method === "DELETE") {
|
|
1882
|
-
const id = url.searchParams.get("id")?.trim();
|
|
1883
|
-
if (!id) return jsonResponse({ error: "id query param is required" }, 400);
|
|
1884
|
-
if (!Object.hasOwn(config.combos ?? {}, id)) {
|
|
1885
|
-
return jsonResponse({ error: "unknown combo" }, 404);
|
|
1886
|
-
}
|
|
1887
|
-
const { clearComboSelectionState, clearComboTargetCooldowns } = await import("../combos");
|
|
1888
|
-
delete config.combos![id];
|
|
1889
|
-
if (Object.keys(config.combos!).length === 0) delete config.combos;
|
|
1890
|
-
saveConfig(config);
|
|
1891
|
-
clearComboSelectionState(id);
|
|
1892
|
-
clearComboTargetCooldowns(id);
|
|
1893
|
-
await refreshCodexCatalogBestEffort();
|
|
1894
|
-
return jsonResponse({ success: true, id });
|
|
1895
|
-
}
|
|
123
|
+
const ctx: ManagementContext = { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort };
|
|
124
|
+
const routed =
|
|
125
|
+
(await handleConfigRoutes(ctx))
|
|
126
|
+
?? (await handleLogsUsageRoutes(ctx))
|
|
127
|
+
?? (await handleProviderRoutes(ctx))
|
|
128
|
+
?? (await handleModelRoutes(ctx))
|
|
129
|
+
?? (await handleAgentSettingsRoutes(ctx))
|
|
130
|
+
?? (await handleOauthAccountRoutes(ctx))
|
|
131
|
+
?? (await handleComboRoutes(ctx));
|
|
132
|
+
if (routed) return routed;
|
|
1896
133
|
|
|
1897
134
|
if (url.pathname === "/api/stop" && req.method === "POST") {
|
|
1898
135
|
const { restoreNativeCodex } = await import("../codex/inject");
|
|
@@ -1916,25 +153,5 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
1916
153
|
return null;
|
|
1917
154
|
}
|
|
1918
155
|
|
|
1919
|
-
/**
|
|
1920
|
-
* Live routed-provider models for the proxy's /api/* and /v1/models endpoints. Delegates to the
|
|
1921
|
-
* canonical, TTL-cached `gatherRoutedModels` (single source of truth) — so the GUI/codex endpoints
|
|
1922
|
-
* share the same fetch, the same per-provider cache (dedups Codex's frequent /v1/models polling),
|
|
1923
|
-
* and the same stale fallback when a provider blips, instead of a parallel uncached copy.
|
|
1924
|
-
*/
|
|
1925
|
-
export async function fetchAllModels(config: OcxConfig): Promise<CatalogModel[]> {
|
|
1926
|
-
const { gatherRoutedModels } = await import("../codex/catalog");
|
|
1927
|
-
return gatherRoutedModels(config);
|
|
1928
|
-
}
|
|
1929
156
|
|
|
1930
|
-
|
|
1931
|
-
const entry = getProviderRegistryEntry(name);
|
|
1932
|
-
if (!entry?.staticHeaders || !provider.headers) return provider;
|
|
1933
|
-
const headerEntries = Object.entries(provider.headers);
|
|
1934
|
-
const staticEntries = Object.entries(entry.staticHeaders);
|
|
1935
|
-
if (headerEntries.length !== staticEntries.length) return provider;
|
|
1936
|
-
const matchesRegistryStaticHeaders = staticEntries.every(([key, value]) => provider.headers?.[key] === value);
|
|
1937
|
-
if (!matchesRegistryStaticHeaders) return provider;
|
|
1938
|
-
const { headers: _headers, ...rest } = provider;
|
|
1939
|
-
return rest;
|
|
1940
|
-
}
|
|
157
|
+
export { fetchAllModels } from "./management/shared";
|