@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
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import type { CatalogModel } from "../../codex/catalog";
|
|
4
|
+
import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog";
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_SUBAGENT_MODELS,
|
|
7
|
+
codexAutoStartEnabled,
|
|
8
|
+
hasOwnProvider,
|
|
9
|
+
isValidProviderName,
|
|
10
|
+
multiAgentGuidanceEnabled,
|
|
11
|
+
providerBaseUrlConfigError,
|
|
12
|
+
providerHeadersConfigError,
|
|
13
|
+
saveConfig,
|
|
14
|
+
} from "../../config";
|
|
15
|
+
import {
|
|
16
|
+
clearLoginState,
|
|
17
|
+
getLoginStatus,
|
|
18
|
+
isPublicOAuthProvider,
|
|
19
|
+
listOAuthProviders,
|
|
20
|
+
startLoginFlow,
|
|
21
|
+
submitManualLoginCode,
|
|
22
|
+
upsertOAuthProvider,
|
|
23
|
+
} from "../../oauth";
|
|
24
|
+
import { removeCredential } from "../../oauth/store";
|
|
25
|
+
import { providerDestinationResolvedError } from "../../lib/destination-policy";
|
|
26
|
+
import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
|
|
27
|
+
import { deriveProviderPresets } from "../../providers/derive";
|
|
28
|
+
import { providerCodexAccountMode } from "../../providers/registry";
|
|
29
|
+
import { routedSlug, slugEquals } from "../../providers/slug-codec";
|
|
30
|
+
import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota";
|
|
31
|
+
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
32
|
+
import { clearThreadAccountMap } from "../../codex/routing";
|
|
33
|
+
import { primeCodexPoolQuotas } from "../../codex/auth-api";
|
|
34
|
+
import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
|
|
35
|
+
import { resolveCodexHomeDir } from "../../codex/home";
|
|
36
|
+
import { scanStorage } from "../../storage/scanner";
|
|
37
|
+
import { readUsageEntries } from "../../usage/log";
|
|
38
|
+
import { getUsageDebugLogEntries } from "../../usage/debug";
|
|
39
|
+
import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary";
|
|
40
|
+
import { stripCodexRuntimeProviderFields } from "../../codex/auth-context";
|
|
41
|
+
import { getProviderRegistryEntry } from "../../providers/registry";
|
|
42
|
+
import { getDebugLogEntries } from "../../lib/debug-log-buffer";
|
|
43
|
+
import { getInjectionDebugLogEntries } from "../../lib/injection-debug-log";
|
|
44
|
+
import {
|
|
45
|
+
clearDebugSettings,
|
|
46
|
+
clearDebugSetting,
|
|
47
|
+
getDebugSettings,
|
|
48
|
+
setDebugSettings,
|
|
49
|
+
type DebugFlag,
|
|
50
|
+
} from "../../lib/debug-settings";
|
|
51
|
+
import type { OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../../types";
|
|
52
|
+
import { drainAndShutdown } from "../lifecycle";
|
|
53
|
+
import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log";
|
|
54
|
+
import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
|
|
55
|
+
import type { PersistedUsageAttempt } from "../../usage/log";
|
|
56
|
+
import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors";
|
|
57
|
+
import { applySystemEnvToggle } from "../system-env";
|
|
58
|
+
|
|
59
|
+
import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
|
|
60
|
+
import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
|
|
61
|
+
import type { ManagementContext } from "./context";
|
|
62
|
+
|
|
63
|
+
export async function handleProviderRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
64
|
+
const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx;
|
|
65
|
+
|
|
66
|
+
if (url.pathname === "/api/provider-quotas" && req.method === "GET") {
|
|
67
|
+
const forceRefresh = url.searchParams.get("refresh") === "1" || url.searchParams.get("refresh") === "true";
|
|
68
|
+
return jsonResponse(await fetchProviderQuotaReports(config, forceRefresh));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (url.pathname === "/api/providers" && req.method === "GET") {
|
|
72
|
+
return jsonResponse(Object.entries(config.providers).map(([name, p]) => ({
|
|
73
|
+
name, adapter: p.adapter, baseUrl: publicProviderBaseUrl(p.baseUrl), defaultModel: p.defaultModel,
|
|
74
|
+
hasApiKey: !!p.apiKey,
|
|
75
|
+
allowPrivateNetwork: p.allowPrivateNetwork === true,
|
|
76
|
+
liveModels: p.liveModels !== false,
|
|
77
|
+
models: p.models ?? [],
|
|
78
|
+
authMode: p.authMode,
|
|
79
|
+
disabled: p.disabled === true,
|
|
80
|
+
codexAccountMode: providerCodexAccountMode(name, p),
|
|
81
|
+
})));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Add (or overwrite) a single provider. Merges into the live in-memory config and
|
|
85
|
+
// persists — existing providers' real keys are never round-tripped (unlike PUT /api/config,
|
|
86
|
+
// which would re-save the masked keys from GET). Live routing picks it up immediately.
|
|
87
|
+
if (url.pathname === "/api/providers" && req.method === "POST") {
|
|
88
|
+
let body: { name?: unknown; provider?: unknown; setDefault?: boolean };
|
|
89
|
+
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
90
|
+
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
91
|
+
const providerError = providerManagementConfigError(name, body.provider);
|
|
92
|
+
if (providerError) return jsonResponse({ error: providerError }, 400);
|
|
93
|
+
const prov = body.provider ? stripCodexRuntimeProviderFields(body.provider as OcxProviderConfig) : undefined;
|
|
94
|
+
if (!name || !prov?.adapter || !prov?.baseUrl) {
|
|
95
|
+
return jsonResponse({ error: "name, provider.adapter and provider.baseUrl are required" }, 400);
|
|
96
|
+
}
|
|
97
|
+
if (!isValidProviderName(name)) {
|
|
98
|
+
return jsonResponse({ error: "provider name must use letters, numbers, dot, underscore, or hyphen and cannot be a reserved object key" }, 400);
|
|
99
|
+
}
|
|
100
|
+
// Hostname destinations additionally get a DNS-resolved SSRF check at write time —
|
|
101
|
+
// the sync check above only classifies literal IPs (review finding, PR #96).
|
|
102
|
+
const resolvedError = await providerDestinationResolvedError(name, prov);
|
|
103
|
+
if (resolvedError) return jsonResponse({ error: resolvedError }, 400);
|
|
104
|
+
// Catalog providers (e.g. ollama-cloud) carry a models + vision/reasoning classification the GUI
|
|
105
|
+
// doesn't send — merge it in so the sidecars are gated correctly.
|
|
106
|
+
enrichProviderFromCatalog(name, prov);
|
|
107
|
+
const { saveConfig: save } = await import("../../config");
|
|
108
|
+
// Overwriting an existing provider must not drop its multi-key pool: carry it over, then
|
|
109
|
+
// let the (possibly new) apiKey join the pool as the active entry.
|
|
110
|
+
const existingPool = config.providers[name]?.apiKeyPool;
|
|
111
|
+
if (existingPool && !prov.apiKeyPool) prov.apiKeyPool = existingPool;
|
|
112
|
+
config.providers[name] = stripRegistryOnlyStaticHeaders(name, prov);
|
|
113
|
+
if (body.setDefault) config.defaultProvider = name;
|
|
114
|
+
save(config);
|
|
115
|
+
if (prov.apiKey && prov.apiKeyPool) {
|
|
116
|
+
const { addProviderApiKey } = await import("../../providers/api-keys");
|
|
117
|
+
addProviderApiKey(config, name, prov.apiKey);
|
|
118
|
+
}
|
|
119
|
+
const { clearModelCache } = await import("../../codex/model-cache");
|
|
120
|
+
clearModelCache(name);
|
|
121
|
+
await refreshCodexCatalogBestEffort();
|
|
122
|
+
return jsonResponse({ success: true, name });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (url.pathname === "/api/providers" && req.method === "PATCH") {
|
|
126
|
+
const name = url.searchParams.get("name")?.trim();
|
|
127
|
+
if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
|
|
128
|
+
let rawBody: unknown;
|
|
129
|
+
try { rawBody = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
130
|
+
if (!isPlainRecord(rawBody)) return jsonResponse({ error: "provider patch body must be a plain object" }, 400);
|
|
131
|
+
const keys = Object.keys(rawBody);
|
|
132
|
+
const hasMode = Object.hasOwn(rawBody, "codexAccountMode");
|
|
133
|
+
|
|
134
|
+
// codexAccountMode keeps its dedicated side-effect path (quota cache clear, thread map
|
|
135
|
+
// clear, pool prime) and is mutually exclusive with every other patch field.
|
|
136
|
+
if (hasMode) {
|
|
137
|
+
if (keys.length !== 1) {
|
|
138
|
+
return jsonResponse({ error: "codexAccountMode cannot be combined with other patch fields" }, 400);
|
|
139
|
+
}
|
|
140
|
+
if (name !== "openai") return jsonResponse({ error: "codexAccountMode is valid only for provider openai" }, 400);
|
|
141
|
+
const mode = rawBody.codexAccountMode;
|
|
142
|
+
if (mode !== "pool" && mode !== "direct") {
|
|
143
|
+
return jsonResponse({ error: "codexAccountMode must be pool or direct" }, 400);
|
|
144
|
+
}
|
|
145
|
+
const provider = config.providers.openai;
|
|
146
|
+
if (!provider || !isCanonicalOpenAiForwardProvider(provider)) {
|
|
147
|
+
return jsonResponse({ error: "provider openai must be the canonical built-in provider" }, 400);
|
|
148
|
+
}
|
|
149
|
+
const { saveConfig: save } = await import("../../config");
|
|
150
|
+
config.providers.openai = { ...provider, codexAccountMode: mode };
|
|
151
|
+
save(config);
|
|
152
|
+
(deps.clearProviderQuotaCache ?? clearProviderQuotaCache)();
|
|
153
|
+
(deps.clearThreadAccountMap ?? clearThreadAccountMap)();
|
|
154
|
+
if (mode === "pool") {
|
|
155
|
+
try {
|
|
156
|
+
const prime = deps.primeCodexPoolQuotas ?? primeCodexPoolQuotas;
|
|
157
|
+
void Promise.resolve(prime(config, "mode-change")).catch(() => undefined);
|
|
158
|
+
} catch {
|
|
159
|
+
// Quota priming is best-effort; the persisted live mode is already authoritative.
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return jsonResponse({ success: true, name: "openai", codexAccountMode: mode });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Field-mask editor: apply recognized fields onto a copy, then validate the MERGED
|
|
166
|
+
// provider (canonical-seed guard covers openai; local-guard covers registry key providers).
|
|
167
|
+
// API keys are never writable here — the api-keys endpoints own pool-integrated key writes.
|
|
168
|
+
if (Object.hasOwn(rawBody, "apiKey")) {
|
|
169
|
+
return jsonResponse({ error: "apiKey cannot be patched here; use the provider API-key endpoints" }, 400);
|
|
170
|
+
}
|
|
171
|
+
const next: OcxProviderConfig = { ...config.providers[name]! };
|
|
172
|
+
let touched = false;
|
|
173
|
+
|
|
174
|
+
if (Object.hasOwn(rawBody, "disabled")) {
|
|
175
|
+
if (typeof rawBody.disabled !== "boolean") return jsonResponse({ error: "disabled must be a boolean" }, 400);
|
|
176
|
+
if (rawBody.disabled && name === config.defaultProvider) {
|
|
177
|
+
return jsonResponse({ error: "cannot disable the default provider; set another default first" }, 400);
|
|
178
|
+
}
|
|
179
|
+
next.disabled = rawBody.disabled;
|
|
180
|
+
touched = true;
|
|
181
|
+
}
|
|
182
|
+
if (Object.hasOwn(rawBody, "adapter")) {
|
|
183
|
+
if (typeof rawBody.adapter !== "string" || !rawBody.adapter.trim()) return jsonResponse({ error: "adapter must be a non-empty string" }, 400);
|
|
184
|
+
next.adapter = rawBody.adapter.trim();
|
|
185
|
+
touched = true;
|
|
186
|
+
}
|
|
187
|
+
if (Object.hasOwn(rawBody, "baseUrl")) {
|
|
188
|
+
if (typeof rawBody.baseUrl !== "string" || !rawBody.baseUrl.trim()) return jsonResponse({ error: "baseUrl must be a non-empty string" }, 400);
|
|
189
|
+
next.baseUrl = rawBody.baseUrl.trim();
|
|
190
|
+
touched = true;
|
|
191
|
+
}
|
|
192
|
+
if (Object.hasOwn(rawBody, "defaultModel")) {
|
|
193
|
+
if (typeof rawBody.defaultModel !== "string") return jsonResponse({ error: "defaultModel must be a string" }, 400);
|
|
194
|
+
const dm = rawBody.defaultModel.trim();
|
|
195
|
+
if (dm) next.defaultModel = dm;
|
|
196
|
+
else delete next.defaultModel;
|
|
197
|
+
touched = true;
|
|
198
|
+
}
|
|
199
|
+
if (Object.hasOwn(rawBody, "authMode")) {
|
|
200
|
+
if (typeof rawBody.authMode !== "string") return jsonResponse({ error: "authMode must be a string" }, 400);
|
|
201
|
+
const mode = rawBody.authMode.trim();
|
|
202
|
+
if (mode === "key" || mode === "forward" || mode === "oauth" || mode === "local") {
|
|
203
|
+
next.authMode = mode;
|
|
204
|
+
touched = true;
|
|
205
|
+
} else if (mode === "") {
|
|
206
|
+
delete next.authMode;
|
|
207
|
+
touched = true;
|
|
208
|
+
} else {
|
|
209
|
+
return jsonResponse({ error: "authMode must be key, forward, oauth, or local" }, 400);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (Object.hasOwn(rawBody, "note")) {
|
|
213
|
+
if (typeof rawBody.note !== "string") return jsonResponse({ error: "note must be a string" }, 400);
|
|
214
|
+
const note = rawBody.note.trim();
|
|
215
|
+
if (note) next.note = note;
|
|
216
|
+
else delete next.note;
|
|
217
|
+
touched = true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (Object.hasOwn(rawBody, "allowPrivateNetwork")) {
|
|
221
|
+
if (typeof rawBody.allowPrivateNetwork !== "boolean") return jsonResponse({ error: "allowPrivateNetwork must be a boolean" }, 400);
|
|
222
|
+
next.allowPrivateNetwork = rawBody.allowPrivateNetwork;
|
|
223
|
+
touched = true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (Object.hasOwn(rawBody, "liveModels")) {
|
|
227
|
+
if (typeof rawBody.liveModels !== "boolean") return jsonResponse({ error: "liveModels must be a boolean" }, 400);
|
|
228
|
+
next.liveModels = rawBody.liveModels;
|
|
229
|
+
touched = true;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!touched) return jsonResponse({ error: "no recognized fields to update" }, 400);
|
|
233
|
+
|
|
234
|
+
// A disabled-only toggle preserves the v2 fast lane: it changes routing eligibility,
|
|
235
|
+
// not the provider shape, so the merged-shape validators (canonical-seed guard for
|
|
236
|
+
// openai, destination/local checks) do not apply.
|
|
237
|
+
const editorTouched = keys.some(key => key !== "disabled");
|
|
238
|
+
if (editorTouched) {
|
|
239
|
+
const providerError = providerManagementConfigError(name, next);
|
|
240
|
+
if (providerError) return jsonResponse({ error: providerError }, 400);
|
|
241
|
+
const resolvedError = await providerDestinationResolvedError(name, next);
|
|
242
|
+
if (resolvedError) return jsonResponse({ error: resolvedError }, 400);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const { saveConfig: save } = await import("../../config");
|
|
246
|
+
config.providers[name] = stripRegistryOnlyStaticHeaders(name, next);
|
|
247
|
+
save(config);
|
|
248
|
+
if (editorTouched) {
|
|
249
|
+
const { clearModelCache } = await import("../../codex/model-cache");
|
|
250
|
+
clearModelCache(name);
|
|
251
|
+
}
|
|
252
|
+
await refreshCodexCatalogBestEffort();
|
|
253
|
+
return jsonResponse({
|
|
254
|
+
success: true,
|
|
255
|
+
name,
|
|
256
|
+
disabled: config.providers[name]!.disabled === true,
|
|
257
|
+
hasApiKey: !!config.providers[name]!.apiKey,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Lightweight connectivity probe: perform the provider's live /models fetch DIRECTLY and
|
|
262
|
+
// report only real upstream evidence. The catalog aggregate (fetchAllModels) deliberately
|
|
263
|
+
// hides fetch failures behind stale/static fallbacks, so a catalog-presence check would
|
|
264
|
+
// let a static-catalog provider with a fake key "pass" — this endpoint never uses it.
|
|
265
|
+
if (url.pathname === "/api/providers/test" && req.method === "POST") {
|
|
266
|
+
const name = url.searchParams.get("name")?.trim();
|
|
267
|
+
if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) {
|
|
268
|
+
return jsonResponse({ error: "unknown provider" }, 404);
|
|
269
|
+
}
|
|
270
|
+
const prov = config.providers[name]!;
|
|
271
|
+
if (prov.disabled) {
|
|
272
|
+
return jsonResponse({ ok: false, error: "Provider is disabled", latencyMs: 0 });
|
|
273
|
+
}
|
|
274
|
+
if (prov.authMode === "forward") {
|
|
275
|
+
return jsonResponse({
|
|
276
|
+
ok: true,
|
|
277
|
+
latencyMs: 0,
|
|
278
|
+
message: "Passthrough provider is configured (forwards your Codex login; no upstream /models).",
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
if (prov.liveModels === false) {
|
|
282
|
+
return jsonResponse({ ok: false, latencyMs: 0, error: "static catalog only — upstream not verified" });
|
|
283
|
+
}
|
|
284
|
+
const { resolveModelsAuthToken, buildModelsRequest } = await import("../../oauth");
|
|
285
|
+
const apiKey = await resolveModelsAuthToken(name, prov);
|
|
286
|
+
if (prov.authMode === "oauth" && !apiKey) {
|
|
287
|
+
return jsonResponse({ ok: false, latencyMs: 0, error: "static catalog only — upstream not verified (not logged in)" });
|
|
288
|
+
}
|
|
289
|
+
const { url: modelsUrl, headers } = buildModelsRequest(prov, apiKey, name);
|
|
290
|
+
const started = Date.now();
|
|
291
|
+
try {
|
|
292
|
+
const res = await fetch(modelsUrl, { headers, signal: AbortSignal.timeout(8000) });
|
|
293
|
+
const latencyMs = Date.now() - started;
|
|
294
|
+
if (!res.ok) {
|
|
295
|
+
return jsonResponse({ ok: false, latencyMs, error: `upstream /models returned ${res.status}` });
|
|
296
|
+
}
|
|
297
|
+
const json = await res.json().catch(() => null) as { data?: unknown; models?: unknown } | null;
|
|
298
|
+
// OpenAI-style lists use { data: [...] }; Google's /v1beta/models (the other shape
|
|
299
|
+
// buildModelsRequest can produce) returns { models: [...] }.
|
|
300
|
+
const list = json && typeof json === "object" && !Array.isArray(json)
|
|
301
|
+
? (Array.isArray(json.data) ? json.data : Array.isArray(json.models) ? json.models : undefined)
|
|
302
|
+
: undefined;
|
|
303
|
+
if (!Array.isArray(list)) {
|
|
304
|
+
return jsonResponse({ ok: false, latencyMs, error: "upstream /models returned an unexpected shape" });
|
|
305
|
+
}
|
|
306
|
+
const models = list.length;
|
|
307
|
+
return jsonResponse({
|
|
308
|
+
ok: true,
|
|
309
|
+
latencyMs,
|
|
310
|
+
models,
|
|
311
|
+
message: `Connected — ${models} model${models === 1 ? "" : "s"} available.`,
|
|
312
|
+
});
|
|
313
|
+
} catch (err) {
|
|
314
|
+
return jsonResponse({
|
|
315
|
+
ok: false,
|
|
316
|
+
latencyMs: Date.now() - started,
|
|
317
|
+
error: err instanceof Error ? err.message : "Connection test failed",
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (url.pathname === "/api/providers" && req.method === "DELETE") {
|
|
323
|
+
const name = url.searchParams.get("name")?.trim();
|
|
324
|
+
if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
|
|
325
|
+
if (name === config.defaultProvider) return jsonResponse({ error: "cannot delete the default provider; set another default first" }, 400);
|
|
326
|
+
const dependentCombos = Object.entries(config.combos ?? {})
|
|
327
|
+
.filter(([, combo]) => combo.targets.some(target => target.provider === name))
|
|
328
|
+
.map(([id]) => id)
|
|
329
|
+
.sort((a, b) => a.localeCompare(b));
|
|
330
|
+
if (dependentCombos.length > 0) {
|
|
331
|
+
return jsonResponse({
|
|
332
|
+
error: `cannot delete provider "${name}" while combos depend on it`,
|
|
333
|
+
combos: dependentCombos,
|
|
334
|
+
}, 409);
|
|
335
|
+
}
|
|
336
|
+
const { saveConfig: save } = await import("../../config");
|
|
337
|
+
delete config.providers[name];
|
|
338
|
+
setProviderContextCap(config, name, false);
|
|
339
|
+
save(config);
|
|
340
|
+
const { clearModelCache: clearCache } = await import("../../codex/model-cache");
|
|
341
|
+
clearCache(name);
|
|
342
|
+
await refreshCodexCatalogBestEffort();
|
|
343
|
+
return jsonResponse({ success: true });
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (url.pathname === "/api/provider-context-caps" && req.method === "GET") {
|
|
347
|
+
return jsonResponse({ cap: DEFAULT_PROVIDER_CONTEXT_CAP, value: globalContextCapValue(config), caps: providerContextCaps(config) });
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (url.pathname === "/api/provider-context-caps" && req.method === "PUT") {
|
|
351
|
+
let body: { provider?: unknown; enabled?: unknown; value?: unknown; setAll?: unknown };
|
|
352
|
+
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
353
|
+
const { saveConfig: save } = await import("../../config");
|
|
354
|
+
const { clearModelCache } = await import("../../codex/model-cache");
|
|
355
|
+
const respond = () => jsonResponse({ ok: true, cap: DEFAULT_PROVIDER_CONTEXT_CAP, value: globalContextCapValue(config), caps: providerContextCaps(config) });
|
|
356
|
+
|
|
357
|
+
// Branch 1: set the global cap value and re-point every enabled provider to it.
|
|
358
|
+
if (body.value !== undefined) {
|
|
359
|
+
if (typeof body.value !== "number" || !Number.isFinite(body.value) || body.value <= 0) {
|
|
360
|
+
return jsonResponse({ error: "value must be a positive number" }, 400);
|
|
361
|
+
}
|
|
362
|
+
const affected = Object.keys(providerContextCaps(config));
|
|
363
|
+
setGlobalContextCapValue(config, body.value);
|
|
364
|
+
save(config);
|
|
365
|
+
for (const provider of affected) clearModelCache(provider);
|
|
366
|
+
await refreshCodexCatalogBestEffort();
|
|
367
|
+
return respond();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Branch 2: enable/clear the cap for every provider at once.
|
|
371
|
+
if (body.setAll !== undefined) {
|
|
372
|
+
if (typeof body.setAll !== "boolean") {
|
|
373
|
+
return jsonResponse({ error: "setAll must be a boolean" }, 400);
|
|
374
|
+
}
|
|
375
|
+
const before = Object.keys(providerContextCaps(config));
|
|
376
|
+
const names = Object.keys(config.providers);
|
|
377
|
+
setAllProviderContextCaps(config, names, body.setAll);
|
|
378
|
+
save(config);
|
|
379
|
+
for (const provider of new Set([...before, ...names])) clearModelCache(provider);
|
|
380
|
+
await refreshCodexCatalogBestEffort();
|
|
381
|
+
return respond();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Branch 3: existing per-provider toggle (enable writes the current global value).
|
|
385
|
+
if (typeof body.provider !== "string" || typeof body.enabled !== "boolean") {
|
|
386
|
+
return jsonResponse({ error: "provider string and enabled boolean are required" }, 400);
|
|
387
|
+
}
|
|
388
|
+
const provider = body.provider.trim();
|
|
389
|
+
if (!isValidProviderName(provider)) {
|
|
390
|
+
return jsonResponse({ error: "provider name must use letters, numbers, dot, underscore, or hyphen and cannot be a reserved object key" }, 400);
|
|
391
|
+
}
|
|
392
|
+
if (!hasOwnProvider(config.providers, provider)) {
|
|
393
|
+
return jsonResponse({ error: "unknown provider" }, 404);
|
|
394
|
+
}
|
|
395
|
+
setProviderContextCap(config, provider, body.enabled);
|
|
396
|
+
save(config);
|
|
397
|
+
clearModelCache(provider);
|
|
398
|
+
await refreshCodexCatalogBestEffort();
|
|
399
|
+
return respond();
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Complete GUI picker presets, derived from the canonical provider registry. The GUI is a
|
|
403
|
+
// standalone Vite package, so it consumes this runtime view instead of importing repo-root src.
|
|
404
|
+
if (url.pathname === "/api/provider-presets" && req.method === "GET") {
|
|
405
|
+
return jsonResponse({ providers: deriveProviderPresets() });
|
|
406
|
+
}
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import type { CatalogModel } from "../../codex/catalog";
|
|
4
|
+
import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog";
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_SUBAGENT_MODELS,
|
|
7
|
+
codexAutoStartEnabled,
|
|
8
|
+
hasOwnProvider,
|
|
9
|
+
isValidProviderName,
|
|
10
|
+
multiAgentGuidanceEnabled,
|
|
11
|
+
providerBaseUrlConfigError,
|
|
12
|
+
providerHeadersConfigError,
|
|
13
|
+
saveConfig,
|
|
14
|
+
} from "../../config";
|
|
15
|
+
import {
|
|
16
|
+
clearLoginState,
|
|
17
|
+
getLoginStatus,
|
|
18
|
+
isPublicOAuthProvider,
|
|
19
|
+
listOAuthProviders,
|
|
20
|
+
startLoginFlow,
|
|
21
|
+
submitManualLoginCode,
|
|
22
|
+
upsertOAuthProvider,
|
|
23
|
+
} from "../../oauth";
|
|
24
|
+
import { removeCredential } from "../../oauth/store";
|
|
25
|
+
import { providerDestinationResolvedError } from "../../lib/destination-policy";
|
|
26
|
+
import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
|
|
27
|
+
import { deriveProviderPresets } from "../../providers/derive";
|
|
28
|
+
import { providerCodexAccountMode } from "../../providers/registry";
|
|
29
|
+
import { routedSlug, slugEquals } from "../../providers/slug-codec";
|
|
30
|
+
import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota";
|
|
31
|
+
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
32
|
+
import { clearThreadAccountMap } from "../../codex/routing";
|
|
33
|
+
import { primeCodexPoolQuotas } from "../../codex/auth-api";
|
|
34
|
+
import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
|
|
35
|
+
import { resolveCodexHomeDir } from "../../codex/home";
|
|
36
|
+
import { scanStorage } from "../../storage/scanner";
|
|
37
|
+
import { readUsageEntries } from "../../usage/log";
|
|
38
|
+
import { getUsageDebugLogEntries } from "../../usage/debug";
|
|
39
|
+
import { parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary";
|
|
40
|
+
import { stripCodexRuntimeProviderFields } from "../../codex/auth-context";
|
|
41
|
+
import { getProviderRegistryEntry } from "../../providers/registry";
|
|
42
|
+
import { getDebugLogEntries } from "../../lib/debug-log-buffer";
|
|
43
|
+
import { getInjectionDebugLogEntries } from "../../lib/injection-debug-log";
|
|
44
|
+
import {
|
|
45
|
+
clearDebugSettings,
|
|
46
|
+
clearDebugSetting,
|
|
47
|
+
getDebugSettings,
|
|
48
|
+
setDebugSettings,
|
|
49
|
+
type DebugFlag,
|
|
50
|
+
} from "../../lib/debug-settings";
|
|
51
|
+
import type { OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../../types";
|
|
52
|
+
import { drainAndShutdown } from "../lifecycle";
|
|
53
|
+
import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log";
|
|
54
|
+
import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
|
|
55
|
+
import type { PersistedUsageAttempt } from "../../usage/log";
|
|
56
|
+
import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors";
|
|
57
|
+
import { applySystemEnvToggle } from "../system-env";
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
export function isPlainRecord(v: unknown): v is Record<string, unknown> {
|
|
61
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function parseDebugLogQuery(url: URL): { after: number; limit: number } {
|
|
65
|
+
const after = Number(url.searchParams.get("after") ?? url.searchParams.get("since") ?? "0");
|
|
66
|
+
const limit = Number(url.searchParams.get("limit") ?? "500");
|
|
67
|
+
return {
|
|
68
|
+
after: Number.isFinite(after) && after > 0 ? after : 0,
|
|
69
|
+
limit: Number.isFinite(limit) && limit > 0 ? Math.min(limit, 2000) : 500,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---- /api/logs display metrics (devlog/_plan/260720_toks_speed_price_columns/020) ----
|
|
74
|
+
// Derived at response time only; NEVER persisted to the request log or usage.jsonl.
|
|
75
|
+
|
|
76
|
+
export type MetricUnavailableReason =
|
|
77
|
+
| "usage_missing" | "usage_unsupported" | "output_missing" | "invalid_duration"
|
|
78
|
+
| "price_unmatched" | "invalid_cache_breakdown"
|
|
79
|
+
| "invalid_usage" | "combo_attempt_unavailable";
|
|
80
|
+
|
|
81
|
+
export type TokPerSecondResult =
|
|
82
|
+
| { kind: "value"; value: number; estimated: boolean }
|
|
83
|
+
| { kind: "unavailable"; reason: MetricUnavailableReason };
|
|
84
|
+
|
|
85
|
+
export type CostEstimateReason = "usage_estimated" | "cache_detail_missing" | "expected_price_overlay";
|
|
86
|
+
|
|
87
|
+
export type CostResult =
|
|
88
|
+
| { kind: "value"; estimate: NonNullable<ReturnType<typeof estimateRequestCost>>; estimateReasons: CostEstimateReason[] }
|
|
89
|
+
| { kind: "unavailable"; reason: MetricUnavailableReason };
|
|
90
|
+
|
|
91
|
+
export type MetricSource = Pick<RequestLogEntry, "provider" | "model" | "durationMs" | "usageStatus" | "usage"> & {
|
|
92
|
+
attempts?: readonly PersistedUsageAttempt[];
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export function tokPerSecondResult(entry: Pick<MetricSource, "durationMs" | "usageStatus" | "usage">): TokPerSecondResult {
|
|
96
|
+
if (!entry.usage) return { kind: "unavailable", reason: "usage_missing" };
|
|
97
|
+
if (entry.usageStatus === "unsupported") return { kind: "unavailable", reason: "usage_unsupported" };
|
|
98
|
+
const value = tokensPerSecond(entry.usage.outputTokens, entry.durationMs);
|
|
99
|
+
if (value === null) {
|
|
100
|
+
return {
|
|
101
|
+
kind: "unavailable",
|
|
102
|
+
reason: entry.usage.outputTokens <= 0 ? "output_missing" : "invalid_duration",
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return { kind: "value", value, estimated: entry.usageStatus === "estimated" || entry.usage.estimated === true };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function unavailableCostReason(entry: MetricSource): MetricUnavailableReason {
|
|
109
|
+
// Normalizer-first classification: the landed normalizer recovers legacy
|
|
110
|
+
// cachedInputTokens=read+write rows via retry, so a raw read+write>input
|
|
111
|
+
// pre-check would misclassify recoverable rows (020 audit blocker #2).
|
|
112
|
+
if (!entry.usage && !entry.attempts?.length) return "usage_missing";
|
|
113
|
+
if (entry.usageStatus === "unsupported") return "usage_unsupported";
|
|
114
|
+
if (entry.attempts?.length) return "combo_attempt_unavailable";
|
|
115
|
+
if (!entry.usage) return "usage_missing";
|
|
116
|
+
if (!normalizeCostTokens(entry.usage)) {
|
|
117
|
+
const effectiveRead = entry.usage.cacheReadInputTokens ?? entry.usage.cachedInputTokens ?? 0;
|
|
118
|
+
const effectiveWrite = entry.usage.cacheCreationInputTokens ?? 0;
|
|
119
|
+
const finite = [entry.usage.inputTokens, entry.usage.outputTokens, effectiveRead, effectiveWrite]
|
|
120
|
+
.every(v => Number.isFinite(v) && v >= 0);
|
|
121
|
+
return finite ? "invalid_cache_breakdown" : "invalid_usage";
|
|
122
|
+
}
|
|
123
|
+
return "price_unmatched";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function costResult(entry: MetricSource): CostResult {
|
|
127
|
+
const estimate = entry.attempts?.length
|
|
128
|
+
? estimateComboCost(entry.attempts)
|
|
129
|
+
: estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus });
|
|
130
|
+
if (!estimate) return { kind: "unavailable", reason: unavailableCostReason(entry) };
|
|
131
|
+
const estimateReasons = [
|
|
132
|
+
entry.usageStatus === "estimated" || entry.usage?.estimated ? "usage_estimated" as const : undefined,
|
|
133
|
+
entry.usage && entry.usage.cachedInputTokens === undefined
|
|
134
|
+
&& entry.usage.cacheReadInputTokens === undefined
|
|
135
|
+
&& entry.usage.cacheCreationInputTokens === undefined ? "cache_detail_missing" as const : undefined,
|
|
136
|
+
estimate.price?.source === "expected" || estimate.attempts?.some(a => a.price.source === "expected")
|
|
137
|
+
? "expected_price_overlay" as const : undefined,
|
|
138
|
+
].filter((reason): reason is CostEstimateReason => reason !== undefined);
|
|
139
|
+
return { kind: "value", estimate, estimateReasons };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function requestLogDto(entry: RequestLogEntry): Record<string, unknown> {
|
|
143
|
+
return {
|
|
144
|
+
...entry,
|
|
145
|
+
displayMetrics: {
|
|
146
|
+
tokPerSecond: tokPerSecondResult(entry),
|
|
147
|
+
cost: costResult(entry),
|
|
148
|
+
},
|
|
149
|
+
...(entry.attempts?.length
|
|
150
|
+
? {
|
|
151
|
+
attempts: entry.attempts.map(attempt => ({
|
|
152
|
+
...attempt,
|
|
153
|
+
displayMetrics: {
|
|
154
|
+
tokPerSecond: tokPerSecondResult(attempt),
|
|
155
|
+
cost: costResult({ ...attempt, attempts: undefined }),
|
|
156
|
+
},
|
|
157
|
+
})),
|
|
158
|
+
}
|
|
159
|
+
: {}),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Live routed-provider models for the proxy's /api/* and /v1/models endpoints. Delegates to the
|
|
165
|
+
* canonical, TTL-cached `gatherRoutedModels` (single source of truth) — so the GUI/codex endpoints
|
|
166
|
+
* share the same fetch, the same per-provider cache (dedups Codex's frequent /v1/models polling),
|
|
167
|
+
* and the same stale fallback when a provider blips, instead of a parallel uncached copy.
|
|
168
|
+
*/
|
|
169
|
+
export async function fetchAllModels(config: OcxConfig): Promise<CatalogModel[]> {
|
|
170
|
+
const { gatherRoutedModels } = await import("../../codex/catalog");
|
|
171
|
+
return gatherRoutedModels(config);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function stripRegistryOnlyStaticHeaders(name: string, provider: OcxProviderConfig): OcxProviderConfig {
|
|
175
|
+
const entry = getProviderRegistryEntry(name);
|
|
176
|
+
if (!entry?.staticHeaders || !provider.headers) return provider;
|
|
177
|
+
const headerEntries = Object.entries(provider.headers);
|
|
178
|
+
const staticEntries = Object.entries(entry.staticHeaders);
|
|
179
|
+
if (headerEntries.length !== staticEntries.length) return provider;
|
|
180
|
+
const matchesRegistryStaticHeaders = staticEntries.every(([key, value]) => provider.headers?.[key] === value);
|
|
181
|
+
if (!matchesRegistryStaticHeaders) return provider;
|
|
182
|
+
const { headers: _headers, ...rest } = provider;
|
|
183
|
+
return rest;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|