@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,176 @@
|
|
|
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 handleLogsUsageRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
64
|
+
const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx;
|
|
65
|
+
|
|
66
|
+
if (url.pathname === "/api/logs" && req.method === "GET") {
|
|
67
|
+
const logs = filterRequestLogs(getRequestLogEntries(), url.searchParams);
|
|
68
|
+
return jsonResponse(logs.map(requestLogDto));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (url.pathname === "/api/debug" && req.method === "GET") {
|
|
72
|
+
return jsonResponse(getDebugSettings());
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (url.pathname === "/api/debug/logs" && req.method === "GET") {
|
|
76
|
+
const { after, limit } = parseDebugLogQuery(url);
|
|
77
|
+
return jsonResponse(getDebugLogEntries({ after, limit }));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (url.pathname === "/api/debug/usage-logs" && req.method === "GET") {
|
|
81
|
+
const { after, limit } = parseDebugLogQuery(url);
|
|
82
|
+
return jsonResponse(getUsageDebugLogEntries({ after, limit }));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (url.pathname === "/api/claude/inbound-debug" && req.method === "GET") {
|
|
86
|
+
const { getClaudeInboundDebugEntries } = await import("../../claude/inbound-debug");
|
|
87
|
+
const { isClaudeDebugEnabled } = await import("../../lib/debug-settings");
|
|
88
|
+
return jsonResponse({ enabled: isClaudeDebugEnabled(), entries: getClaudeInboundDebugEntries() });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (url.pathname === "/api/debug/injection-logs" && req.method === "GET") {
|
|
92
|
+
const { after, limit } = parseDebugLogQuery(url);
|
|
93
|
+
return jsonResponse(getInjectionDebugLogEntries({ after, limit }));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (url.pathname === "/api/debug" && req.method === "PUT") {
|
|
97
|
+
let body: { debug?: unknown; usage?: unknown; injection?: unknown; claude?: unknown; reset?: unknown };
|
|
98
|
+
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
99
|
+
if (body.reset === true) return jsonResponse(clearDebugSettings());
|
|
100
|
+
if (body.reset === "debug" || body.reset === "provider") return jsonResponse(clearDebugSetting("debug"));
|
|
101
|
+
if (body.reset === "usage") return jsonResponse(clearDebugSetting("usage"));
|
|
102
|
+
if (body.reset === "injection") return jsonResponse(clearDebugSetting("injection"));
|
|
103
|
+
if (body.reset === "claude") return jsonResponse(clearDebugSetting("claude"));
|
|
104
|
+
const partial: Partial<Record<DebugFlag, boolean>> = {};
|
|
105
|
+
for (const key of ["debug", "usage", "injection", "claude"] as const) {
|
|
106
|
+
if (body[key] === undefined) continue;
|
|
107
|
+
if (typeof body[key] !== "boolean") return jsonResponse({ error: `${key} must be a boolean` }, 400);
|
|
108
|
+
partial[key] = body[key];
|
|
109
|
+
}
|
|
110
|
+
if (Object.keys(partial).length === 0) {
|
|
111
|
+
return jsonResponse({ error: "provide debug/usage/injection/claude booleans or reset:true" }, 400);
|
|
112
|
+
}
|
|
113
|
+
// Turning capture off should also flush already-captured entries (privacy contract).
|
|
114
|
+
if (partial.claude === false) {
|
|
115
|
+
const { clearClaudeInboundDebug } = await import("../../claude/inbound-debug");
|
|
116
|
+
clearClaudeInboundDebug();
|
|
117
|
+
}
|
|
118
|
+
return jsonResponse(setDebugSettings(partial));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (url.pathname === "/api/usage" && req.method === "GET") {
|
|
122
|
+
const range = parseRange(url.searchParams.get("range"));
|
|
123
|
+
const surface = parseUsageSurface(url.searchParams.get("surface"));
|
|
124
|
+
const now = Date.now();
|
|
125
|
+
try {
|
|
126
|
+
return jsonResponse(summarizeUsage(readUsageEntries(), range, now, surface));
|
|
127
|
+
} catch {
|
|
128
|
+
return jsonResponse({
|
|
129
|
+
range,
|
|
130
|
+
surface,
|
|
131
|
+
since: null,
|
|
132
|
+
generatedAt: now,
|
|
133
|
+
summary: {
|
|
134
|
+
requests: 0,
|
|
135
|
+
attemptCount: 0,
|
|
136
|
+
measuredRequests: 0,
|
|
137
|
+
reportedRequests: 0,
|
|
138
|
+
unreportedRequests: 0,
|
|
139
|
+
unsupportedRequests: 0,
|
|
140
|
+
estimatedRequests: 0,
|
|
141
|
+
inputTokens: 0,
|
|
142
|
+
outputTokens: 0,
|
|
143
|
+
cachedInputTokens: 0,
|
|
144
|
+
cacheReadInputTokens: 0,
|
|
145
|
+
cacheCreationInputTokens: 0,
|
|
146
|
+
reasoningOutputTokens: 0,
|
|
147
|
+
totalTokens: 0,
|
|
148
|
+
coverageRatio: 0,
|
|
149
|
+
estimatedCostUsd: 0,
|
|
150
|
+
pricedRequests: 0,
|
|
151
|
+
unpricedRequests: 0,
|
|
152
|
+
unmeteredRequests: 0,
|
|
153
|
+
},
|
|
154
|
+
days: [],
|
|
155
|
+
models: [],
|
|
156
|
+
providers: [],
|
|
157
|
+
error: "read_failed",
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (url.pathname === "/api/storage" && req.method === "GET") {
|
|
163
|
+
try {
|
|
164
|
+
return jsonResponse(scanStorage());
|
|
165
|
+
} catch {
|
|
166
|
+
return jsonResponse({
|
|
167
|
+
codexHome: resolveCodexHomeDir(),
|
|
168
|
+
generatedAt: Date.now(),
|
|
169
|
+
total: { bytes: 0, fileCount: 0 },
|
|
170
|
+
buckets: [],
|
|
171
|
+
error: "scan_failed",
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
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 handleModelRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
64
|
+
const { req, url, config, deps, refreshCodexCatalogBestEffort, syncClaudeAgentDefsBestEffort } = ctx;
|
|
65
|
+
|
|
66
|
+
if (url.pathname === "/api/models" && req.method === "GET") {
|
|
67
|
+
const models = await fetchAllModels(config);
|
|
68
|
+
const disabled = new Set(config.disabledModels ?? []);
|
|
69
|
+
// Native GPT passthrough rows lead (provider "openai", bare-slug namespaced ids): sourced
|
|
70
|
+
// from the static supported set so a disabled model stays listed and re-enableable.
|
|
71
|
+
const native = nativeModelRows(config).map(row => ({
|
|
72
|
+
provider: "openai",
|
|
73
|
+
id: row.slug,
|
|
74
|
+
namespaced: row.slug,
|
|
75
|
+
disabled: row.disabled,
|
|
76
|
+
native: true,
|
|
77
|
+
...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}),
|
|
78
|
+
}));
|
|
79
|
+
const customModels = (config.customModels ?? []).map(cm => {
|
|
80
|
+
const namespaced = routedSlug(cm.provider, cm.modelId);
|
|
81
|
+
return {
|
|
82
|
+
provider: cm.provider,
|
|
83
|
+
id: cm.modelId,
|
|
84
|
+
namespaced,
|
|
85
|
+
disabled: [...disabled].some(stored => slugEquals(stored, cm.provider, cm.modelId)),
|
|
86
|
+
custom: true,
|
|
87
|
+
customId: cm.id,
|
|
88
|
+
displayName: cm.displayName,
|
|
89
|
+
...(cm.contextWindow ? { contextWindow: cm.contextWindow } : {}),
|
|
90
|
+
...(cm.inputModalities ? { inputModalities: cm.inputModalities } : {}),
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
const publicModels = uniqueCatalogModelsForPublicList(models);
|
|
94
|
+
const comboNamespaced = new Set(
|
|
95
|
+
publicModels.filter(model => model.provider === "combo").map(catalogModelSlug),
|
|
96
|
+
);
|
|
97
|
+
const visibleCustomModels = customModels.filter(model => !comboNamespaced.has(model.namespaced));
|
|
98
|
+
// Custom metadata wins when a physical live/static row resolves to the same Codex-facing
|
|
99
|
+
// slug, while a combo keeps the same precedence it has in routing and /v1/models.
|
|
100
|
+
const customNamespaced = new Set(visibleCustomModels.map(c => c.namespaced));
|
|
101
|
+
const dedupedRouted = publicModels.map(m => {
|
|
102
|
+
// Codex-facing slug (one "/", slug-codec); disabledModels compares tolerate both forms.
|
|
103
|
+
const namespaced = catalogModelSlug(m);
|
|
104
|
+
if (m.provider !== "combo" && customNamespaced.has(namespaced)) return null;
|
|
105
|
+
const contextCap = providerContextCap(config, m.provider);
|
|
106
|
+
return {
|
|
107
|
+
...m,
|
|
108
|
+
namespaced,
|
|
109
|
+
disabled: [...disabled].some(stored => (
|
|
110
|
+
stored === namespaced || slugEquals(stored, m.provider, m.id)
|
|
111
|
+
)),
|
|
112
|
+
...(contextCap !== undefined ? { contextCap, contextCapped: m.contextCapped === true } : {}),
|
|
113
|
+
};
|
|
114
|
+
}).filter(Boolean);
|
|
115
|
+
return jsonResponse([...native, ...dedupedRouted, ...visibleCustomModels]);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Enable/disable models: which routed models Codex sees. PUT hides them from the catalog +
|
|
119
|
+
// /v1/models and invalidates Codex's 5-min models cache so it applies on the next turn.
|
|
120
|
+
if (url.pathname === "/api/disabled-models" && req.method === "PUT") {
|
|
121
|
+
let body: { models?: unknown };
|
|
122
|
+
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
123
|
+
const disabled = Array.isArray(body.models) ? body.models.filter((m): m is string => typeof m === "string") : [];
|
|
124
|
+
config.disabledModels = disabled;
|
|
125
|
+
const { saveConfig: save } = await import("../../config");
|
|
126
|
+
save(config);
|
|
127
|
+
await refreshCodexCatalogBestEffort();
|
|
128
|
+
return jsonResponse({ ok: true, disabled });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (url.pathname === "/api/custom-models" && req.method === "GET") {
|
|
132
|
+
return jsonResponse(config.customModels ?? []);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (url.pathname === "/api/custom-models" && req.method === "POST") {
|
|
136
|
+
let body: { provider?: unknown; modelId?: unknown; displayName?: unknown; contextWindow?: unknown; inputModalities?: unknown };
|
|
137
|
+
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
138
|
+
const provider = typeof body.provider === "string" ? body.provider.trim() : "";
|
|
139
|
+
const modelId = typeof body.modelId === "string" ? body.modelId.trim() : "";
|
|
140
|
+
if (!provider || !modelId) return jsonResponse({ error: "provider and modelId are required" }, 400);
|
|
141
|
+
if (modelId.includes("/")) return jsonResponse({ error: "modelId must not contain /" }, 400);
|
|
142
|
+
if (!isValidProviderName(provider)) return jsonResponse({ error: "invalid provider name" }, 400);
|
|
143
|
+
if (!hasOwnProvider(config.providers, provider)) return jsonResponse({ error: "provider not configured" }, 404);
|
|
144
|
+
const displayName = typeof body.displayName === "string" && body.displayName.trim() ? body.displayName.trim() : undefined;
|
|
145
|
+
if (displayName?.includes("/")) return jsonResponse({ error: "displayName must not contain /" }, 400);
|
|
146
|
+
const contextWindow = typeof body.contextWindow === "number" && body.contextWindow > 0 ? Math.floor(body.contextWindow) : undefined;
|
|
147
|
+
const inputModalities = Array.isArray(body.inputModalities) ? body.inputModalities.filter((m): m is string => typeof m === "string") : undefined;
|
|
148
|
+
const existing = config.customModels ?? [];
|
|
149
|
+
const newSlug = routedSlug(provider, modelId);
|
|
150
|
+
if (existing.some(cm => routedSlug(cm.provider, cm.modelId) === newSlug)) {
|
|
151
|
+
return jsonResponse({ error: "duplicate model" }, 409);
|
|
152
|
+
}
|
|
153
|
+
const entry: OcxCustomModel = {
|
|
154
|
+
id: randomUUID(),
|
|
155
|
+
provider,
|
|
156
|
+
modelId,
|
|
157
|
+
...(displayName ? { displayName } : {}),
|
|
158
|
+
...(contextWindow ? { contextWindow } : {}),
|
|
159
|
+
...(inputModalities && inputModalities.length > 0 ? { inputModalities } : {}),
|
|
160
|
+
addedAt: new Date().toISOString(),
|
|
161
|
+
};
|
|
162
|
+
config.customModels = [...existing, entry];
|
|
163
|
+
const { saveConfig: save } = await import("../../config");
|
|
164
|
+
save(config);
|
|
165
|
+
await refreshCodexCatalogBestEffort();
|
|
166
|
+
return jsonResponse(entry, 201);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const customPutMatch = url.pathname.match(/^\/api\/custom-models\/([^/]+)$/);
|
|
170
|
+
if (customPutMatch && req.method === "PUT") {
|
|
171
|
+
let id: string;
|
|
172
|
+
try { id = decodeURIComponent(customPutMatch[1]); } catch { return jsonResponse({ error: "invalid id encoding" }, 400); }
|
|
173
|
+
let body: { displayName?: unknown; contextWindow?: unknown; inputModalities?: unknown; modelId?: unknown };
|
|
174
|
+
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
175
|
+
const list = config.customModels ?? [];
|
|
176
|
+
const idx = list.findIndex(cm => cm.id === id);
|
|
177
|
+
if (idx === -1) return jsonResponse({ error: "not found" }, 404);
|
|
178
|
+
const cm = { ...list[idx] };
|
|
179
|
+
if (typeof body.modelId === "string" && body.modelId.trim()) {
|
|
180
|
+
if (body.modelId.includes("/")) return jsonResponse({ error: "modelId must not contain /" }, 400);
|
|
181
|
+
cm.modelId = body.modelId.trim();
|
|
182
|
+
}
|
|
183
|
+
if (body.displayName !== undefined) {
|
|
184
|
+
const dn = typeof body.displayName === "string" ? body.displayName.trim() : "";
|
|
185
|
+
if (dn.includes("/")) return jsonResponse({ error: "displayName must not contain /" }, 400);
|
|
186
|
+
cm.displayName = dn || undefined;
|
|
187
|
+
}
|
|
188
|
+
if (body.contextWindow !== undefined) {
|
|
189
|
+
cm.contextWindow = typeof body.contextWindow === "number" && body.contextWindow > 0 ? Math.floor(body.contextWindow) : undefined;
|
|
190
|
+
}
|
|
191
|
+
if (body.inputModalities !== undefined) {
|
|
192
|
+
cm.inputModalities = Array.isArray(body.inputModalities) ? body.inputModalities.filter((m): m is string => typeof m === "string") : undefined;
|
|
193
|
+
}
|
|
194
|
+
const updatedSlug = routedSlug(cm.provider, cm.modelId);
|
|
195
|
+
if (list.some((other, i) => i !== idx && routedSlug(other.provider, other.modelId) === updatedSlug)) {
|
|
196
|
+
return jsonResponse({ error: "duplicate model" }, 409);
|
|
197
|
+
}
|
|
198
|
+
list[idx] = cm;
|
|
199
|
+
config.customModels = list;
|
|
200
|
+
const { saveConfig: save } = await import("../../config");
|
|
201
|
+
save(config);
|
|
202
|
+
await refreshCodexCatalogBestEffort();
|
|
203
|
+
return jsonResponse(cm);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const customDelMatch = url.pathname.match(/^\/api\/custom-models\/([^/]+)$/);
|
|
207
|
+
if (customDelMatch && req.method === "DELETE") {
|
|
208
|
+
let id: string;
|
|
209
|
+
try { id = decodeURIComponent(customDelMatch[1]); } catch { return jsonResponse({ error: "invalid id encoding" }, 400); }
|
|
210
|
+
const list = config.customModels ?? [];
|
|
211
|
+
const idx = list.findIndex(cm => cm.id === id);
|
|
212
|
+
if (idx === -1) return jsonResponse({ error: "not found" }, 404);
|
|
213
|
+
list.splice(idx, 1);
|
|
214
|
+
config.customModels = list.length > 0 ? list : undefined;
|
|
215
|
+
const { saveConfig: save } = await import("../../config");
|
|
216
|
+
save(config);
|
|
217
|
+
await refreshCodexCatalogBestEffort();
|
|
218
|
+
return jsonResponse({ ok: true });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Per-provider catalog allowlist (issue #52): when a provider has a non-empty selectedModels list,
|
|
222
|
+
// only those ids ship to Codex's catalog / /v1/models. GET returns the CURRENT selection plus the
|
|
223
|
+
// FULL available set per provider (unfiltered — the picker needs everything to choose from).
|
|
224
|
+
if (url.pathname === "/api/selected-models" && req.method === "GET") {
|
|
225
|
+
const models = await fetchAllModels(config);
|
|
226
|
+
const available: Record<string, string[]> = {};
|
|
227
|
+
for (const m of models) (available[m.provider] ??= []).push(m.id);
|
|
228
|
+
const selected: Record<string, string[]> = {};
|
|
229
|
+
for (const [name, prov] of Object.entries(config.providers)) {
|
|
230
|
+
if (Array.isArray(prov.selectedModels) && prov.selectedModels.length > 0) selected[name] = [...prov.selectedModels];
|
|
231
|
+
}
|
|
232
|
+
return jsonResponse({ selected, available });
|
|
233
|
+
}
|
|
234
|
+
if (url.pathname === "/api/selected-models" && req.method === "PUT") {
|
|
235
|
+
let body: { provider?: unknown; models?: unknown };
|
|
236
|
+
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
237
|
+
const provider = typeof body.provider === "string" ? body.provider : "";
|
|
238
|
+
if (!provider || !hasOwnProvider(config.providers, provider)) {
|
|
239
|
+
return jsonResponse({ error: "unknown provider" }, provider ? 404 : 400);
|
|
240
|
+
}
|
|
241
|
+
const models = Array.isArray(body.models)
|
|
242
|
+
? [...new Set(body.models.filter((m): m is string => typeof m === "string"))]
|
|
243
|
+
: [];
|
|
244
|
+
// Empty list clears the allowlist (provider reverts to exposing all models).
|
|
245
|
+
if (models.length > 0) config.providers[provider].selectedModels = models;
|
|
246
|
+
else delete config.providers[provider].selectedModels;
|
|
247
|
+
const { saveConfig: save } = await import("../../config");
|
|
248
|
+
save(config);
|
|
249
|
+
await refreshCodexCatalogBestEffort();
|
|
250
|
+
return jsonResponse({ ok: true, provider, selected: models });
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|