@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.
Files changed (49) hide show
  1. package/AGENTS_INSTALL.md +32 -0
  2. package/README.md +1 -1
  3. package/gui/dist/assets/{index-BOFeam5a.js → index-ClEcVlFO.js} +43 -17
  4. package/gui/dist/assets/index-DQsMZzI5.css +1 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/claude/agents-inject.ts +2 -2
  8. package/src/cli/codex-log-guard-doctor.ts +103 -0
  9. package/src/cli/dispatch.ts +7 -1
  10. package/src/cli/help.ts +1 -1
  11. package/src/cli/models.ts +16 -6
  12. package/src/cli/observe.ts +38 -2
  13. package/src/cli/registry.ts +2 -1
  14. package/src/cli/v2.ts +34 -1
  15. package/src/codex/app-server-processes.ts +46 -26
  16. package/src/codex/catalog/effort.ts +49 -1
  17. package/src/codex/catalog/parsing.ts +64 -4
  18. package/src/codex/catalog/provider-fetch.ts +12 -0
  19. package/src/codex/catalog/sync.ts +14 -1
  20. package/src/codex/convergence.ts +2 -0
  21. package/src/codex/inject.ts +3 -3
  22. package/src/codex/log-guard/inspect.ts +506 -0
  23. package/src/codex/log-guard/lock.ts +150 -0
  24. package/src/codex/log-guard/maintenance.ts +403 -0
  25. package/src/codex/log-guard/path-safety.ts +39 -0
  26. package/src/codex/log-guard/policy.ts +44 -0
  27. package/src/codex/log-guard/processes.ts +205 -0
  28. package/src/codex/log-guard/protection.ts +489 -0
  29. package/src/codex/log-guard/sqlite-errors.ts +9 -0
  30. package/src/codex/paths.ts +5 -0
  31. package/src/codex/plugins-doctor.ts +1 -1
  32. package/src/codex/project-config-warnings.ts +2 -2
  33. package/src/generated/compatibility-version.json +72 -32
  34. package/src/providers/antigravity-models.ts +11 -1
  35. package/src/providers/model-discovery.ts +94 -6
  36. package/src/providers/quota.ts +159 -0
  37. package/src/providers/registry.ts +13 -0
  38. package/src/providers/slug-codec.ts +29 -0
  39. package/src/router.ts +12 -4
  40. package/src/routing/capability.ts +32 -17
  41. package/src/server/index.ts +1 -0
  42. package/src/server/management/agent-settings-routes.ts +20 -2
  43. package/src/server/management/context.ts +15 -0
  44. package/src/server/management/model-routes.ts +12 -3
  45. package/src/server/management/storage-log-guard-routes.ts +186 -0
  46. package/src/server/management-api.ts +3 -1
  47. package/src/server/responses/core.ts +13 -0
  48. package/src/types.ts +6 -1
  49. 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 { decodeRoutedModelId, encodeRoutedModelId } from "./providers/slug-codec";
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(provName: string, prov: OcxProviderConfig): string[] {
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
- decodeRoutedModelId(modelId.slice(slash + 1), known),
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
- const rows = models
56
- .filter((model): model is Record<string, unknown> & { id: string; provider: string } =>
57
- typeof model === "object" && model !== null && typeof model.id === "string" && typeof model.provider === "string")
58
- .map(model => ({
59
- provider: model.provider,
60
- id: model.id,
61
- ...(typeof model.contextWindow === "number" ? { contextWindow: model.contextWindow } : {}),
62
- ...(Array.isArray(model.inputModalities)
63
- ? { inputModalities: model.inputModalities.filter((value): value is string => typeof value === "string") }
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(model.reasoningEfforts)
66
- ? { reasoningEfforts: model.reasoningEfforts.filter((value): value is string => typeof value === "string") }
75
+ ...(Array.isArray(source.input_modalities)
76
+ ? { inputModalities: source.input_modalities.filter((value): value is string => typeof value === "string") }
67
77
  : {}),
68
- ...(Array.isArray(model.capabilities)
69
- ? { capabilities: model.capabilities.filter((value): value is string => typeof value === "string") }
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
- || (catalogRow === undefined && provider !== undefined && TOOL_CAPABLE_ADAPTERS.has(provider.adapter))
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
@@ -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
- /** 모델 슬러그 (프로바이더 접두사 없는 bare id) */
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;