@bitkyc08/opencodex 2.21.0 → 2.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS_INSTALL.md +32 -0
- package/README.md +1 -1
- package/gui/dist/assets/{index-BOFeam5a.js → index-ClEcVlFO.js} +43 -17
- package/gui/dist/assets/index-DQsMZzI5.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/claude/agents-inject.ts +2 -2
- package/src/cli/codex-log-guard-doctor.ts +103 -0
- package/src/cli/dispatch.ts +7 -1
- package/src/cli/help.ts +1 -1
- package/src/cli/models.ts +16 -6
- package/src/cli/observe.ts +38 -2
- package/src/cli/registry.ts +2 -1
- package/src/cli/v2.ts +34 -1
- package/src/codex/app-server-processes.ts +46 -26
- package/src/codex/catalog/effort.ts +49 -1
- package/src/codex/catalog/parsing.ts +64 -4
- package/src/codex/catalog/provider-fetch.ts +12 -0
- package/src/codex/catalog/sync.ts +14 -1
- package/src/codex/convergence.ts +2 -0
- package/src/codex/inject.ts +3 -3
- package/src/codex/log-guard/inspect.ts +506 -0
- package/src/codex/log-guard/lock.ts +150 -0
- package/src/codex/log-guard/maintenance.ts +403 -0
- package/src/codex/log-guard/path-safety.ts +39 -0
- package/src/codex/log-guard/policy.ts +44 -0
- package/src/codex/log-guard/processes.ts +205 -0
- package/src/codex/log-guard/protection.ts +489 -0
- package/src/codex/log-guard/sqlite-errors.ts +9 -0
- package/src/codex/paths.ts +5 -0
- package/src/codex/plugins-doctor.ts +1 -1
- package/src/codex/project-config-warnings.ts +2 -2
- package/src/generated/compatibility-version.json +72 -32
- package/src/providers/antigravity-models.ts +11 -1
- package/src/providers/model-discovery.ts +94 -6
- package/src/providers/quota.ts +159 -0
- package/src/providers/registry.ts +13 -0
- package/src/providers/slug-codec.ts +29 -0
- package/src/router.ts +12 -4
- package/src/routing/capability.ts +32 -17
- package/src/server/index.ts +1 -0
- package/src/server/management/agent-settings-routes.ts +20 -2
- package/src/server/management/context.ts +15 -0
- package/src/server/management/model-routes.ts +12 -3
- package/src/server/management/storage-log-guard-routes.ts +186 -0
- package/src/server/management-api.ts +3 -1
- package/src/server/responses/core.ts +13 -0
- package/src/types.ts +6 -1
- package/gui/dist/assets/index-Xq49CY8F.css +0 -1
|
@@ -80,6 +80,11 @@ interface ProviderModelDiscoverySharedSpec {
|
|
|
80
80
|
maxResponseBytes?: number;
|
|
81
81
|
/** Optional lower raw-row ceiling; the process-wide hard ceiling still wins. */
|
|
82
82
|
maxModels?: number;
|
|
83
|
+
/**
|
|
84
|
+
* If a valid extracted id starts with this prefix, strip it and re-validate the remainder.
|
|
85
|
+
* Empty/invalid remainders skip that row only.
|
|
86
|
+
*/
|
|
87
|
+
stripIdPrefix?: string;
|
|
83
88
|
}
|
|
84
89
|
|
|
85
90
|
type ProviderModelDiscoveryLocation =
|
|
@@ -2467,6 +2472,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
2467
2472
|
// Cloudflare Workers AI: OpenAI-compatible endpoint. The base URL contains {account_id}
|
|
2468
2473
|
// which must be resolved by the user at setup time. Model IDs use the @cf/ prefix.
|
|
2469
2474
|
// Live-verified 2026-07-21 against https://developers.cloudflare.com/workers-ai/models/
|
|
2475
|
+
// Official search is sibling to /ai/v1 (GET .../ai/models/search?format=openrouter).
|
|
2470
2476
|
id: "cloudflare-workers-ai", label: "Cloudflare Workers AI",
|
|
2471
2477
|
baseUrl: "https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1",
|
|
2472
2478
|
adapter: "openai-chat", authKind: "key", freeTier: true,
|
|
@@ -2481,6 +2487,13 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
2481
2487
|
"@cf/zai-org/glm-5.2",
|
|
2482
2488
|
"@cf/mistralai/mistral-small-3.1-24b-instruct",
|
|
2483
2489
|
],
|
|
2490
|
+
liveModels: true,
|
|
2491
|
+
modelDiscovery: {
|
|
2492
|
+
path: "../models/search",
|
|
2493
|
+
query: { format: "openrouter", per_page: "1000" },
|
|
2494
|
+
stripIdPrefix: "workers-ai/",
|
|
2495
|
+
maxModels: 256,
|
|
2496
|
+
},
|
|
2484
2497
|
note: "Workers AI · Free tier included · Account ID required in base URL",
|
|
2485
2498
|
},
|
|
2486
2499
|
// FREEZE 2026-07-10: /models was auth-gated under key login. OAuth device-flow + copilot_internal
|
|
@@ -29,6 +29,20 @@ export function encodeRoutedModelId(id: string): string {
|
|
|
29
29
|
return id.includes("/") ? id.replaceAll("/", SLUG_ALIAS_SEPARATOR) : id;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* True when `modelId` shares a Codex-facing encoded form with a different known id.
|
|
34
|
+
* That collision is what makes `provider/openai-gpt-5.5` decode to native `openai-gpt-5.5`
|
|
35
|
+
* while a custom `openai/gpt-5.5` row is still visible.
|
|
36
|
+
*/
|
|
37
|
+
export function encodedModelIdCollides(modelId: string, knownIds: Iterable<string>): boolean {
|
|
38
|
+
const encoded = encodeRoutedModelId(modelId);
|
|
39
|
+
for (const id of knownIds) {
|
|
40
|
+
if (id === modelId) continue;
|
|
41
|
+
if (encodeRoutedModelId(id) === encoded) return true;
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
32
46
|
/** Codex-facing routed slug: exactly one "/" — `<provider>/<encoded id>`. */
|
|
33
47
|
export function routedSlug(provider: string, id: string): string {
|
|
34
48
|
return `${provider}/${encodeRoutedModelId(id)}`;
|
|
@@ -51,6 +65,21 @@ export function decodeRoutedModelId(requested: string, knownIds: Iterable<string
|
|
|
51
65
|
return aliasMatch ?? requested;
|
|
52
66
|
}
|
|
53
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Decode a Codex-facing id, but fail when a custom slash id and another known id
|
|
70
|
+
* share the same encoded form. Write-time checks cannot cover a later live cache.
|
|
71
|
+
*/
|
|
72
|
+
export function decodeRoutedModelIdOrThrow(requested: string, knownIds: Iterable<string>): string {
|
|
73
|
+
const ids = [...knownIds];
|
|
74
|
+
const encodedRequested = encodeRoutedModelId(requested);
|
|
75
|
+
const matches = new Set<string>();
|
|
76
|
+
for (const id of ids) {
|
|
77
|
+
if (id === requested || encodeRoutedModelId(id) === encodedRequested) matches.add(id);
|
|
78
|
+
}
|
|
79
|
+
if (matches.size > 1) throw new Error(`ambiguous model id "${requested}"`);
|
|
80
|
+
return decodeRoutedModelId(requested, ids);
|
|
81
|
+
}
|
|
82
|
+
|
|
54
83
|
/** Does a stored config slug name this routed model, in either raw or encoded form? */
|
|
55
84
|
export function slugEquals(stored: string, provider: string, id: string): boolean {
|
|
56
85
|
return stored === `${provider}/${id}` || stored === routedSlug(provider, id);
|
package/src/router.ts
CHANGED
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
OPENAI_API_PROVIDER_ID,
|
|
25
25
|
OPENAI_CODEX_PROVIDER_ID,
|
|
26
26
|
} from "./providers/openai-tiers";
|
|
27
|
-
import {
|
|
27
|
+
import { decodeRoutedModelIdOrThrow, encodeRoutedModelId } from "./providers/slug-codec";
|
|
28
28
|
import { getStaleCached } from "./codex/model-cache";
|
|
29
29
|
import { codexAccountNamespaceEntries } from "./codex/account-namespaces";
|
|
30
30
|
import {
|
|
@@ -87,9 +87,14 @@ const MODEL_PROVIDER_PATTERNS: Array<{ providerNames: string[]; prefixes: string
|
|
|
87
87
|
* last-known-good live /models cache (may be empty on a cold start; decode then passes
|
|
88
88
|
* unknown ids through unchanged for an honest upstream error).
|
|
89
89
|
*/
|
|
90
|
-
export function knownModelIdsForProvider(
|
|
90
|
+
export function knownModelIdsForProvider(
|
|
91
|
+
provName: string,
|
|
92
|
+
prov: OcxProviderConfig,
|
|
93
|
+
config?: Pick<OcxConfig, "customModels">,
|
|
94
|
+
): string[] {
|
|
91
95
|
const ids = new Set<string>();
|
|
92
96
|
for (const id of prov.models ?? []) ids.add(id);
|
|
97
|
+
if (prov.defaultModel) ids.add(prov.defaultModel);
|
|
93
98
|
const registry = providerMatchesRegistryTransportWithStaticGuards(provName, prov)
|
|
94
99
|
? PROVIDER_REGISTRY.find(entry => entry.id === provName)
|
|
95
100
|
: undefined;
|
|
@@ -108,6 +113,9 @@ export function knownModelIdsForProvider(provName: string, prov: OcxProviderConf
|
|
|
108
113
|
for (const id of Object.keys(map ?? {})) ids.add(id);
|
|
109
114
|
}
|
|
110
115
|
for (const cached of getStaleCached(provName) ?? []) ids.add(cached.id);
|
|
116
|
+
for (const model of config?.customModels ?? []) {
|
|
117
|
+
if (model.provider === provName && model.modelId) ids.add(model.modelId);
|
|
118
|
+
}
|
|
111
119
|
return [...ids];
|
|
112
120
|
}
|
|
113
121
|
|
|
@@ -610,7 +618,7 @@ function routeModelInternal(
|
|
|
610
618
|
if (hasOwnProvider(config.providers, provName)) {
|
|
611
619
|
const prov = config.providers[provName];
|
|
612
620
|
if (prov.disabled === true) throw new Error(`Provider is disabled: ${provName}`);
|
|
613
|
-
const known = knownModelIdsForProvider(provName, prov);
|
|
621
|
+
const known = knownModelIdsForProvider(provName, prov, config);
|
|
614
622
|
// Self-namespaced native id — the vendor segment equals the provider id, so the FULL ref is
|
|
615
623
|
// itself a known model (e.g. orcarouter/auto). Route it whole instead of stripping to the
|
|
616
624
|
// remainder, which would send a bare `auto` the upstream cannot resolve.
|
|
@@ -622,7 +630,7 @@ function routeModelInternal(
|
|
|
622
630
|
return routeResult(
|
|
623
631
|
provName,
|
|
624
632
|
prov,
|
|
625
|
-
|
|
633
|
+
decodeRoutedModelIdOrThrow(modelId.slice(slash + 1), known),
|
|
626
634
|
"explicit-provider",
|
|
627
635
|
"explicit-provider-namespace",
|
|
628
636
|
);
|
|
@@ -26,11 +26,12 @@ import { statSync } from "node:fs";
|
|
|
26
26
|
import type { RouteCapabilityEvidence } from "./trace";
|
|
27
27
|
|
|
28
28
|
type CatalogModelRow = {
|
|
29
|
+
/** Exact provider/native-id identity, from the provenance block. */
|
|
29
30
|
provider: string;
|
|
30
31
|
id: string;
|
|
32
|
+
/** Only values a real source asserted; never a strict-parser default. */
|
|
31
33
|
contextWindow?: number;
|
|
32
34
|
inputModalities?: string[];
|
|
33
|
-
reasoningEfforts?: string[];
|
|
34
35
|
capabilities?: string[];
|
|
35
36
|
};
|
|
36
37
|
|
|
@@ -52,23 +53,33 @@ function cachedCatalogModels(): CatalogModelRow[] {
|
|
|
52
53
|
const catalog = readCatalog(path);
|
|
53
54
|
const models = catalog?.models;
|
|
54
55
|
if (!Array.isArray(models)) return [];
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
56
|
+
// Read ONLY `opencodex_capability_provenance` (written by
|
|
57
|
+
// applyCatalogModelMetadata). The row's own `context_window` and
|
|
58
|
+
// `input_modalities` always exist because ensureStrictCatalogFields fills them
|
|
59
|
+
// with compatibility defaults for Codex's strict parser, so reading them would
|
|
60
|
+
// turn "nobody asserted anything" into a confident `image: false` and a
|
|
61
|
+
// fabricated 128000 — the opposite of this module's contract. A row without
|
|
62
|
+
// provenance contributes nothing.
|
|
63
|
+
const rows = models.flatMap((model): CatalogModelRow[] => {
|
|
64
|
+
if (typeof model !== "object" || model === null) return [];
|
|
65
|
+
const provenance = (model as Record<string, unknown>).opencodex_capability_provenance;
|
|
66
|
+
if (typeof provenance !== "object" || provenance === null) return [];
|
|
67
|
+
const source = provenance as Record<string, unknown>;
|
|
68
|
+
if (typeof source.provider !== "string" || typeof source.model_id !== "string") return [];
|
|
69
|
+
return [{
|
|
70
|
+
provider: source.provider,
|
|
71
|
+
id: source.model_id,
|
|
72
|
+
...(typeof source.context_window === "number" && source.context_window > 0
|
|
73
|
+
? { contextWindow: source.context_window }
|
|
64
74
|
: {}),
|
|
65
|
-
...(Array.isArray(
|
|
66
|
-
? {
|
|
75
|
+
...(Array.isArray(source.input_modalities)
|
|
76
|
+
? { inputModalities: source.input_modalities.filter((value): value is string => typeof value === "string") }
|
|
67
77
|
: {}),
|
|
68
|
-
...(Array.isArray(
|
|
69
|
-
? { capabilities:
|
|
78
|
+
...(Array.isArray(source.capabilities)
|
|
79
|
+
? { capabilities: source.capabilities.filter((value): value is string => typeof value === "string") }
|
|
70
80
|
: {}),
|
|
71
|
-
}
|
|
81
|
+
}];
|
|
82
|
+
});
|
|
72
83
|
catalogCache = { path, mtimeMs, rows };
|
|
73
84
|
return rows;
|
|
74
85
|
} catch {
|
|
@@ -177,13 +188,17 @@ export function candidateCapabilityEvidence(
|
|
|
177
188
|
// override.
|
|
178
189
|
const tools = capabilities.includes("tools")
|
|
179
190
|
|| isNative
|
|
180
|
-
|
|
191
|
+
// The adapter protocol is positive evidence on its own. This was once gated
|
|
192
|
+
// on `catalogRow === undefined`, which was only safe while the catalog lookup
|
|
193
|
+
// never matched anything: once it matches, a row that simply does not
|
|
194
|
+
// enumerate "tools" would silently revoke tool support for every openai-chat
|
|
195
|
+
// and anthropic candidate.
|
|
196
|
+
|| (provider !== undefined && TOOL_CAPABLE_ADAPTERS.has(provider.adapter))
|
|
181
197
|
|| provider?.parallelToolCalls === true
|
|
182
198
|
|| undefined;
|
|
183
199
|
|
|
184
200
|
const reasoningEfforts = provider?.modelReasoningEfforts?.[modelId]
|
|
185
201
|
?? registryEntry?.modelReasoningEfforts?.[modelId]
|
|
186
|
-
?? catalogRow?.reasoningEfforts
|
|
187
202
|
?? (isNative ? nativeReasoningEfforts(modelId) : undefined);
|
|
188
203
|
|
|
189
204
|
const tierSupport = provider
|
package/src/server/index.ts
CHANGED
|
@@ -988,6 +988,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
|
|
|
988
988
|
providerContextCap(config, OPENAI_CODEX_PROVIDER_ID),
|
|
989
989
|
accountNativeSlugs,
|
|
990
990
|
accountNativeSlugsBySelector,
|
|
991
|
+
config.keepNativeChatGptOnV1 === true,
|
|
991
992
|
);
|
|
992
993
|
return jsonResponse({
|
|
993
994
|
models: applyNativeVisibility(
|
|
@@ -232,6 +232,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
232
232
|
agentsMaxThreadsConflict: enabled && hasAgentsMaxThreads(),
|
|
233
233
|
maxConcurrentThreadsPerSession: getLogicalMaxThreads(),
|
|
234
234
|
multiAgentMode: config.multiAgentMode ?? "default",
|
|
235
|
+
keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true,
|
|
235
236
|
agentsEnabled: getAgentsEnabled(),
|
|
236
237
|
agentsMaxDepth: getAgentsMaxDepth(),
|
|
237
238
|
subagentDeveloperInstructions: getSubagentDeveloperInstructions(),
|
|
@@ -246,6 +247,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
246
247
|
enabled?: unknown;
|
|
247
248
|
maxConcurrentThreadsPerSession?: unknown;
|
|
248
249
|
multiAgentMode?: unknown;
|
|
250
|
+
keepNativeChatGptOnV1?: unknown;
|
|
249
251
|
agentsEnabled?: unknown;
|
|
250
252
|
agentsMaxDepth?: unknown;
|
|
251
253
|
subagentDeveloperInstructions?: unknown;
|
|
@@ -255,17 +257,21 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
255
257
|
const wantsFlag = body.enabled !== undefined;
|
|
256
258
|
const wantsThreads = body.maxConcurrentThreadsPerSession !== undefined;
|
|
257
259
|
const wantsMode = body.multiAgentMode !== undefined;
|
|
260
|
+
const wantsKeepNative = body.keepNativeChatGptOnV1 !== undefined;
|
|
258
261
|
const wantsAgentsEnabled = body.agentsEnabled !== undefined;
|
|
259
262
|
const wantsMaxDepth = body.agentsMaxDepth !== undefined;
|
|
260
263
|
const wantsSubagentInstructions = body.subagentDeveloperInstructions !== undefined;
|
|
261
264
|
const wantsModeHintText = body.multiAgentModeHintText !== undefined;
|
|
262
|
-
if (!wantsFlag && !wantsThreads && !wantsMode && !wantsAgentsEnabled && !wantsMaxDepth && !wantsSubagentInstructions && !wantsModeHintText) {
|
|
263
|
-
return jsonResponse({ error: "body must set enabled, multiAgentMode, maxConcurrentThreadsPerSession, agentsEnabled, agentsMaxDepth, subagentDeveloperInstructions, and/or multiAgentModeHintText" }, 400);
|
|
265
|
+
if (!wantsFlag && !wantsThreads && !wantsMode && !wantsKeepNative && !wantsAgentsEnabled && !wantsMaxDepth && !wantsSubagentInstructions && !wantsModeHintText) {
|
|
266
|
+
return jsonResponse({ error: "body must set enabled, multiAgentMode, keepNativeChatGptOnV1, maxConcurrentThreadsPerSession, agentsEnabled, agentsMaxDepth, subagentDeveloperInstructions, and/or multiAgentModeHintText" }, 400);
|
|
264
267
|
}
|
|
265
268
|
if (wantsFlag && typeof body.enabled !== "boolean") return jsonResponse({ error: "body.enabled must be a boolean" }, 400);
|
|
266
269
|
if (wantsMode && body.multiAgentMode !== "v1" && body.multiAgentMode !== "default" && body.multiAgentMode !== "v2") {
|
|
267
270
|
return jsonResponse({ error: "body.multiAgentMode must be 'v1', 'default', or 'v2'" }, 400);
|
|
268
271
|
}
|
|
272
|
+
if (wantsKeepNative && typeof body.keepNativeChatGptOnV1 !== "boolean") {
|
|
273
|
+
return jsonResponse({ error: "body.keepNativeChatGptOnV1 must be a boolean" }, 400);
|
|
274
|
+
}
|
|
269
275
|
if (wantsThreads && (typeof body.maxConcurrentThreadsPerSession !== "number" || !Number.isInteger(body.maxConcurrentThreadsPerSession) || body.maxConcurrentThreadsPerSession < 1)) {
|
|
270
276
|
return jsonResponse({ error: "body.maxConcurrentThreadsPerSession must be an integer >= 1" }, 400);
|
|
271
277
|
}
|
|
@@ -333,6 +339,17 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
333
339
|
saveConfigPreservingClaudeCode(config);
|
|
334
340
|
warnings.push(`Multi-agent mode set to '${mode}'. Applies to new sessions.`);
|
|
335
341
|
}
|
|
342
|
+
if (wantsKeepNative) {
|
|
343
|
+
if (body.keepNativeChatGptOnV1 === true) config.keepNativeChatGptOnV1 = true;
|
|
344
|
+
else delete config.keepNativeChatGptOnV1;
|
|
345
|
+
saveConfigPreservingClaudeCode(config);
|
|
346
|
+
const effectiveMode = mode ?? config.multiAgentMode ?? "default";
|
|
347
|
+
warnings.push(body.keepNativeChatGptOnV1 === true
|
|
348
|
+
? (effectiveMode === "v2"
|
|
349
|
+
? "ChatGPT-native models stay on v1 while other models use v2. Applies to new sessions."
|
|
350
|
+
: "keepNativeChatGptOnV1 is stored but inactive until multi-agent mode is v2. Applies to new sessions.")
|
|
351
|
+
: "ChatGPT-native models follow the selected v1/v2/base surface. Applies to new sessions.");
|
|
352
|
+
}
|
|
336
353
|
// New-key scalar writes: each writer is individually atomic, so apply them in
|
|
337
354
|
// sequence after the transition. A failure here is a persistence failure (the
|
|
338
355
|
// writers' ok:false result or a throw from the underlying atomic write helper),
|
|
@@ -373,6 +390,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
373
390
|
agentsMaxThreadsConflict: enabled && hasAgentsMaxThreads(),
|
|
374
391
|
maxConcurrentThreadsPerSession: getLogicalMaxThreads(),
|
|
375
392
|
multiAgentMode: config.multiAgentMode ?? "default",
|
|
393
|
+
keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true,
|
|
376
394
|
agentsEnabled: getAgentsEnabled(),
|
|
377
395
|
agentsMaxDepth: getAgentsMaxDepth(),
|
|
378
396
|
subagentDeveloperInstructions: getSubagentDeveloperInstructions(),
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { OcxConfig } from "../../types";
|
|
2
2
|
import type { NativeProfileApiDeps } from "../../codex/native-profile-api";
|
|
3
|
+
import type { CodexLogGuardProtectionDeps } from "../../codex/log-guard/protection";
|
|
4
|
+
import type { CodexLogGuardMaintenanceDeps } from "../../codex/log-guard/maintenance";
|
|
3
5
|
import type { StartupHealth } from "../../codex/autostart-health";
|
|
4
6
|
import type { StartupInstallAction } from "../startup-action-control";
|
|
5
7
|
import type { ManagementPrincipal } from "../management-auth";
|
|
@@ -70,6 +72,19 @@ export interface ManagementApiDeps {
|
|
|
70
72
|
performRestart: typeof performCodexRestart;
|
|
71
73
|
};
|
|
72
74
|
nativeProfileApi?: NativeProfileApiDeps;
|
|
75
|
+
/**
|
|
76
|
+
* Log Guard mutation seam. Production leaves this unset and therefore uses the
|
|
77
|
+
* owner-verified process enumerator, trusted L namespace and real config store.
|
|
78
|
+
* Route tests inject all three so they cannot depend on local Codex processes
|
|
79
|
+
* or create lock/config state outside the fixture.
|
|
80
|
+
*/
|
|
81
|
+
codexLogGuardProtectionDeps?: CodexLogGuardProtectionDeps;
|
|
82
|
+
/**
|
|
83
|
+
* Log Guard maintenance seam. Production reuses the same fail-closed process
|
|
84
|
+
* enumerator and L namespace as Protect; route tests keep all maintenance
|
|
85
|
+
* state inside their temporary Codex home.
|
|
86
|
+
*/
|
|
87
|
+
codexLogGuardMaintenanceDeps?: CodexLogGuardMaintenanceDeps;
|
|
73
88
|
}
|
|
74
89
|
|
|
75
90
|
|
|
@@ -97,7 +97,8 @@ import { providerDestinationResolvedError } from "../../lib/destination-policy";
|
|
|
97
97
|
import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
|
|
98
98
|
import { deriveProviderPresets } from "../../providers/derive";
|
|
99
99
|
import { providerCodexAccountMode } from "../../providers/registry";
|
|
100
|
-
import { routedSlug, slugEquals } from "../../providers/slug-codec";
|
|
100
|
+
import { encodedModelIdCollides, routedSlug, slugEquals } from "../../providers/slug-codec";
|
|
101
|
+
import { knownModelIdsForProvider } from "../../router";
|
|
101
102
|
import { COMBO_NAMESPACE, comboDisabledModelSelectors, comboModelId, preservesPhysicalComboProvider } from "../../combos";
|
|
102
103
|
import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota";
|
|
103
104
|
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
@@ -376,7 +377,6 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
376
377
|
const provider = typeof body.provider === "string" ? body.provider.trim() : "";
|
|
377
378
|
const modelId = typeof body.modelId === "string" ? body.modelId.trim() : "";
|
|
378
379
|
if (!provider || !modelId) return jsonResponse({ error: "provider and modelId are required" }, 400);
|
|
379
|
-
if (modelId.includes("/")) return jsonResponse({ error: "modelId must not contain /" }, 400);
|
|
380
380
|
if (!isValidProviderName(provider)) return jsonResponse({ error: "invalid provider name" }, 400);
|
|
381
381
|
if (!hasOwnProvider(config.providers, provider)) return jsonResponse({ error: "provider not configured" }, 404);
|
|
382
382
|
const displayName = typeof body.displayName === "string" && body.displayName.trim() ? body.displayName.trim() : undefined;
|
|
@@ -394,6 +394,10 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
394
394
|
if (existing.some(cm => routedSlug(cm.provider, cm.modelId) === newSlug)) {
|
|
395
395
|
return jsonResponse({ error: "duplicate model" }, 409);
|
|
396
396
|
}
|
|
397
|
+
const known = knownModelIdsForProvider(provider, config.providers[provider], config);
|
|
398
|
+
if (encodedModelIdCollides(modelId, known)) {
|
|
399
|
+
return jsonResponse({ error: "ambiguous model id" }, 409);
|
|
400
|
+
}
|
|
397
401
|
const entry: OcxCustomModel = {
|
|
398
402
|
id: randomUUID(),
|
|
399
403
|
provider,
|
|
@@ -422,7 +426,6 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
422
426
|
if (idx === -1) return jsonResponse({ error: "not found" }, 404);
|
|
423
427
|
const cm = { ...list[idx] };
|
|
424
428
|
if (typeof body.modelId === "string" && body.modelId.trim()) {
|
|
425
|
-
if (body.modelId.includes("/")) return jsonResponse({ error: "modelId must not contain /" }, 400);
|
|
426
429
|
cm.modelId = body.modelId.trim();
|
|
427
430
|
}
|
|
428
431
|
if (body.displayName !== undefined) {
|
|
@@ -469,6 +472,12 @@ export async function handleModelRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
469
472
|
if (list.some((other, i) => i !== idx && routedSlug(other.provider, other.modelId) === updatedSlug)) {
|
|
470
473
|
return jsonResponse({ error: "duplicate model" }, 409);
|
|
471
474
|
}
|
|
475
|
+
const known = knownModelIdsForProvider(cm.provider, config.providers[cm.provider], {
|
|
476
|
+
customModels: list.filter((_, i) => i !== idx),
|
|
477
|
+
});
|
|
478
|
+
if (encodedModelIdCollides(cm.modelId, known)) {
|
|
479
|
+
return jsonResponse({ error: "ambiguous model id" }, 409);
|
|
480
|
+
}
|
|
472
481
|
list[idx] = cm;
|
|
473
482
|
config.customModels = list;
|
|
474
483
|
persistConfig(config);
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { resolveCodexHomeDir } from "../../codex/home";
|
|
2
|
+
import {
|
|
3
|
+
compactCodexLogs,
|
|
4
|
+
type CodexLogGuardCompactionResult,
|
|
5
|
+
} from "../../codex/log-guard/maintenance";
|
|
6
|
+
import {
|
|
7
|
+
getCodexLogGuardProtectionStatus,
|
|
8
|
+
protectCodexLogs,
|
|
9
|
+
repairCodexLogGuardProtection,
|
|
10
|
+
unprotectCodexLogs,
|
|
11
|
+
type CodexLogGuardMutationResult,
|
|
12
|
+
type CodexLogGuardStatus,
|
|
13
|
+
} from "../../codex/log-guard/protection";
|
|
14
|
+
import { scanStorage } from "../../storage/scanner";
|
|
15
|
+
import { jsonResponse } from "../auth-cors";
|
|
16
|
+
import {
|
|
17
|
+
managementBodyTooLargeResponse,
|
|
18
|
+
readManagementJsonBody,
|
|
19
|
+
} from "./body";
|
|
20
|
+
import type { ManagementContext } from "./context";
|
|
21
|
+
|
|
22
|
+
const INSPECTION_FAILED_MESSAGE = "Codex log inspection failed";
|
|
23
|
+
|
|
24
|
+
function inspectionUnavailable(report: CodexLogGuardStatus): boolean {
|
|
25
|
+
return report.schema.state === "unavailable";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function mutationStatus(result: CodexLogGuardMutationResult): number {
|
|
29
|
+
if (result.ok) return 200;
|
|
30
|
+
switch (result.error) {
|
|
31
|
+
case "process_enumeration_failed":
|
|
32
|
+
return 503;
|
|
33
|
+
case "codex_running":
|
|
34
|
+
case "busy":
|
|
35
|
+
case "unsupported_schema":
|
|
36
|
+
case "trigger_collision":
|
|
37
|
+
case "unsafe_path":
|
|
38
|
+
return 409;
|
|
39
|
+
case "database_error":
|
|
40
|
+
case "config_write_failed":
|
|
41
|
+
return 500;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function compactStatus(result: CodexLogGuardCompactionResult): number {
|
|
46
|
+
if (result.ok) return 200;
|
|
47
|
+
switch (result.error) {
|
|
48
|
+
case "process_enumeration_failed":
|
|
49
|
+
return 503;
|
|
50
|
+
case "codex_running":
|
|
51
|
+
case "busy":
|
|
52
|
+
case "unsupported_schema":
|
|
53
|
+
case "auto_vacuum_not_incremental":
|
|
54
|
+
case "unsafe_path":
|
|
55
|
+
case "integrity_check_failed":
|
|
56
|
+
return 409;
|
|
57
|
+
case "database_error":
|
|
58
|
+
return 500;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function mutationResponse(
|
|
63
|
+
result: CodexLogGuardMutationResult,
|
|
64
|
+
ctx: ManagementContext,
|
|
65
|
+
): Response {
|
|
66
|
+
return result.ok
|
|
67
|
+
? jsonResponse(result.status, 200, ctx.req, ctx.config)
|
|
68
|
+
: jsonResponse({ error: result.error }, mutationStatus(result), ctx.req, ctx.config);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function compactResponse(
|
|
72
|
+
result: CodexLogGuardCompactionResult,
|
|
73
|
+
ctx: ManagementContext,
|
|
74
|
+
): Response {
|
|
75
|
+
if (result.ok) {
|
|
76
|
+
return jsonResponse({ report: result.report }, 200, ctx.req, ctx.config);
|
|
77
|
+
}
|
|
78
|
+
return jsonResponse(
|
|
79
|
+
result.error === "integrity_check_failed"
|
|
80
|
+
? { error: result.error, phase: result.phase }
|
|
81
|
+
: { error: result.error },
|
|
82
|
+
compactStatus(result),
|
|
83
|
+
ctx.req,
|
|
84
|
+
ctx.config,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function readProtectMode(ctx: ManagementContext): Promise<"compat" | "quiet" | Response> {
|
|
89
|
+
let body: unknown;
|
|
90
|
+
try {
|
|
91
|
+
body = await readManagementJsonBody(ctx.req);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
const tooLarge = managementBodyTooLargeResponse(error, ctx.req, ctx.config);
|
|
94
|
+
if (tooLarge) return tooLarge;
|
|
95
|
+
return jsonResponse({ error: "invalid_request" }, 400, ctx.req, ctx.config);
|
|
96
|
+
}
|
|
97
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
98
|
+
return jsonResponse({ error: "invalid_request" }, 400, ctx.req, ctx.config);
|
|
99
|
+
}
|
|
100
|
+
const mode = (body as Record<string, unknown>).mode;
|
|
101
|
+
if (mode !== "compat" && mode !== "quiet") {
|
|
102
|
+
return jsonResponse({ error: "invalid_mode" }, 400, ctx.req, ctx.config);
|
|
103
|
+
}
|
|
104
|
+
return mode;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Codex Log Guard diagnostics plus explicit protection and maintenance mutations. */
|
|
108
|
+
export async function handleStorageLogGuardRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
109
|
+
const { req, url, config, deps } = ctx;
|
|
110
|
+
const protectionDeps = deps.codexLogGuardProtectionDeps;
|
|
111
|
+
|
|
112
|
+
if (url.pathname === "/api/storage/codex-logs") {
|
|
113
|
+
if (req.method !== "GET") return null;
|
|
114
|
+
try {
|
|
115
|
+
const report = getCodexLogGuardProtectionStatus(protectionDeps);
|
|
116
|
+
if (inspectionUnavailable(report)) {
|
|
117
|
+
return jsonResponse({ error: "inspect_failed", message: INSPECTION_FAILED_MESSAGE }, 500, req, config);
|
|
118
|
+
}
|
|
119
|
+
return jsonResponse(report, 200, req, config);
|
|
120
|
+
} catch {
|
|
121
|
+
return jsonResponse({
|
|
122
|
+
error: "inspect_failed",
|
|
123
|
+
message: INSPECTION_FAILED_MESSAGE,
|
|
124
|
+
}, 500, req, config);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (url.pathname === "/api/storage/codex-logs/protect") {
|
|
129
|
+
if (req.method !== "POST") return null;
|
|
130
|
+
const mode = await readProtectMode(ctx);
|
|
131
|
+
if (mode instanceof Response) return mode;
|
|
132
|
+
return mutationResponse(protectCodexLogs(mode, protectionDeps), ctx);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (url.pathname === "/api/storage/codex-logs/unprotect") {
|
|
136
|
+
if (req.method !== "POST") return null;
|
|
137
|
+
return mutationResponse(unprotectCodexLogs(protectionDeps), ctx);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (url.pathname === "/api/storage/codex-logs/repair") {
|
|
141
|
+
if (req.method !== "POST") return null;
|
|
142
|
+
return mutationResponse(repairCodexLogGuardProtection(protectionDeps), ctx);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (url.pathname === "/api/storage/codex-logs/compact") {
|
|
146
|
+
if (req.method !== "POST") return null;
|
|
147
|
+
return compactResponse(compactCodexLogs(deps.codexLogGuardMaintenanceDeps), ctx);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (url.pathname !== "/api/storage" || req.method !== "GET") return null;
|
|
151
|
+
|
|
152
|
+
// Keep the existing CODEX_HOME scan as the primary storage contract. The Log Guard
|
|
153
|
+
// report is attached separately so an external sqlite_home is visible without being
|
|
154
|
+
// silently folded into CODEX_HOME totals.
|
|
155
|
+
let storage;
|
|
156
|
+
try {
|
|
157
|
+
storage = scanStorage();
|
|
158
|
+
} catch {
|
|
159
|
+
const fallback = {
|
|
160
|
+
codexHome: resolveCodexHomeDir(),
|
|
161
|
+
generatedAt: Date.now(),
|
|
162
|
+
total: { bytes: 0, fileCount: 0 },
|
|
163
|
+
buckets: [],
|
|
164
|
+
error: "scan_failed",
|
|
165
|
+
};
|
|
166
|
+
try {
|
|
167
|
+
const report = getCodexLogGuardProtectionStatus(protectionDeps);
|
|
168
|
+
return inspectionUnavailable(report)
|
|
169
|
+
? jsonResponse({ ...fallback, codexLogs: null, codexLogsError: "inspect_failed" }, 200, req, config)
|
|
170
|
+
: jsonResponse({ ...fallback, codexLogs: report }, 200, req, config);
|
|
171
|
+
} catch {
|
|
172
|
+
return jsonResponse({ ...fallback, codexLogs: null, codexLogsError: "inspect_failed" }, 200, req, config);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const report = getCodexLogGuardProtectionStatus(protectionDeps);
|
|
178
|
+
return inspectionUnavailable(report)
|
|
179
|
+
? jsonResponse({ ...storage, codexLogs: null, codexLogsError: "inspect_failed" }, 200, req, config)
|
|
180
|
+
: jsonResponse({ ...storage, codexLogs: report }, 200, req, config);
|
|
181
|
+
} catch {
|
|
182
|
+
// Log Guard inspection is auxiliary to the existing Storage page. A config/path
|
|
183
|
+
// resolution failure must not take the legacy read-only storage report down with it.
|
|
184
|
+
return jsonResponse({ ...storage, codexLogs: null, codexLogsError: "inspect_failed" }, 200, req, config);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -59,6 +59,7 @@ import { applySystemEnvToggle } from "./system-env";
|
|
|
59
59
|
import type { ManagementApiDeps } from "./management/context";
|
|
60
60
|
import { handleConfigRoutes } from "./management/config-routes";
|
|
61
61
|
import { handleLogsUsageRoutes } from "./management/logs-usage-routes";
|
|
62
|
+
import { handleStorageLogGuardRoutes } from "./management/storage-log-guard-routes";
|
|
62
63
|
import { handleRequestHistoryRoutes } from "./management/request-history-routes";
|
|
63
64
|
import { handleRoutingAnalyticsRoutes } from "./management/routing-analytics-routes";
|
|
64
65
|
import { handleProviderRoutes } from "./management/provider-routes";
|
|
@@ -209,6 +210,7 @@ export async function handleManagementAPI(
|
|
|
209
210
|
let routed: Response | null;
|
|
210
211
|
try {
|
|
211
212
|
routed = (await handleConfigRoutes(ctx))
|
|
213
|
+
?? (await handleStorageLogGuardRoutes(ctx))
|
|
212
214
|
?? (await handleLogsUsageRoutes(ctx))
|
|
213
215
|
?? (await handleRequestHistoryRoutes(ctx))
|
|
214
216
|
?? (await handleRoutingAnalyticsRoutes(ctx))
|
|
@@ -300,4 +302,4 @@ export async function handleManagementAPI(
|
|
|
300
302
|
}
|
|
301
303
|
|
|
302
304
|
|
|
303
|
-
export { buildClaudeDesktopState, fetchAllModels } from "./management/shared";
|
|
305
|
+
export { buildClaudeDesktopState, fetchAllModels } from "./management/shared";
|
|
@@ -1080,6 +1080,19 @@ async function applyFinalRouteRequestNormalization(args: {
|
|
|
1080
1080
|
}
|
|
1081
1081
|
}
|
|
1082
1082
|
|
|
1083
|
+
// Generic Responses clients (e.g. AI-SDK apps) omit `store`, but the canonical
|
|
1084
|
+
// forward Codex backend rejects a native request without an explicit store:false.
|
|
1085
|
+
// Default it only there — every other Responses upstream (key-auth providers and
|
|
1086
|
+
// custom forward gateways) intentionally keeps the omitted-store server-side
|
|
1087
|
+
// default for previous_response_id reuse — and never override an explicit value.
|
|
1088
|
+
if (
|
|
1089
|
+
isCanonicalOpenAiForwardProvider(route.provider)
|
|
1090
|
+
&& parsed._rawBody && typeof parsed._rawBody === "object"
|
|
1091
|
+
&& (parsed._rawBody as Record<string, unknown>).store === undefined
|
|
1092
|
+
) {
|
|
1093
|
+
(parsed._rawBody as Record<string, unknown>).store = false;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1083
1096
|
// Final selected model before virtual wire-model rewriting (Pro aliases).
|
|
1084
1097
|
const finalSelectedModelId = route.modelId;
|
|
1085
1098
|
|
package/src/types.ts
CHANGED
|
@@ -596,7 +596,7 @@ export interface OcxCustomModel {
|
|
|
596
596
|
id: string;
|
|
597
597
|
/** 프로바이더 키 (기존 providers[name]) */
|
|
598
598
|
provider: string;
|
|
599
|
-
/**
|
|
599
|
+
/** Native provider model id; slashes are allowed and encoded for Codex as provider/<hyphenated-id>. */
|
|
600
600
|
modelId: string;
|
|
601
601
|
/** 인간 가독 표시명 (선택, 슬래시 불가) */
|
|
602
602
|
displayName?: string;
|
|
@@ -825,6 +825,11 @@ export interface OcxConfig {
|
|
|
825
825
|
* - "v2": force ALL models to v2 surface (override upstream pins)
|
|
826
826
|
*/
|
|
827
827
|
multiAgentMode?: "v1" | "default" | "v2";
|
|
828
|
+
/**
|
|
829
|
+
* When `multiAgentMode` is `"v2"`, keep ChatGPT-native catalog rows on v1.
|
|
830
|
+
* Routed parents get v2 tools; Sol/Terra can still spawn Grok/Claude (issue #92).
|
|
831
|
+
*/
|
|
832
|
+
keepNativeChatGptOnV1?: boolean;
|
|
828
833
|
/** Experimental, default-off ChatGPT recovery for encrypted V2 routed tasks. */
|
|
829
834
|
agentTaskRecovery?: {
|
|
830
835
|
enabled?: boolean;
|