@yansigit/opencodex 2.31.3 → 2.33.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 (176) hide show
  1. package/README.md +2 -2
  2. package/bin/ocx.mjs +99 -70
  3. package/gui/dist/assets/index-DKLr4LTE.js +102 -0
  4. package/gui/dist/assets/index-DrSQdTRd.css +1 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +6 -5
  7. package/src/adapters/anthropic.ts +25 -13
  8. package/src/adapters/azure.ts +20 -4
  9. package/src/adapters/base.ts +5 -1
  10. package/src/adapters/command-code.ts +42 -10
  11. package/src/adapters/cursor/live-models.ts +8 -0
  12. package/src/adapters/cursor/live-transport.ts +1 -1
  13. package/src/adapters/cursor/native-exec-desktop.ts +16 -0
  14. package/src/adapters/cursor/protobuf-events.ts +158 -7
  15. package/src/adapters/cursor/protobuf-request.ts +33 -15
  16. package/src/adapters/cursor/request-builder.ts +4 -3
  17. package/src/adapters/cursor/tool-definitions.ts +27 -1
  18. package/src/adapters/cursor/types.ts +4 -3
  19. package/src/adapters/cursor.ts +9 -0
  20. package/src/adapters/google-antigravity-replay.ts +2 -2
  21. package/src/adapters/google-antigravity-wire.ts +7 -0
  22. package/src/adapters/google-errors.ts +6 -2
  23. package/src/adapters/google-http.ts +30 -7
  24. package/src/adapters/google-truncation.ts +5 -0
  25. package/src/adapters/google-wire-compiler.ts +38 -6
  26. package/src/adapters/google.ts +154 -33
  27. package/src/adapters/kiro-tools.ts +20 -9
  28. package/src/adapters/kiro.ts +0 -3
  29. package/src/adapters/openai-chat.ts +9 -0
  30. package/src/adapters/openai-responses.ts +4 -1
  31. package/src/adapters/tool-catalog-nudge.ts +1 -1
  32. package/src/adapters/xai-web-search.ts +7 -2
  33. package/src/bridge.ts +133 -24
  34. package/src/claude/context-windows.ts +16 -9
  35. package/src/cli/dispatch.ts +50 -2
  36. package/src/cli/doctor.ts +26 -13
  37. package/src/cli/help.ts +4 -3
  38. package/src/cli/index.ts +20 -6
  39. package/src/cli/models.ts +13 -3
  40. package/src/cli/observe.ts +20 -5
  41. package/src/cli/provider.ts +8 -1
  42. package/src/cli/registry.ts +7 -5
  43. package/src/cli/status.ts +25 -1
  44. package/src/cli/system-restart-client.ts +1 -1
  45. package/src/cli/usage-report.ts +134 -0
  46. package/src/codex/app-server-processes.ts +3 -1
  47. package/src/codex/auth-api.ts +4 -2
  48. package/src/codex/autostart-health.ts +16 -0
  49. package/src/codex/catalog/aggregation.ts +12 -0
  50. package/src/codex/catalog/effort.ts +42 -12
  51. package/src/codex/catalog/metadata.ts +27 -1
  52. package/src/codex/catalog/model-metadata.ts +593 -0
  53. package/src/codex/catalog/parsing.ts +71 -27
  54. package/src/codex/catalog/provider-fetch.ts +189 -33
  55. package/src/codex/catalog/sync.ts +6 -5
  56. package/src/codex/convergence.ts +5 -0
  57. package/src/codex/desktop-app-restart.ts +342 -0
  58. package/src/codex/history-job.ts +32 -3
  59. package/src/codex/history-manifest.ts +112 -0
  60. package/src/codex/history-migration-guardian.ts +5 -5
  61. package/src/codex/history-provider.ts +825 -247
  62. package/src/codex/history-worker.ts +8 -5
  63. package/src/codex/inject.ts +49 -21
  64. package/src/codex/injected-marker.ts +1 -1
  65. package/src/codex/internal/history-writer.ts +4 -3
  66. package/src/codex/native-profile-startup.ts +157 -27
  67. package/src/codex/native-residue.ts +26 -33
  68. package/src/codex/shim.ts +56 -3
  69. package/src/combos/failover.ts +27 -0
  70. package/src/compatibility/index.ts +26 -0
  71. package/src/compatibility/manifest.ts +253 -0
  72. package/src/compatibility/openai-responses.ts +81 -0
  73. package/src/config/atomic-write.ts +219 -0
  74. package/src/config/paths.ts +40 -0
  75. package/src/config/process-state.ts +308 -0
  76. package/src/config/provider-validation.ts +214 -0
  77. package/src/config.ts +153 -814
  78. package/src/generated/compatibility-version.json +232 -148
  79. package/src/images/loop.ts +37 -6
  80. package/src/images/plan.ts +5 -4
  81. package/src/integrations/ownership-policy.ts +141 -0
  82. package/src/integrations/ownership.ts +10 -0
  83. package/src/integrations/state.ts +44 -5
  84. package/src/integrations/writer.ts +6 -0
  85. package/src/lib/azure-identity.ts +154 -0
  86. package/src/lib/bounded-body.ts +14 -2
  87. package/src/lib/debug.ts +42 -0
  88. package/src/lib/errors.ts +14 -0
  89. package/src/lib/process-control.ts +2 -1
  90. package/src/lib/provider-outbound.ts +45 -33
  91. package/src/lib/provider-tls-profile.ts +309 -0
  92. package/src/lib/proxy-env.ts +49 -0
  93. package/src/lib/redact.ts +10 -1
  94. package/src/lib/state-store-registrations.ts +2 -0
  95. package/src/lib/tool-argument-integers.ts +56 -5
  96. package/src/oauth/antigravity-routing.ts +282 -236
  97. package/src/oauth/callback-server.ts +22 -2
  98. package/src/oauth/command-code.ts +5 -16
  99. package/src/oauth/google-antigravity.ts +42 -5
  100. package/src/oauth/health.ts +1 -1
  101. package/src/oauth/index.ts +15 -3
  102. package/src/oauth/kimi.ts +9 -1
  103. package/src/oauth/open-browser-choice.ts +26 -0
  104. package/src/oauth/store.ts +6 -0
  105. package/src/providers/antigravity-quota.ts +3 -1
  106. package/src/providers/api-keys.ts +2 -1
  107. package/src/providers/auto-compact-budget.ts +65 -0
  108. package/src/providers/derive.ts +4 -0
  109. package/src/providers/key-failover.ts +5 -1
  110. package/src/providers/openai-tiers.ts +5 -0
  111. package/src/providers/provider-id-rewrite.ts +1 -0
  112. package/src/providers/quota.ts +59 -13
  113. package/src/providers/registry.ts +4 -2
  114. package/src/providers/request-pacing.ts +33 -6
  115. package/src/providers/xai-transport.ts +21 -0
  116. package/src/reasoning-effort.ts +19 -2
  117. package/src/responses/apply-patch-envelope.ts +63 -0
  118. package/src/responses/custom-tool-compat.ts +132 -38
  119. package/src/responses/google-provider-options.ts +36 -0
  120. package/src/responses/namespace-tool-compat.ts +84 -4
  121. package/src/responses/parser.ts +14 -2
  122. package/src/responses/provider-opaque-metadata.ts +3 -3
  123. package/src/responses/reasoning-replay-cache.ts +81 -3
  124. package/src/responses/schema.ts +37 -0
  125. package/src/responses/state.ts +94 -4
  126. package/src/router.ts +8 -2
  127. package/src/server/auth-cors.ts +37 -7
  128. package/src/server/images.ts +19 -35
  129. package/src/server/index.ts +102 -21
  130. package/src/server/local-management-read-client.ts +1 -1
  131. package/src/server/local-provider-reload-client.ts +1 -1
  132. package/src/server/management/agent-settings-routes.ts +206 -16
  133. package/src/server/management/combo-routes.ts +6 -0
  134. package/src/server/management/config-routes.ts +35 -6
  135. package/src/server/management/context.ts +1 -1
  136. package/src/server/management/logs-usage-routes.ts +27 -6
  137. package/src/server/management/model-routes.ts +8 -4
  138. package/src/server/management/model-rows.ts +4 -0
  139. package/src/server/management/native-integration-routes.ts +2 -1
  140. package/src/server/management/oauth-account-routes.ts +25 -4
  141. package/src/server/management/provider-capability-config.ts +1 -1
  142. package/src/server/management/provider-routes.ts +113 -15
  143. package/src/server/management/routing-profile-routes.ts +3 -0
  144. package/src/server/management/system-restart.ts +1 -1
  145. package/src/server/port-reclaim.ts +1 -1
  146. package/src/server/proxy-liveness.ts +2 -1
  147. package/src/server/request-log-conversation.ts +30 -0
  148. package/src/server/request-log.ts +21 -0
  149. package/src/server/responses/agent-task-recovery.ts +1 -1
  150. package/src/server/responses/codex-auth-error.ts +55 -0
  151. package/src/server/responses/combo-stream-preflight.ts +171 -0
  152. package/src/server/responses/compact.ts +36 -22
  153. package/src/server/responses/core.ts +584 -247
  154. package/src/server/responses/empty-completion-guard.ts +35 -6
  155. package/src/server/responses/fetch-helpers.ts +20 -102
  156. package/src/server/responses/v2-native-parent-override.ts +59 -0
  157. package/src/server/responses/ws-upstream.ts +75 -2
  158. package/src/server/responses-custom-tool-repair.ts +41 -5
  159. package/src/server/responses-undeclared-tool-guard.ts +241 -18
  160. package/src/service.ts +9 -5
  161. package/src/types/config.ts +16 -1
  162. package/src/types/provider.ts +16 -0
  163. package/src/types/request.ts +34 -1
  164. package/src/types/tools.ts +114 -11
  165. package/src/types.ts +7 -1
  166. package/src/update/index.ts +5 -4
  167. package/src/update/job.ts +3 -1
  168. package/src/update/transactional-install.mjs +8 -1
  169. package/src/usage/log.ts +16 -8
  170. package/src/usage/summary.ts +201 -8
  171. package/src/vision/describe.ts +18 -13
  172. package/src/web-search/executor.ts +10 -3
  173. package/src/web-search/gemini-executor.ts +6 -4
  174. package/src/web-search/loop.ts +42 -6
  175. package/gui/dist/assets/index-CGoDO3uO.css +0 -1
  176. package/gui/dist/assets/index-Cxt5fZMP.js +0 -102
package/src/codex/shim.ts CHANGED
@@ -603,6 +603,47 @@ function backupPathFor(path: string): string {
603
603
  return ext ? `${path.slice(0, -ext.length)}.opencodex-real${ext}` : `${path}.opencodex-real`;
604
604
  }
605
605
 
606
+ /**
607
+ * True when a Codex binary lives inside a version manager's install tree.
608
+ *
609
+ * These trees are rewritten in place on upgrade, which destroys both the shim
610
+ * and the sibling .opencodex-real backup it restores from (#2412). The tempting
611
+ * repair — adopt the newly installed binary as a fresh original — is wrong
612
+ * twice: it records a provenance that never happened, and the next upgrade wipes
613
+ * it again, so the repair silently un-repairs on the version manager's schedule.
614
+ *
615
+ * Scope is the three managers named in the report. nvm/fnm/npm-prefix are
616
+ * deliberately excluded: a false positive here refuses a restore that would
617
+ * otherwise be correct.
618
+ */
619
+ export function isVersionManagerOwnedCodexPath(path: string): boolean {
620
+ const normalized = path.replace(/\\/g, "/").toLowerCase();
621
+ return normalized.includes("/mise/installs/")
622
+ || normalized.includes("/mise/shims/")
623
+ || normalized.includes("/.asdf/installs/")
624
+ || normalized.includes("/.asdf/shims/")
625
+ || normalized.includes("/.volta/");
626
+ }
627
+
628
+ /**
629
+ * Why auto-restore refused, in the operator's own terms. Auto-restore used to
630
+ * return a bare `{ status: "ineligible" }`, and the CLI warns only when a
631
+ * message is present, so `ocx start`, `ocx ensure`, and `ocx service repair` all
632
+ * reported success while routing quietly stayed native (#2412, the cause behind
633
+ * the misleading green status in #2411).
634
+ */
635
+ function destroyedShimMessage(file: ShimFileState): string {
636
+ const wrapper = existsSync(file.wrapperPath)
637
+ ? isShim(file.wrapperPath) ? "present but unusable" : "present but not an opencodex shim"
638
+ : "missing";
639
+ const backup = existsSync(file.backupPath) ? "present" : "missing";
640
+ const base = `Codex autostart shim not restored: wrapper ${wrapper} at ${file.wrapperPath}; original backup ${backup} at ${file.backupPath}.`;
641
+ if (!isVersionManagerOwnedCodexPath(file.wrapperPath)) {
642
+ return `${base} Re-run 'ocx codex-shim install' once the Codex binary is stable.`;
643
+ }
644
+ return `${base} This Codex binary is owned by a version manager (mise/asdf/volta), so opencodex will not wrap it as a new original — the next upgrade would overwrite the shim and its backup again. Route through Codex instead with 'ocx start', and use 'ocx service install' for autostart.`;
645
+ }
646
+
606
647
  function shQuote(value: string): string {
607
648
  return `'${value.replace(/'/g, "'\\''")}'`;
608
649
  }
@@ -2039,14 +2080,20 @@ export function autoRestoreCodexShim(options: {
2039
2080
  if (seen.has(file.wrapperPath)) return { status: "ineligible" };
2040
2081
  seen.add(file.wrapperPath);
2041
2082
  if (file.preserveOnly) {
2042
- if (!existsSync(file.backupPath) || existsSync(file.originalPath)) return { status: "ineligible" };
2083
+ if (!existsSync(file.backupPath) || existsSync(file.originalPath)) {
2084
+ return { status: "ineligible", message: destroyedShimMessage(file) };
2085
+ }
2043
2086
  continue;
2044
2087
  }
2045
- if (!existsSync(file.wrapperPath) || !hasUsableBackingPath(file)) return { status: "ineligible" };
2088
+ if (!existsSync(file.wrapperPath) || !hasUsableBackingPath(file)) {
2089
+ return { status: "ineligible", message: destroyedShimMessage(file) };
2090
+ }
2046
2091
  const probe = stableShimPathProbe(file.wrapperPath);
2047
2092
  if (!probe) return { status: "deferred" };
2048
2093
  if (probe.prefix.includes(SHIM_MARKER)) {
2049
- if (!isHealthyShimProbe(probe, state.platform)) return { status: "ineligible" };
2094
+ if (!isHealthyShimProbe(probe, state.platform)) {
2095
+ return { status: "ineligible", message: destroyedShimMessage(file) };
2096
+ }
2050
2097
  if (state.platform !== "win32" && !isCurrentUnixShimProbe(probe)) {
2051
2098
  obsoleteShimProbes.set(file.wrapperPath, probe);
2052
2099
  continue;
@@ -2054,6 +2101,12 @@ export function autoRestoreCodexShim(options: {
2054
2101
  healthyCount += 1;
2055
2102
  continue;
2056
2103
  }
2104
+ // A surviving backup would otherwise let the replacement path below wrap the
2105
+ // version manager's NEW binary as a fresh original — the same adoption the
2106
+ // missing-backup case refuses, arriving through the back door.
2107
+ if (isVersionManagerOwnedCodexPath(file.wrapperPath)) {
2108
+ return { status: "ineligible", message: destroyedShimMessage(file) };
2109
+ }
2057
2110
  replacementProbes.set(file.wrapperPath, probe);
2058
2111
  }
2059
2112
 
@@ -109,6 +109,28 @@ export function clearComboTargetCooldowns(comboId?: string): void {
109
109
 
110
110
  export type ComboFailureDecision = "hop" | "stop";
111
111
 
112
+ function isModelLifecycleGone(
113
+ status: number,
114
+ message: string,
115
+ code?: string | null,
116
+ ): boolean {
117
+ if (status !== 410) return false;
118
+ const normalizedCode = code?.trim().toLowerCase().replaceAll("-", "_");
119
+ if ([
120
+ "model_deprecated",
121
+ "model_end_of_life",
122
+ "model_eol",
123
+ "model_not_found",
124
+ "model_retired",
125
+ ].includes(normalizedCode ?? "")) return true;
126
+ const text = message.toLowerCase();
127
+ return /\bmodel\b/.test(text) && (
128
+ /\bend[ -]of[ -]life\b/.test(text)
129
+ || /\bno longer available\b/.test(text)
130
+ || /\b(?:deprecated|retired|retirement|sunset|decommissioned)\b/.test(text)
131
+ );
132
+ }
133
+
112
134
  export function comboFailureDecision(
113
135
  status: number,
114
136
  message: string,
@@ -119,6 +141,11 @@ export function comboFailureDecision(
119
141
  // Cyber policy is a hard non-retryable refusal — honor structured code even when
120
142
  // classificationText was truncated before the JSON code field.
121
143
  if (isCyberPolicyCode(options?.code)) return "stop";
144
+ // HTTP 410 is normally terminal. A model-specific lifecycle verdict is target-local,
145
+ // however: another provider/model in the declared combo can still serve the request.
146
+ // Require structured lifecycle code or explicit model+lifecycle prose so unrelated
147
+ // application-level 410 responses remain fail-closed.
148
+ if (isModelLifecycleGone(status, message, options?.code)) return "hop";
122
149
  const error = classifyError(status, "upstream_error", message);
123
150
  if (isCyberPolicyCode(error.code)) return "stop";
124
151
  // A local input-admission refusal (#1524) says "this candidate cannot fit the request",
@@ -0,0 +1,26 @@
1
+ import { OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST } from "./openai-responses";
2
+ import type { CompatibilityManifestV1 } from "./manifest";
3
+
4
+ export {
5
+ COMPATIBILITY_DISPOSITIONS,
6
+ COMPATIBILITY_MANIFEST_SCHEMA_VERSION,
7
+ compatibilityManifestIssues,
8
+ defineCompatibilityManifest,
9
+ } from "./manifest";
10
+ export type {
11
+ CompatibilityClaimV1,
12
+ CompatibilityDisposition,
13
+ CompatibilityEvidenceKind,
14
+ CompatibilityEvidenceRefV1,
15
+ CompatibilityManifestV1,
16
+ CompatibilitySubjectV1,
17
+ } from "./manifest";
18
+ export { OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST } from "./openai-responses";
19
+
20
+ export const COMPATIBILITY_MANIFESTS: readonly CompatibilityManifestV1[] = Object.freeze([
21
+ OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST,
22
+ ]);
23
+
24
+ export function getCompatibilityManifest(id: string): CompatibilityManifestV1 | undefined {
25
+ return COMPATIBILITY_MANIFESTS.find(manifest => manifest.id === id);
26
+ }
@@ -0,0 +1,253 @@
1
+ export const COMPATIBILITY_MANIFEST_SCHEMA_VERSION = 1 as const;
2
+
3
+ export const COMPATIBILITY_DISPOSITIONS = [
4
+ "passthrough",
5
+ "translated",
6
+ "degraded",
7
+ "unsupported",
8
+ ] as const;
9
+
10
+ export type CompatibilityDisposition = typeof COMPATIBILITY_DISPOSITIONS[number];
11
+ export type CompatibilityEvidenceKind = "fixture" | "lab-scenario";
12
+
13
+ export interface CompatibilityEvidenceRefV1 {
14
+ kind: CompatibilityEvidenceKind;
15
+ id: string;
16
+ /** Exact fixture assertions that prove this claim. Required for fixture evidence. */
17
+ assertionIds?: readonly string[];
18
+ }
19
+
20
+ export interface CompatibilityClaimV1 {
21
+ /** Stable manifest-local claim id. */
22
+ id: string;
23
+ /** Stable public feature key, such as `request.previous_response_id`. */
24
+ feature: string;
25
+ disposition: CompatibilityDisposition;
26
+ summary: string;
27
+ /** Required when behavior is not byte/semantic passthrough. */
28
+ limitation?: string;
29
+ evidence: readonly CompatibilityEvidenceRefV1[];
30
+ }
31
+
32
+ export interface CompatibilitySubjectV1 {
33
+ providerId: string;
34
+ /** Exact normalized provider base URL; destination changes require a new subject/version. */
35
+ baseUrl: string;
36
+ adapterId: string;
37
+ authMode: "forward" | "key" | "oauth" | "local";
38
+ inboundProtocol: "responses" | "chat" | "messages";
39
+ upstreamProtocol: string;
40
+ /** Claims apply only to these exact wire model ids. */
41
+ modelIds: readonly string[];
42
+ }
43
+
44
+ export interface CompatibilityManifestV1 {
45
+ schemaVersion: typeof COMPATIBILITY_MANIFEST_SCHEMA_VERSION;
46
+ id: string;
47
+ version: string;
48
+ subject: CompatibilitySubjectV1;
49
+ claims: readonly CompatibilityClaimV1[];
50
+ }
51
+
52
+ const ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/;
53
+ const FEATURE_PATTERN = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)+$/;
54
+ const DISPOSITIONS = new Set<string>(COMPATIBILITY_DISPOSITIONS);
55
+ const EVIDENCE_KINDS = new Set<string>(["fixture", "lab-scenario"]);
56
+
57
+ function isRecord(value: unknown): value is Record<string, unknown> {
58
+ return !!value && typeof value === "object" && !Array.isArray(value);
59
+ }
60
+
61
+ function checkKnownKeys(
62
+ value: Record<string, unknown>,
63
+ allowed: readonly string[],
64
+ path: string,
65
+ issues: string[],
66
+ ): void {
67
+ const allowedSet = new Set(allowed);
68
+ for (const key of Object.keys(value)) {
69
+ if (!allowedSet.has(key)) issues.push(`${path}${path ? "." : ""}${key} is not allowed by schema v1`);
70
+ }
71
+ }
72
+
73
+ function checkId(value: unknown, path: string, issues: string[]): value is string {
74
+ if (typeof value !== "string" || !ID_PATTERN.test(value)) {
75
+ issues.push(`${path} must match ${ID_PATTERN}`);
76
+ return false;
77
+ }
78
+ return true;
79
+ }
80
+
81
+ function checkNonBlank(value: unknown, path: string, issues: string[]): value is string {
82
+ if (typeof value !== "string" || value.trim().length === 0) {
83
+ issues.push(`${path} must be a non-blank string`);
84
+ return false;
85
+ }
86
+ return true;
87
+ }
88
+
89
+ function checkNormalizedBaseUrl(value: unknown, path: string, issues: string[]): value is string {
90
+ if (typeof value !== "string") {
91
+ issues.push(`${path} must be a normalized absolute HTTP(S) base URL`);
92
+ return false;
93
+ }
94
+ try {
95
+ const parsed = new URL(value);
96
+ if (
97
+ (parsed.protocol !== "https:" && parsed.protocol !== "http:")
98
+ || parsed.username
99
+ || parsed.password
100
+ || parsed.search
101
+ || parsed.hash
102
+ ) {
103
+ issues.push(`${path} must be a normalized absolute HTTP(S) base URL without credentials, query, or fragment`);
104
+ return false;
105
+ }
106
+ const normalizedPath = parsed.pathname.replace(/\/+$/, "");
107
+ const normalized = `${parsed.origin}${normalizedPath}`;
108
+ if (value !== normalized) {
109
+ issues.push(`${path} must be normalized as ${normalized}`);
110
+ return false;
111
+ }
112
+ return true;
113
+ } catch {
114
+ issues.push(`${path} must be a normalized absolute HTTP(S) base URL`);
115
+ return false;
116
+ }
117
+ }
118
+
119
+ function checkSortedUniqueStrings(
120
+ value: unknown,
121
+ path: string,
122
+ issues: string[],
123
+ options: { nonEmpty?: boolean } = {},
124
+ ): value is string[] {
125
+ if (!Array.isArray(value) || (options.nonEmpty === true && value.length === 0)) {
126
+ issues.push(`${path} must be${options.nonEmpty === true ? " a non-empty" : " an"} array`);
127
+ return false;
128
+ }
129
+ if (value.some(item => typeof item !== "string" || item.trim().length === 0)) {
130
+ issues.push(`${path} must contain only non-blank strings`);
131
+ return false;
132
+ }
133
+ const strings = value as string[];
134
+ if (new Set(strings).size !== strings.length) issues.push(`${path} must not contain duplicates`);
135
+ const sorted = [...strings].sort();
136
+ if (strings.some((item, index) => item !== sorted[index])) issues.push(`${path} must be sorted`);
137
+ return true;
138
+ }
139
+
140
+ function checkEvidence(value: unknown, path: string, issues: string[]): void {
141
+ if (!Array.isArray(value) || value.length === 0) {
142
+ issues.push(`${path} must be a non-empty array`);
143
+ return;
144
+ }
145
+ const seen = new Set<string>();
146
+ value.forEach((raw, index) => {
147
+ const evidencePath = `${path}[${index}]`;
148
+ if (!isRecord(raw)) {
149
+ issues.push(`${evidencePath} must be an object`);
150
+ return;
151
+ }
152
+ checkKnownKeys(raw, ["kind", "id", "assertionIds"], evidencePath, issues);
153
+ if (!EVIDENCE_KINDS.has(String(raw.kind))) {
154
+ issues.push(`${evidencePath}.kind must be fixture or lab-scenario`);
155
+ }
156
+ const id = checkId(raw.id, `${evidencePath}.id`, issues) ? raw.id : "";
157
+ const assertionIds = raw.assertionIds;
158
+ if (raw.kind === "fixture") {
159
+ checkSortedUniqueStrings(assertionIds, `${evidencePath}.assertionIds`, issues, { nonEmpty: true });
160
+ } else if (assertionIds !== undefined) {
161
+ checkSortedUniqueStrings(assertionIds, `${evidencePath}.assertionIds`, issues, { nonEmpty: true });
162
+ }
163
+ const key = `${String(raw.kind)}:${id}:${JSON.stringify(assertionIds ?? [])}`;
164
+ if (seen.has(key)) issues.push(`${evidencePath} duplicates an earlier evidence reference`);
165
+ seen.add(key);
166
+ });
167
+ }
168
+
169
+ /**
170
+ * Validate one versioned compatibility manifest without importing provider or Lab state.
171
+ * Keeping this as a leaf lets future CLI/GUI readers load contracts without activating Lab.
172
+ */
173
+ export function compatibilityManifestIssues(value: unknown): string[] {
174
+ const issues: string[] = [];
175
+ if (!isRecord(value)) return ["manifest must be an object"];
176
+ checkKnownKeys(value, ["schemaVersion", "id", "version", "subject", "claims"], "", issues);
177
+ if (value.schemaVersion !== COMPATIBILITY_MANIFEST_SCHEMA_VERSION) {
178
+ issues.push(`schemaVersion must be ${COMPATIBILITY_MANIFEST_SCHEMA_VERSION}`);
179
+ }
180
+ checkId(value.id, "id", issues);
181
+ checkNonBlank(value.version, "version", issues);
182
+
183
+ if (!isRecord(value.subject)) {
184
+ issues.push("subject must be an object");
185
+ } else {
186
+ checkKnownKeys(
187
+ value.subject,
188
+ ["providerId", "baseUrl", "adapterId", "authMode", "inboundProtocol", "upstreamProtocol", "modelIds"],
189
+ "subject",
190
+ issues,
191
+ );
192
+ checkId(value.subject.providerId, "subject.providerId", issues);
193
+ checkNormalizedBaseUrl(value.subject.baseUrl, "subject.baseUrl", issues);
194
+ checkId(value.subject.adapterId, "subject.adapterId", issues);
195
+ if (!["forward", "key", "oauth", "local"].includes(String(value.subject.authMode))) {
196
+ issues.push("subject.authMode must be forward, key, oauth, or local");
197
+ }
198
+ if (!["responses", "chat", "messages"].includes(String(value.subject.inboundProtocol))) {
199
+ issues.push("subject.inboundProtocol must be responses, chat, or messages");
200
+ }
201
+ checkNonBlank(value.subject.upstreamProtocol, "subject.upstreamProtocol", issues);
202
+ checkSortedUniqueStrings(value.subject.modelIds, "subject.modelIds", issues, { nonEmpty: true });
203
+ }
204
+
205
+ if (!Array.isArray(value.claims) || value.claims.length === 0) {
206
+ issues.push("claims must be a non-empty array");
207
+ return issues;
208
+ }
209
+ const claimIds = new Set<string>();
210
+ const claimFeatures = new Set<string>();
211
+ value.claims.forEach((raw, index) => {
212
+ const path = `claims[${index}]`;
213
+ if (!isRecord(raw)) {
214
+ issues.push(`${path} must be an object`);
215
+ return;
216
+ }
217
+ checkKnownKeys(raw, ["id", "feature", "disposition", "summary", "limitation", "evidence"], path, issues);
218
+ const id = checkId(raw.id, `${path}.id`, issues) ? raw.id : "";
219
+ if (id && claimIds.has(id)) issues.push(`${path}.id duplicates ${id}`);
220
+ if (id) claimIds.add(id);
221
+ if (typeof raw.feature !== "string" || !FEATURE_PATTERN.test(raw.feature)) {
222
+ issues.push(`${path}.feature must be a dotted stable feature key`);
223
+ } else if (claimFeatures.has(raw.feature)) {
224
+ issues.push(`${path}.feature duplicates ${raw.feature}`);
225
+ } else {
226
+ claimFeatures.add(raw.feature);
227
+ }
228
+ if (!DISPOSITIONS.has(String(raw.disposition))) {
229
+ issues.push(`${path}.disposition is invalid`);
230
+ }
231
+ checkNonBlank(raw.summary, `${path}.summary`, issues);
232
+ if (raw.disposition !== "passthrough") {
233
+ checkNonBlank(raw.limitation, `${path}.limitation`, issues);
234
+ } else if (raw.limitation !== undefined) {
235
+ issues.push(`${path}.limitation must be omitted for passthrough claims`);
236
+ }
237
+ checkEvidence(raw.evidence, `${path}.evidence`, issues);
238
+ });
239
+ return issues;
240
+ }
241
+
242
+ function deepFreeze<T>(value: T, seen = new WeakSet<object>()): Readonly<T> {
243
+ if (!value || typeof value !== "object" || seen.has(value)) return value;
244
+ seen.add(value);
245
+ for (const nested of Object.values(value as Record<string, unknown>)) deepFreeze(nested, seen);
246
+ return Object.freeze(value);
247
+ }
248
+
249
+ export function defineCompatibilityManifest<T extends CompatibilityManifestV1>(manifest: T): Readonly<T> {
250
+ const issues = compatibilityManifestIssues(manifest);
251
+ if (issues.length > 0) throw new TypeError(`Invalid compatibility manifest ${manifest.id}: ${issues.join("; ")}`);
252
+ return deepFreeze(manifest);
253
+ }
@@ -0,0 +1,81 @@
1
+ import { defineCompatibilityManifest } from "./manifest";
2
+
3
+ const FIXTURE_ID = "openai-codex-forward-gpt56-sol-v1";
4
+
5
+ /**
6
+ * First deliberately narrow compatibility slice: one exact native Codex-login model.
7
+ * Broader model/provider claims require their own fixtures instead of inheriting this row.
8
+ */
9
+ export const OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST = defineCompatibilityManifest({
10
+ schemaVersion: 1,
11
+ id: "openai.codex-forward.gpt-5-6-sol.responses",
12
+ version: "1.0.0",
13
+ subject: {
14
+ providerId: "openai",
15
+ baseUrl: "https://chatgpt.com/backend-api/codex",
16
+ adapterId: "openai-responses",
17
+ authMode: "forward",
18
+ inboundProtocol: "responses",
19
+ upstreamProtocol: "openai-responses",
20
+ modelIds: ["gpt-5.6-sol"],
21
+ },
22
+ claims: [
23
+ {
24
+ id: "custom-tools",
25
+ feature: "request.custom_tools",
26
+ disposition: "passthrough",
27
+ summary: "Native custom-tool declarations retain their Responses representation.",
28
+ evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["custom-tool-preserved"] }],
29
+ },
30
+ {
31
+ id: "prompt-cache-key",
32
+ feature: "request.prompt_cache_key",
33
+ disposition: "passthrough",
34
+ summary: "The caller's prompt cache key is forwarded unchanged.",
35
+ evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["prompt-cache-key-preserved"] }],
36
+ },
37
+ {
38
+ id: "previous-response-id",
39
+ feature: "request.previous_response_id",
40
+ disposition: "translated",
41
+ summary: "Continuation is owned by the proxy rather than the ChatGPT backend field.",
42
+ limitation: "OpenCodex expands remembered input locally and removes previous_response_id before dispatch.",
43
+ evidence: [
44
+ { kind: "fixture", id: FIXTURE_ID, assertionIds: ["previous-response-id-removed"] },
45
+ { kind: "lab-scenario", id: "codex-core.protocol.previous-response-replay" },
46
+ ],
47
+ },
48
+ {
49
+ id: "orphan-tool-output",
50
+ feature: "continuation.orphan_tool_output",
51
+ disposition: "degraded",
52
+ summary: "An output whose prior call is unavailable remains visible to the model.",
53
+ limitation: "The orphan output is converted to user text because the forward backend cannot accept the unmatched item.",
54
+ evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["orphan-output-converted"] }],
55
+ },
56
+ {
57
+ id: "max-output-tokens",
58
+ feature: "request.max_output_tokens",
59
+ disposition: "unsupported",
60
+ summary: "The ChatGPT Codex forward route does not receive max_output_tokens.",
61
+ limitation: "The field is removed before dispatch to avoid a backend parameter rejection.",
62
+ evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["max-output-tokens-removed"] }],
63
+ },
64
+ {
65
+ id: "metadata",
66
+ feature: "request.metadata",
67
+ disposition: "unsupported",
68
+ summary: "Caller metadata is not forwarded to the ChatGPT Codex backend.",
69
+ limitation: "The field is removed before dispatch.",
70
+ evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["metadata-removed"] }],
71
+ },
72
+ {
73
+ id: "prompt-cache-retention",
74
+ feature: "request.prompt_cache_retention",
75
+ disposition: "unsupported",
76
+ summary: "The retired retention field is not sent for gpt-5.6-sol.",
77
+ limitation: "The field is removed without inventing a replacement prompt_cache_options value.",
78
+ evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["prompt-cache-retention-removed"] }],
79
+ },
80
+ ],
81
+ } as const);