@bitkyc08/opencodex 2.52.0-preview.20260911 → 2.52.0-preview.20260912

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 (166) hide show
  1. package/gui/dist/assets/index-D_t6sCWs.js +115 -0
  2. package/gui/dist/assets/index-EdoPnm9_.css +1 -0
  3. package/gui/dist/index.html +2 -2
  4. package/gui/dist/provider-icons/devin.svg +49 -0
  5. package/gui/dist/provider-icons/omo.svg +42 -0
  6. package/package.json +3 -1
  7. package/src/AGENTS.md +1 -1
  8. package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +0 -1
  9. package/src/adapters/command-code.ts +0 -1
  10. package/src/adapters/cursor/checkpoint-store.ts +37 -0
  11. package/src/adapters/cursor/live-transport.ts +74 -2
  12. package/src/adapters/cursor.ts +6 -0
  13. package/src/adapters/devin/cloud-direct/auth.ts +264 -0
  14. package/src/adapters/devin/cloud-direct/catalog.ts +306 -0
  15. package/src/adapters/devin/cloud-direct/chat.ts +1274 -0
  16. package/src/adapters/devin/cloud-direct/index.ts +65 -0
  17. package/src/adapters/devin/cloud-direct/metadata.ts +134 -0
  18. package/src/adapters/devin/cloud-direct/wire.ts +206 -0
  19. package/src/adapters/devin/live-models.ts +133 -0
  20. package/src/adapters/devin-cli/acp.ts +204 -0
  21. package/src/adapters/devin-cli/adapter.ts +345 -0
  22. package/src/adapters/devin-cli/binary.ts +69 -0
  23. package/src/adapters/devin-cli/models.ts +57 -0
  24. package/src/adapters/devin.ts +326 -0
  25. package/src/adapters/google.ts +1 -1
  26. package/src/adapters/openai-chat.ts +31 -10
  27. package/src/adapters/openai-responses.ts +16 -1
  28. package/src/adapters/registry.ts +25 -1
  29. package/src/bridge.ts +8 -2
  30. package/src/claude/inbound-cache-stabilize.ts +130 -0
  31. package/src/claude/inbound.ts +45 -5
  32. package/src/cli/account-auth.ts +60 -1
  33. package/src/cli/account-extended.ts +23 -30
  34. package/src/cli/account.ts +2 -1
  35. package/src/cli/capabilities.ts +50 -8
  36. package/src/cli/config-command.ts +2 -2
  37. package/src/cli/dispatch.ts +14 -2
  38. package/src/cli/export-command.ts +11 -25
  39. package/src/cli/help.ts +2 -2
  40. package/src/cli/opencode.ts +5 -0
  41. package/src/cli/registry.ts +15 -4
  42. package/src/clients/config-export/cline.ts +71 -0
  43. package/src/clients/config-export/contracts.ts +10 -1
  44. package/src/clients/config-export/model-metadata.ts +33 -0
  45. package/src/clients/config-export/zcode.ts +23 -12
  46. package/src/clients/config-export.ts +156 -4
  47. package/src/codex/account-store.ts +7 -2
  48. package/src/codex/auth-context.ts +1 -1
  49. package/src/codex/catalog/effort.ts +4 -3
  50. package/src/codex/catalog/metadata.ts +8 -4
  51. package/src/codex/catalog/native-models.ts +24 -4
  52. package/src/codex/catalog/parsing.ts +19 -1
  53. package/src/codex/catalog/provider-fetch.ts +57 -0
  54. package/src/codex/catalog/sync.ts +1 -1
  55. package/src/codex/context-compat.ts +97 -0
  56. package/src/codex/context-owner.ts +201 -0
  57. package/src/codex/history-provider.ts +161 -14
  58. package/src/codex/inject-coordination.ts +3 -2
  59. package/src/codex/inject.ts +128 -9
  60. package/src/codex/pool-rotation.ts +8 -292
  61. package/src/codex/quota.ts +41 -12
  62. package/src/codex/retired-model-migration.ts +41 -0
  63. package/src/codex/routing.ts +327 -22
  64. package/src/codex/warmup.ts +2 -2
  65. package/src/combos/failover.ts +5 -0
  66. package/src/combos/resolve.ts +11 -15
  67. package/src/config.ts +46 -14
  68. package/src/generated/compatibility-version.json +293 -113
  69. package/src/grok/grpc-web.ts +120 -0
  70. package/src/grok/reset-coupon-ledger.ts +139 -0
  71. package/src/grok/reset-coupons.ts +278 -0
  72. package/src/integrations/catalog-refresh.ts +1 -1
  73. package/src/integrations/cline-document.ts +73 -0
  74. package/src/integrations/cline-io.ts +149 -0
  75. package/src/integrations/cline-transaction.ts +42 -0
  76. package/src/integrations/config-io.ts +21 -2
  77. package/src/integrations/journal.ts +43 -1
  78. package/src/integrations/omp-yaml-source.ts +123 -4
  79. package/src/integrations/ownership-policy.ts +7 -0
  80. package/src/integrations/ownership.ts +36 -0
  81. package/src/integrations/registry.ts +27 -0
  82. package/src/integrations/state.ts +5 -2
  83. package/src/integrations/store.ts +6 -0
  84. package/src/integrations/writer.ts +53 -9
  85. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  86. package/src/lib/abort.ts +36 -0
  87. package/src/lib/local-destinations.ts +1 -1
  88. package/src/lib/upstream-retry.ts +36 -1
  89. package/src/oauth/account-quota-rank.ts +11 -0
  90. package/src/oauth/callback-server.ts +10 -5
  91. package/src/oauth/devin/api-base.ts +63 -0
  92. package/src/oauth/devin/login.ts +1 -0
  93. package/src/oauth/devin/register-user.ts +186 -0
  94. package/src/oauth/devin/types.ts +71 -0
  95. package/src/oauth/devin-cli.ts +149 -0
  96. package/src/oauth/devin.ts +170 -0
  97. package/src/oauth/generic-account-failover.ts +169 -1
  98. package/src/oauth/index.ts +20 -1
  99. package/src/oauth/login-cli.ts +19 -5
  100. package/src/oauth/pool-kernel.ts +321 -0
  101. package/src/oauth/pool-settings-capability.ts +127 -9
  102. package/src/oauth/store.ts +7 -3
  103. package/src/oauth/token-guardian.ts +1 -1
  104. package/src/providers/codebuddy-models.ts +0 -3
  105. package/src/providers/command-code-efforts.ts +23 -4
  106. package/src/providers/default-aliases.ts +4 -0
  107. package/src/providers/derive.ts +8 -0
  108. package/src/providers/devin-cli-authmode-migration.ts +57 -0
  109. package/src/providers/key-failover.ts +175 -0
  110. package/src/providers/model-rename-startup.ts +21 -1
  111. package/src/providers/qoder-models.ts +0 -1
  112. package/src/providers/quota-key-accounts.ts +50 -0
  113. package/src/providers/quota-routing-cache.ts +65 -6
  114. package/src/providers/quota-types.ts +7 -0
  115. package/src/providers/quota.ts +113 -30
  116. package/src/providers/registry.ts +237 -77
  117. package/src/providers/stale-context-window-migration.ts +92 -0
  118. package/src/providers/zai-responses-migration.ts +45 -0
  119. package/src/quota/reset-observer.ts +2 -1
  120. package/src/quota/reset-seen-store.ts +9 -1
  121. package/src/remote-control/crypto.ts +442 -0
  122. package/src/remote-control/host.ts +175 -0
  123. package/src/remote-control/index.ts +100 -0
  124. package/src/remote-control/protocol.ts +200 -0
  125. package/src/remote-control/relay.ts +162 -0
  126. package/src/remote-control/workspace-agent-protocol.ts +246 -0
  127. package/src/remote-control/workspace-rpc-framing.ts +128 -0
  128. package/src/remote-control/workspace-tools.ts +237 -0
  129. package/src/remote-control/workspace-utf8.ts +24 -0
  130. package/src/responses/custom-tool-compat.ts +23 -0
  131. package/src/router.ts +6 -1
  132. package/src/routing/compatibility/behavior.ts +3 -0
  133. package/src/server/adapter-resolve.ts +6 -2
  134. package/src/server/auth-cors.ts +71 -6
  135. package/src/server/chat-completions.ts +5 -3
  136. package/src/server/chat-native.ts +9 -0
  137. package/src/server/claude-messages.ts +25 -30
  138. package/src/server/context-history.ts +207 -0
  139. package/src/server/images.ts +28 -1
  140. package/src/server/index.ts +42 -22
  141. package/src/server/live.ts +2 -1
  142. package/src/server/management/agent-settings-routes.ts +7 -1
  143. package/src/server/management/config-routes.ts +3 -3
  144. package/src/server/management/grok-coupon-routes.ts +287 -0
  145. package/src/server/management/integration-routes.ts +6 -1
  146. package/src/server/management/oauth-account-routes.ts +124 -7
  147. package/src/server/management/provider-routes.ts +77 -2
  148. package/src/server/management/route-registry.ts +6 -1
  149. package/src/server/management-api.ts +7 -0
  150. package/src/server/request-log.ts +4 -0
  151. package/src/server/responses/codex-ws-exchange.ts +90 -14
  152. package/src/server/responses/codex-ws-wire.ts +51 -1
  153. package/src/server/responses/compact.ts +8 -1
  154. package/src/server/responses/core.ts +188 -22
  155. package/src/server/responses/ws-upstream.ts +1 -1
  156. package/src/server/responses-undeclared-tool-guard.ts +261 -12
  157. package/src/server/zai-responses-startup.ts +21 -0
  158. package/src/types/config.ts +33 -3
  159. package/src/types/provider.ts +54 -4
  160. package/src/types/request.ts +1 -1
  161. package/src/types/tools.ts +36 -6
  162. package/src/update/job.ts +33 -11
  163. package/src/vision/plan.ts +40 -37
  164. package/src/web-search/index.ts +1 -0
  165. package/gui/dist/assets/index-BoBRSehJ.css +0 -1
  166. package/gui/dist/assets/index-Dx0xv2EA.js +0 -115
@@ -0,0 +1,71 @@
1
+ import type { ExportContext, ManagedContribution } from "./contracts";
2
+ import { LOOPBACK_API_KEY_PLACEHOLDER, OPENCODE_PROVIDER_ID } from "./constants";
3
+ import { authoritativeContextWindow, exportModelLabel, inputModalitiesForClient, normalizeExportModels } from "./model-metadata";
4
+
5
+ /** Cline CLI/shared SDK schema at cline/cline cfe9cadab996 (2026-09-12). */
6
+ export interface ClineModel {
7
+ name: string;
8
+ contextWindow?: number;
9
+ modalities?: { input: string[]; output: string[] };
10
+ supportsVision?: boolean;
11
+ }
12
+
13
+ export function buildClineClientConfig(ctx: ExportContext) {
14
+ const models: Record<string, ClineModel> = {};
15
+ for (const model of normalizeExportModels(ctx.models)) {
16
+ const input = inputModalitiesForClient("pi", model.inputModalities);
17
+ if (input === null) continue;
18
+ const contextWindow = authoritativeContextWindow(model.contextWindow);
19
+ models[model.namespaced] = {
20
+ name: exportModelLabel(model),
21
+ ...(contextWindow === undefined ? {} : { contextWindow }),
22
+ ...(model.inputModalities?.length ? {
23
+ modalities: { input, output: ["text"] },
24
+ supportsVision: input.includes("image"),
25
+ } : {}),
26
+ };
27
+ }
28
+ const connection = { baseUrl: ctx.baseUrl, protocol: "openai-responses", client: "openai" } as const;
29
+ return {
30
+ settings: {
31
+ version: 1,
32
+ modes: {},
33
+ providers: {
34
+ [OPENCODE_PROVIDER_ID]: {
35
+ settings: { provider: OPENCODE_PROVIDER_ID, ...connection, apiKey: LOOPBACK_API_KEY_PLACEHOLDER },
36
+ // Required by Cline's schema; a stable generation sentinel keeps export deterministic.
37
+ // The operation's actual time belongs to the OpenCodex journal.
38
+ updatedAt: "1970-01-01T00:00:00.000Z",
39
+ tokenSource: "manual",
40
+ },
41
+ },
42
+ },
43
+ catalog: {
44
+ version: 1,
45
+ providers: {
46
+ [OPENCODE_PROVIDER_ID]: {
47
+ provider: { name: "OpenCodex", ...connection },
48
+ models,
49
+ },
50
+ },
51
+ },
52
+ };
53
+ }
54
+
55
+ export type ClineGeneratedConfig = ReturnType<typeof buildClineClientConfig>;
56
+
57
+ export function summarizeCline(document: unknown) {
58
+ const models = Object.values((document as ClineGeneratedConfig | undefined)?.catalog?.providers?.[OPENCODE_PROVIDER_ID]?.models ?? {});
59
+ return { modelCount: models.length, modelsWithoutLimits: models.filter(model => model.contextWindow === undefined).length };
60
+ }
61
+
62
+ export function buildClineContribution(ctx: ExportContext): ManagedContribution {
63
+ const document = buildClineClientConfig(ctx);
64
+ return {
65
+ clientId: "cline",
66
+ fragments: [
67
+ { path: ["settings", "providers", OPENCODE_PROVIDER_ID], value: document.settings.providers[OPENCODE_PROVIDER_ID] },
68
+ { path: ["catalog", "providers", OPENCODE_PROVIDER_ID], value: document.catalog.providers[OPENCODE_PROVIDER_ID] },
69
+ ],
70
+ };
71
+ }
@@ -38,6 +38,13 @@ export interface OpencodeCatalogModel {
38
38
  id?: string;
39
39
  contextWindow?: number;
40
40
  displayName?: string;
41
+ /**
42
+ * Declared input modalities, carried verbatim from `/api/models`. Serialized as opencode's
43
+ * per-model `attachment` + `modalities`, because opencode gates attachments CLIENT-side:
44
+ * without them every `opencodex` model is text-only in its picker and an image never
45
+ * reaches the proxy or the vision sidecar (#4286).
46
+ */
47
+ inputModalities?: readonly string[];
41
48
  /** Declared effort ladder. Exported as opencode model variants where the client reads them. */
42
49
  reasoningEfforts?: readonly string[];
43
50
  /**
@@ -94,7 +101,9 @@ export type ExportClientId =
94
101
  | "zcode"
95
102
  | "prime"
96
103
  | "aside"
97
- | "raycast";
104
+ | "raycast"
105
+ | "omo"
106
+ | "cline";
98
107
 
99
108
  export interface ExportClientSpec {
100
109
  id: ExportClientId;
@@ -72,6 +72,39 @@ export function inputModalitiesForClient(
72
72
  return kept.length > 0 ? kept : null;
73
73
  }
74
74
 
75
+ /**
76
+ * Input modalities opencode's model schema accepts (opencode.ai/config.json, both
77
+ * `modalities.input` and `modalities.output`). Wider than our internal `text | image | audio`
78
+ * vocabulary, so unlike Pi and Gajae this filter can only drop a value no current ingress
79
+ * produces: `/api/custom-models`, `ocx models add` and the catalog writer all normalize to
80
+ * the internal three. It exists so a future ingress cannot do to opencode what `audio` did
81
+ * to Gajae, whose loader rejected the whole config file over one out-of-enum value.
82
+ */
83
+ const OPENCODE_INPUT_MODALITIES: ReadonlySet<string> = new Set(["text", "audio", "image", "video", "pdf"]);
84
+
85
+ /**
86
+ * opencode's per-model capability fields for one catalog row, or `undefined` when the row
87
+ * declares nothing.
88
+ *
89
+ * `undefined` rather than `{ input: ["text"] }`: opencode already computes an entry without
90
+ * capabilities as text-only, and leaving the keys out keeps every model that declares
91
+ * nothing byte-identical to what shipped before. A declared list is carried across as-is, so
92
+ * an audio-only row keeps `attachment: true` instead of being rewritten to text it cannot
93
+ * read — the same call Pi's exporter makes, in the opposite direction.
94
+ */
95
+ export function opencodeModelCapabilities(
96
+ modalities: readonly string[] | undefined,
97
+ ): { attachment: boolean; modalities: { input: string[]; output: string[] } } | undefined {
98
+ const input: string[] = [];
99
+ for (const value of modalities ?? []) {
100
+ if (OPENCODE_INPUT_MODALITIES.has(value) && !input.includes(value)) input.push(value);
101
+ }
102
+ if (input.length === 0) return undefined;
103
+ // `attachment` is what opencode's client gates pasting on; `modalities` refines it into
104
+ // which kinds. Output is always text — nothing in the catalog declares otherwise.
105
+ return { attachment: input.some(value => value !== "text"), modalities: { input, output: ["text"] } };
106
+ }
107
+
75
108
  /**
76
109
  * Label shared by every client: `"<displayName|id> (<native|provider|routed>)"`. The
77
110
  * provider suffix is what makes two same-named models from different upstreams
@@ -6,9 +6,18 @@ import { OPENCODE_PROVIDER_ID, LOOPBACK_API_KEY_PLACEHOLDER } from "./constants"
6
6
 
7
7
 
8
8
  /**
9
- * ZCode's `~/.zcode/v2/config.json` provider entry (observed schema, validated
10
- * live against ZCode 3.7.7 / 3.8.1). `kind: "openai-compatible"` selects the
11
- * OpenAI Chat Completions protocol, which the proxy serves at `/v1/chat/completions`.
9
+ * ZCode's `~/.zcode/v2/config.json` provider entry (observed schema, validated live
10
+ * against ZCode 3.7.7 / 3.8.1 and re-extracted from 3.11.2's bundle).
11
+ * `kind: "openai"` selects the OpenAI Responses protocol, which the proxy serves at
12
+ * `/v1/responses`. ZCode's own dispatch is the authority: its
13
+ * `getDefaultModelProviderEndpointPathForKind` maps `anthropic` to `/v1/messages`,
14
+ * `openai` to `/responses`, and `openai-compatible` to `/chat/completions`.
15
+ *
16
+ * Responses is the only surface the proxy speaks natively. The Chat and Anthropic
17
+ * inbounds translate their body into a Responses shape and replay it through
18
+ * `handleResponses`, then translate the stream back, so the previous
19
+ * `openai-compatible` wiring paid two translations per turn and reshaped tool-call
20
+ * deltas and reasoning blocks on the way through.
12
21
  * `apiKeyRequired` keeps ZCode's UI from prompting for a key it does not need on
13
22
  * loopback; the serialized key is always the non-secret loopback placeholder.
14
23
  */
@@ -30,7 +39,7 @@ export interface ZcodeModelEntry {
30
39
 
31
40
  export interface ZcodeProviderBlock {
32
41
  name: "OpenCodex";
33
- kind: "openai-compatible";
42
+ kind: "openai";
34
43
  enabled: true;
35
44
  source: "custom";
36
45
  options: {
@@ -46,11 +55,12 @@ export interface ZcodeGeneratedConfig {
46
55
  }
47
56
 
48
57
  /**
49
- * ZCode dials the OpenAI Chat Completions surface (`openai-compatible`), which
50
- * appends `/chat/completions` to `baseURL`. We supply `baseURL` with the `/v1`
51
- * suffix so requests land on `/v1/chat/completions`. Model ids are the proxy's canonical
52
- * `provider/id` selectors, which `/v1/chat/completions` resolves directly. Context
53
- * limits follow the authoritative-window rule: a model without one ships
58
+ * ZCode dials the OpenAI Responses surface (`openai`), which appends `/responses` to
59
+ * `baseURL`. We supply `baseURL` with the `/v1` suffix so requests land on
60
+ * `/v1/responses`; ZCode's `normalizeModelProviderBaseUrlForKind` strips only the
61
+ * `/responses` suffix for this kind, so the `/v1` root survives. Model ids are the
62
+ * proxy's canonical `provider/id` selectors, which `/v1/responses` resolves directly.
63
+ * Context limits follow the authoritative-window rule: a model without one ships
54
64
  * without `limit` rather than guessing. Modalities are ZCode's observed
55
65
  * `text`-floor vocabulary; image-capable rows advertise image input.
56
66
  */
@@ -73,8 +83,9 @@ export function buildZcodeClientConfig(ctx: ExportContext): ZcodeGeneratedConfig
73
83
  entry.limit = { context };
74
84
  }
75
85
  // `none` is a Codex omit-sentinel, not a ZCode picker option. Keep catalog
76
- // `ultra` when present: ZCode forwards the selected variant as
77
- // `reasoning_effort`. Set `defaultVariant` only when it survives that filter.
86
+ // `ultra` when present: ZCode forwards the selected variant to the wire field its
87
+ // kind uses — `reasoning.effort` on `openai`, which is what `/v1/responses` reads
88
+ // natively. Set `defaultVariant` only when it survives that filter.
78
89
  const efforts = sanitizeCodexReasoningEfforts(model.reasoningEfforts)
79
90
  ?.filter(effort => effort !== "none");
80
91
  if (efforts && efforts.length > 0) {
@@ -91,7 +102,7 @@ export function buildZcodeClientConfig(ctx: ExportContext): ZcodeGeneratedConfig
91
102
  provider: {
92
103
  [OPENCODE_PROVIDER_ID]: {
93
104
  name: "OpenCodex",
94
- kind: "openai-compatible",
105
+ kind: "openai",
95
106
  enabled: true,
96
107
  source: "custom",
97
108
  options: {
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import { homedir } from "node:os";
23
23
  import { existsSync, readFileSync } from "node:fs";
24
- import { isAbsolute, join, resolve } from "node:path";
24
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
25
25
  import { shouldInjectApiAuthHeader, standaloneCodexRoutingTarget } from "../codex/inject";
26
26
  import { FORMAT_MEDIA_TYPE, serializeDocument, type ConfigFormat } from "../integrations/serialize";
27
27
  import { canonicalizeReasoningEfforts } from "../reasoning-effort";
@@ -42,11 +42,12 @@ export { buildRaycastClientConfig, summarizeRaycast, buildRaycastContribution }
42
42
 
43
43
  import type { OpencodeLaunchEnv, OpencodeCatalogModel, ExportContext, PiModelEntry, ManagedContribution, ManagedFragment, ExportClientId, ExportClientSpec } from "./config-export/contracts";
44
44
  import { OPENCODE_API_KEY_ENV_REF, OPENCODE_PROVIDER_BLOCK_DEFAULT_CONFIG, OPENCODE_CONFIG_SCHEMA, OPENCODE_PROVIDER_ID, PI_API_DIALECT, LOOPBACK_API_KEY_PLACEHOLDER, HERMES_API_KEY_ENV_REF, OPENCLAW_API_KEY_ENV_REF, GAJAE_API_KEY_ENV, OPENCODE_API_KEY_ENV, HERMES_API_KEY_ENV, OPENCLAW_API_KEY_ENV } from "./config-export/constants";
45
- import { exportModelLabel, authoritativeContextWindow, outputBudgetFor, normalizeExportModels, inputModalitiesForClient, proxyAdmissionHeaders, singleFragment } from "./config-export/model-metadata";
45
+ import { exportModelLabel, authoritativeContextWindow, outputBudgetFor, normalizeExportModels, inputModalitiesForClient, opencodeModelCapabilities, proxyAdmissionHeaders, singleFragment } from "./config-export/model-metadata";
46
46
  import { buildOmpClientConfig, summarizeOmp, buildOmpContribution } from "./config-export/omp";
47
47
  import { buildDshClientConfig, summarizeDsh, buildDshContribution } from "./config-export/dsh";
48
48
  import { buildMcodeClientConfig, summarizeMcode, buildMcodeContribution } from "./config-export/mcode";
49
49
  import { buildZcodeClientConfig, summarizeZcode, buildZcodeContribution } from "./config-export/zcode";
50
+ import { buildClineClientConfig, summarizeCline, buildClineContribution } from "./config-export/cline";
50
51
  import { buildRaycastClientConfig, summarizeRaycast, buildRaycastContribution } from "./config-export/raycast";
51
52
 
52
53
 
@@ -54,6 +55,14 @@ import { buildRaycastClientConfig, summarizeRaycast, buildRaycastContribution }
54
55
  export interface OpencodeModelEntry {
55
56
  name: string;
56
57
  limit?: { context: number; output: number };
58
+ /**
59
+ * opencode's own capability fields, derived from the catalog row's declared input
60
+ * modalities. Written only when the row declares at least one — an entry without them is
61
+ * what opencode already treats as text-only, and omitting them keeps an undeclared model
62
+ * byte-identical to what shipped before.
63
+ */
64
+ attachment?: boolean;
65
+ modalities?: { input: string[]; output: string[] };
57
66
  }
58
67
 
59
68
  /**
@@ -458,6 +467,51 @@ export function primeConfigPath(env: OpencodeLaunchEnv = process.env, home: stri
458
467
  return join(primeAgentDir(env, home), "models.json");
459
468
  }
460
469
 
470
+ /**
471
+ * omo resolves its agent directory from THREE variables, in its own order:
472
+ * `OMO_CODING_AGENT_DIR`, then `SENPI_CODING_AGENT_DIR`, then
473
+ * `PI_CODING_AGENT_DIR`, falling back to `~/.omo/agent`. That is not an
474
+ * inference from the family resemblance — `bin/lib/agent-dir.js` publishes the
475
+ * list as `AGENT_DIR_ENV_NAMES` and the launcher pins the first two to whatever
476
+ * it resolves before spawning senpi, so the engine can never disagree with it.
477
+ *
478
+ * The order is load-bearing rather than cosmetic. A user with both
479
+ * `PI_CODING_AGENT_DIR` and `OMO_CODING_AGENT_DIR` set runs omo out of the omo
480
+ * one; checking Pi's first would have us write a catalog omo never reads.
481
+ *
482
+ * Each variable reports under its OWN name, because telling someone that
483
+ * `PI_CODING_AGENT_DIR` is relative when they set `OMO_CODING_AGENT_DIR` sends
484
+ * them to the wrong line of their shell profile. An empty or whitespace value
485
+ * falls through to the next name, which is what omo's own `.trim()`-then-test
486
+ * loop does.
487
+ *
488
+ * One divergence is deliberate: omo `resolve()`s a relative override against its
489
+ * own cwd and does not expand `~`. We refuse the relative form and do expand
490
+ * `~`, exactly as Pi, Prime, MCode and ZCode already do, because a background
491
+ * proxy and a foreground client have different working directories and would
492
+ * otherwise disagree about which file is named.
493
+ *
494
+ * Consequence worth knowing: a user who sets only `PI_CODING_AGENT_DIR` has Pi
495
+ * and omo reading ONE `models.json`. Both clients emit the same provider block
496
+ * through the same builder so the bytes agree; what cannot be shared is the
497
+ * ownership record, since two enabled clients would claim one file. That is
498
+ * omo's contract, not ours to paper over.
499
+ */
500
+ export function omoAgentDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
501
+ const omo = env.OMO_CODING_AGENT_DIR?.trim();
502
+ if (omo) return absoluteClientPath(omo, home, "OMO_CODING_AGENT_DIR");
503
+ const senpi = env.SENPI_CODING_AGENT_DIR?.trim();
504
+ if (senpi) return absoluteClientPath(senpi, home, "SENPI_CODING_AGENT_DIR");
505
+ const pi = env.PI_CODING_AGENT_DIR?.trim();
506
+ if (pi) return absoluteClientPath(pi, home, "PI_CODING_AGENT_DIR");
507
+ return join(home, ".omo", "agent");
508
+ }
509
+
510
+ /** omo's canonical custom-provider catalog, read by the senpi engine it wraps. */
511
+ export function omoConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
512
+ return join(omoAgentDir(env, home), "models.json");
513
+ }
514
+
461
515
  /**
462
516
  * Aside's state root. Unlike every other client here, Aside ships NO variable
463
517
  * that relocates it: its CLI carries `ASIDE_DAEMON_BASE_URL`,
@@ -614,13 +668,30 @@ export function opencodeProviderBlocks(
614
668
  if (context !== undefined) {
615
669
  entry.limit = { context, output: outputBudgetFor(context) };
616
670
  }
671
+ // `attachment` / `modalities` are fields of opencode's V1 model schema — the shape its
672
+ // published config.json defines and the one its loader reads (verified against opencode
673
+ // 1.18.30, src/provider/provider.ts: `model.attachment ?? …` / `model.modalities?.input`).
674
+ // They ride on both generations anyway: the two blocks are two spellings of one model list,
675
+ // and the V2 model schema (capabilities.{tools,input,output}, which opencode fills by
676
+ // migrating this same `modalities` field) ignores keys it does not define — its loader
677
+ // decodes with `onExcessProperty: "ignore"`. Same values on both, so a merge cannot make
678
+ // the two entries disagree.
679
+ const capabilities = opencodeModelCapabilities(model.inputModalities);
680
+ if (capabilities) {
681
+ entry.attachment = capabilities.attachment;
682
+ entry.modalities = capabilities.modalities;
683
+ }
617
684
  v1Models[key] = entry;
618
685
  const variants = opencodeEffortVariants(model);
619
- // Own `limit` object, not a shared reference: the two blocks are serialized and reasoned
620
- // about separately, and an in-place edit of one must never move the other.
686
+ // Own `limit` and `modalities` objects, not shared references: the two blocks are
687
+ // serialized and reasoned about separately, and an in-place edit of one must never move
688
+ // the other.
621
689
  v2Models[key] = {
622
690
  ...entry,
623
691
  ...(entry.limit ? { limit: { ...entry.limit } } : {}),
692
+ ...(entry.modalities
693
+ ? { modalities: { input: [...entry.modalities.input], output: [...entry.modalities.output] } }
694
+ : {}),
624
695
  ...(variants ? { variants } : {}),
625
696
  };
626
697
  }
@@ -1109,6 +1180,44 @@ function buildAsideContribution(ctx: ExportContext): ManagedContribution {
1109
1180
  return singleFragment("aside", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]);
1110
1181
  }
1111
1182
 
1183
+ /**
1184
+ * omo is the Pi document again, and this time the engine was checked rather
1185
+ * than inferred: `omo-ai@beta` is a launcher around `@code-yeongyu/senpi`, and
1186
+ * senpi's compiled validator accepts what `buildPiClientConfig` emits verbatim —
1187
+ * the keyed `providers`, the `models` ARRAY whose identity is `id`, the
1188
+ * `openai-completions` dialect, the loopback placeholder, and the
1189
+ * `thinkingLevelMap` levels. Evidence:
1190
+ * `devlog/_plan/260912_omo_client_integration/001_omo_contract.md`.
1191
+ *
1192
+ * The flag is passed HERE as well as in the spec's `build`, which is the one
1193
+ * thing Prime and Aside do not do. They pass the default on both paths, so they
1194
+ * are consistent; passing it on only one would make `ocx export --client omo`
1195
+ * emit a `compat` block while enable and refresh wrote a file without it, and
1196
+ * the two would drift apart at the first refresh.
1197
+ */
1198
+ function buildOmoContribution(ctx: ExportContext): ManagedContribution {
1199
+ const doc = buildPiClientConfig(ctx, true);
1200
+ return singleFragment("omo", ["providers", OPENCODE_PROVIDER_ID], doc.providers[OPENCODE_PROVIDER_ID]);
1201
+ }
1202
+
1203
+ /** Cline's shared SDK store; command-local --config must be mirrored by the env override. */
1204
+ export function clineConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
1205
+ const explicit = env.CLINE_PROVIDER_SETTINGS_PATH?.trim();
1206
+ const data = env.CLINE_DATA_DIR?.trim();
1207
+ const root = env.CLINE_DIR?.trim();
1208
+ const path = explicit ? absoluteClientPath(explicit, home, "CLINE_PROVIDER_SETTINGS_PATH")
1209
+ : join(data ? absoluteClientPath(data, home, "CLINE_DATA_DIR")
1210
+ : join(root ? absoluteClientPath(root, home, "CLINE_DIR") : join(home, ".cline"), "data"), "settings", "providers.json");
1211
+ if (basename(path).toLowerCase() === "models.json") {
1212
+ throw new ClientPathError("CLINE_PROVIDER_SETTINGS_PATH must differ from the sibling models.json catalog");
1213
+ }
1214
+ return path;
1215
+ }
1216
+
1217
+ export function clineSettingsDir(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string {
1218
+ return dirname(clineConfigPath(env, home));
1219
+ }
1220
+
1112
1221
  export const EXPORT_CLIENTS: Record<ExportClientId, ExportClientSpec> = {
1113
1222
  opencode: {
1114
1223
  id: "opencode",
@@ -1297,6 +1406,49 @@ export const EXPORT_CLIENTS: Record<ExportClientId, ExportClientSpec> = {
1297
1406
  // remote bind would be a plaintext secret on disk. Refuse instead.
1298
1407
  loopbackOnly: true,
1299
1408
  },
1409
+ /*
1410
+ * Appended rather than filed beside the other Pi-family clients on purpose.
1411
+ * `EXPORT_CLIENT_IDS` is `Object.keys(EXPORT_CLIENTS)`, so this object's
1412
+ * insertion order IS the public order, and three tests assert it exactly. The
1413
+ * existing sequence is landing order — `pi` second, `prime` eleventh — not a
1414
+ * grouping, so appending is the edit that leaves the other thirteen alone.
1415
+ */
1416
+ omo: {
1417
+ id: "omo",
1418
+ // Not a bare `models.json`: same Downloads-folder collision argument as
1419
+ // `prime-models.json` and `aside-models.json`.
1420
+ filename: "omo-models.json",
1421
+ destination: env => omoConfigPath(env),
1422
+ apiKeyEnv: "",
1423
+ exportHint: "omo reads a non-secret placeholder from models.json; loopback needs no key.",
1424
+ build: ctx => buildPiClientConfig(ctx, true),
1425
+ format: "json",
1426
+ summarize: summarizePi,
1427
+ buildContribution: buildOmoContribution,
1428
+ /*
1429
+ * Loopback-only for OMP's and Prime's reason, NOT Pi's and Aside's. senpi's
1430
+ * provider block does accept a `headers` map and does interpolate `$ENV` in
1431
+ * its values, so unlike Aside there is somewhere the dedicated admission
1432
+ * header could live. What does not exist is a builder that emits one:
1433
+ * `buildPiClientConfig` writes no headers at all, which is why `pi` is
1434
+ * loopback-only too. Teaching the shared builder to emit them would change
1435
+ * four clients at once, so remote wiring is deferred and a non-loopback bind
1436
+ * refuses rather than generating a config that 401s.
1437
+ */
1438
+ loopbackOnly: true,
1439
+ },
1440
+ cline: {
1441
+ id: "cline",
1442
+ filename: "cline-config-bundle.json",
1443
+ destination: env => clineConfigPath(env),
1444
+ apiKeyEnv: "",
1445
+ exportHint: "Cline CLI bundle: settings goes in providers.json, catalog in sibling models.json. Stop Cline before enabling/syncing/restoring, then restart. Select --provider opencodex; the default provider stays unchanged. Loopback only.",
1446
+ build: buildClineClientConfig,
1447
+ format: "json",
1448
+ summarize: summarizeCline,
1449
+ buildContribution: buildClineContribution,
1450
+ loopbackOnly: true,
1451
+ },
1300
1452
  };
1301
1453
 
1302
1454
  export const EXPORT_CLIENT_IDS: readonly ExportClientId[] = Object.keys(EXPORT_CLIENTS) as ExportClientId[];
@@ -751,8 +751,13 @@ export async function forceRefreshCodexPoolToken(
751
751
  };
752
752
  }
753
753
 
754
- export async function getValidCodexToken(id: string): Promise<CodexTokenResult> {
755
- const result = await resolveCodexToken(id);
754
+ export async function getValidCodexToken(
755
+ id: string,
756
+ options: { signal?: AbortSignal } = {},
757
+ ): Promise<CodexTokenResult> {
758
+ // Cancellation ends THIS caller's wait. A shared refresh already in flight keeps running for
759
+ // whoever else awaits it, which is what `awaitOwnCancellation` inside the resolver preserves.
760
+ const result = await resolveCodexToken(id, undefined, options.signal);
756
761
  return {
757
762
  accessToken: result.accessToken,
758
763
  chatgptAccountId: result.chatgptAccountId,
@@ -1023,7 +1023,7 @@ export async function resolveCodexAuthContext(
1023
1023
  }
1024
1024
 
1025
1025
  try {
1026
- const token = await getValidCodexToken(accountId);
1026
+ const token = await getValidCodexToken(accountId, { signal: options.signal });
1027
1027
  assertCodexAccountValidationReady(accountId);
1028
1028
  return {
1029
1029
  kind: "pool",
@@ -59,9 +59,10 @@ export function nativeEffortClamp(slug: string, effort: string | undefined): str
59
59
  : [];
60
60
  if (levels.length === 0) {
61
61
  // Not snapshot-covered. gpt-5.6 natives have a REAL max rung (ensureGpt56ReasoningLevels
62
- // restores it even off-snapshot) -> never clamp. Every other bare native (gpt-5.5/5.4/
63
- // 5.4-mini/5.3-codex-spark and future old-ladder slugs) really stops at xhigh — the
64
- // ChatGPT backend error names exactly none..xhigh — so clamp the synthetic top tier.
62
+ // restores it even off-snapshot) -> never clamp. Every other bare native (gpt-5.5,
63
+ // 5.3-codex-spark, a retired slug a client still asks for, and future old-ladder slugs)
64
+ // really stops at xhigh — the ChatGPT backend error names exactly none..xhigh — so clamp
65
+ // the synthetic top tier.
65
66
  return isGpt56NativeSlug(slug) ? null : "xhigh";
66
67
  }
67
68
  const supported = levels.flatMap(l => typeof l.effort === "string" ? [l.effort] : []);
@@ -48,6 +48,7 @@ import {
48
48
  NATIVE_OPENAI_MODELS,
49
49
  SELF_DESCRIBED_NATIVE_OPENAI_MODELS,
50
50
  SUPPORTED_NATIVE_OPENAI_SLUGS,
51
+ RETIRED_NATIVE_OPENAI_MODELS,
51
52
  hasNativeOpenAiCapabilityMetadata,
52
53
  isNativeOpenAiCapabilityAliasModel,
53
54
  nativeOpenAiAliasPresentation,
@@ -63,6 +64,7 @@ export {
63
64
  NATIVE_OPENAI_MODELS,
64
65
  SELF_DESCRIBED_NATIVE_OPENAI_MODELS,
65
66
  SUPPORTED_NATIVE_OPENAI_SLUGS,
67
+ RETIRED_NATIVE_OPENAI_MODELS,
66
68
  hasNativeOpenAiCapabilityMetadata,
67
69
  isNativeOpenAiCapabilityAliasModel,
68
70
  nativeOpenAiAliasPresentation,
@@ -120,8 +122,7 @@ export function isUnsupportedOpenAiNativeSlug(slug: string): boolean {
120
122
  *
121
123
  * This is an OPERATING CAP, not the hard ceiling — the same shape upstream uses. The live
122
124
  * catalog reports `context_window: 272000` against a `max_context_window: 872000` for these
123
- * slugs, and gpt-5.4 runs 272,000 against 1,000,000: the advertised window is always well
124
- * inside what the model can take.
125
+ * slugs: the advertised window is always well inside what the model can take.
125
126
  *
126
127
  * The hard ceiling here was measured on 2026-08-17 against a real Codex-login account:
127
128
  * `POST /backend-api/codex/responses` admitted 921,508 input tokens and refused 922,013 with
@@ -162,7 +163,6 @@ const NATIVE_GPT56_FAMILY = new Set<string>([
162
163
 
163
164
  export const NATIVE_OPENAI_CONTEXT_OVERRIDES: Record<string, { contextWindow?: number; maxContextWindow?: number; maxInputTokens?: number }> = {
164
165
  "gpt-5.5": { contextWindow: 272_000, maxContextWindow: 272_000 },
165
- "gpt-5.4": { contextWindow: 1_000_000, maxContextWindow: 1_000_000 },
166
166
  "gpt-5.3-codex-spark": { contextWindow: 100_000, maxContextWindow: 100_000 },
167
167
  "gpt-5.6-sol": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_MAX_INPUT_TOKENS, maxInputTokens: NATIVE_GPT56_MAX_INPUT_TOKENS },
168
168
  "gpt-5.6-terra": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_MAX_INPUT_TOKENS, maxInputTokens: NATIVE_GPT56_MAX_INPUT_TOKENS },
@@ -537,7 +537,7 @@ function upstreamNativeEntryForSlug(slug: string): RawEntry | undefined {
537
537
  const sourceSlug = nativeOpenAiCapabilitySourceSlug(slug);
538
538
  // A self-described native returns its OWN pinned row; the alias-cloning branch below stays
539
539
  // reserved for slugs that genuinely borrow another model's identity. The allowlist is explicit
540
- // rather than "has a pinned entry", which would also admit gpt-5.5/gpt-5.4/gpt-5.4-mini into
540
+ // rather than "has a pinned entry", which would also admit gpt-5.5/gpt-5.2/codex-auto-review into
541
541
  // the sync-replacement authority this map carries.
542
542
  if (!sourceSlug.startsWith("gpt-5.6-") && !SELF_DESCRIBED_NATIVE_OPENAI_MODELS.has(slug)) {
543
543
  return undefined;
@@ -696,6 +696,10 @@ function hasNativeCatalogRowShape(entry: RawEntry): boolean {
696
696
  function observedAccountBoundNativeSlug(entry: RawEntry): string | undefined {
697
697
  const accountBound = trustedAccountBoundNativeCatalogSlug(entry);
698
698
  const slug = accountBound ?? (typeof entry.slug === "string" ? entry.slug : "");
699
+ // A retired native is refused at this one choke point rather than at each call site: every
700
+ // observation path funnels through here, and an admitted retired slug comes back as a real
701
+ // catalog row — bare or account-qualified — with capabilities synthesized from the template.
702
+ if (RETIRED_NATIVE_OPENAI_MODELS.has(slug)) return undefined;
699
703
  if (!isAccountBoundOpenAiNativeSlug(slug)
700
704
  || (entry.supported_in_api !== true && !(slug === NATIVE_RESERVE_MODEL && entry.supported_in_api === false))
701
705
  || (slug === NATIVE_RESERVE_MODEL && entry[RESERVE_METADATA_SOURCE_FIELD] !== undefined
@@ -72,9 +72,11 @@ const NATIVE_OPENAI_CAPABILITY_SOURCES: Readonly<Record<string, string>> = Objec
72
72
  *
73
73
  * Membership authorizes `upstreamNativeEntryForSlug` to return the pinned entry directly. It is
74
74
  * an explicit list, not a structural `PINNED_UPSTREAM_MODELS.has(slug)` predicate: the pin also
75
- * holds `gpt-5.5`, `gpt-5.4` and `gpt-5.4-mini`, and admitting those into
75
+ * holds `gpt-5.5`, `gpt-5.2` and `codex-auto-review`, and admitting those into
76
76
  * `UPSTREAM_NATIVE_ENTRIES` would newly authorize replacing their persisted catalog rows during
77
- * sync — an invariant that map's own comment reserves for the GPT-5.6 family.
77
+ * sync — an invariant that map's own comment reserves for the GPT-5.6 family. The snapshot
78
+ * keeps rows this runtime does not expose, which is exactly why presence in the pin cannot be
79
+ * the predicate: `gpt-5.4` and `gpt-5.4-mini` are still pinned after their retirement.
78
80
  */
79
81
  export const SELF_DESCRIBED_NATIVE_OPENAI_MODELS: ReadonlySet<string> = new Set([
80
82
  NATIVE_GPT6_ASTRA_MODEL,
@@ -153,7 +155,7 @@ export function nativeOpenAiAliasPresentation(slug: string): { displayName: stri
153
155
  * Devlog: 260816_codexrs_multiagent_v2_and_history_perf/011 §4-bis.
154
156
  */
155
157
  export const NATIVE_OPENAI_MODELS = [
156
- "gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark",
158
+ "gpt-5.5", "gpt-5.3-codex-spark",
157
159
  "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna",
158
160
  NATIVE_DAYBREAK_BLUE_MODEL,
159
161
  NATIVE_GPT6_ASTRA_MODEL,
@@ -161,6 +163,24 @@ export const NATIVE_OPENAI_MODELS = [
161
163
 
162
164
  export const SUPPORTED_NATIVE_OPENAI_SLUGS = new Set(NATIVE_OPENAI_MODELS);
163
165
 
166
+ /**
167
+ * Natives this runtime used to ship that upstream has since retired.
168
+ *
169
+ * Leaving `NATIVE_OPENAI_MODELS` is not enough on its own. An account-bound observation admits
170
+ * any native it sees that is NOT already in `SUPPORTED_NATIVE_OPENAI_SLUGS` — that is how a
171
+ * genuinely new upstream model reaches one entitled account before this repo knows about it. A
172
+ * retired slug fails that same membership test, so a stale `selector/gpt-5.4` row persisted in a
173
+ * user's catalog or models cache would be re-observed as an unknown native and synthesized
174
+ * straight back into the picker, one sync after the removal took it out.
175
+ *
176
+ * This set is the difference between the two cases: unknown-and-new is admitted, known-and-dead
177
+ * is refused. It is deliberately explicit rather than a version heuristic, because the only
178
+ * thing that makes a slug retired is upstream withdrawing it.
179
+ */
180
+ export const RETIRED_NATIVE_OPENAI_MODELS: ReadonlySet<string> = new Set([
181
+ "gpt-5.4", "gpt-5.4-mini",
182
+ ]);
183
+
164
184
  /**
165
185
  * Natives that retain the physical main account as a read-free sentinel during a native-main
166
186
  * drain, instead of reading as unavailable and letting the subagent fallback chain advance.
@@ -172,7 +192,7 @@ export const SUPPORTED_NATIVE_OPENAI_SLUGS = new Set(NATIVE_OPENAI_MODELS);
172
192
  * flipped false — letting a drain silently rewrite the operator's configured subagent model.
173
193
  *
174
194
  * It is an explicit list rather than `SUPPORTED_NATIVE_OPENAI_SLUGS`, which would have widened
175
- * the sentinel to `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini` and `gpt-5.3-codex-spark` as well. Those
195
+ * the sentinel to `gpt-5.5` and `gpt-5.3-codex-spark` as well. Those
176
196
  * models were never covered, and widening would turn "fell back and answered" into a
177
197
  * maintenance error for the most commonly configured fallback slug in the repo. Membership is
178
198
  * the set the drain behaviour was actually reasoned about: the account-gated natives plus the
@@ -183,6 +183,23 @@ export const ROUTED_MODEL_COMPATIBILITY_EXCLUSIONS = new Set([
183
183
  // Issue #2330: OpenCode Go models absent from current documentation or returning terminal HTTP 400 errors.
184
184
  "opencode-go/mimo-v2-omni",
185
185
  "opencode-go/mimo-v2-pro",
186
+ /*
187
+ * DeepSeek retired `deepseek-v4-pro` on 2026-09-14 04:00 UTC and routes its requests to
188
+ * V4.1-Flash (api-docs.deepseek.com/news/news260910). Deleting the registry rows removes
189
+ * the model on providers that publish a static roster, but every provider below discovers
190
+ * its models live — there, a deleted row does not remove anything, it only strips the
191
+ * context window, the effort ladder and the text-only hint, so the retired model would
192
+ * keep appearing with its capabilities broken. Excluding the slug is what actually takes
193
+ * it out of the routed catalog.
194
+ */
195
+ "command-code/deepseek-deepseek-v4-pro",
196
+ "commandcode/deepseek-deepseek-v4-pro",
197
+ "orcarouter/deepseek-deepseek-v4-pro",
198
+ "cline-pass/cline-pass-deepseek-v4-pro",
199
+ "baseten/deepseek-ai-DeepSeek-V4-Pro",
200
+ "digitalocean/deepseek-v4-pro",
201
+ "qoder/DeepSeek-V4-Pro",
202
+ "codebuddy/deepseek-v4-pro",
186
203
  ]);
187
204
 
188
205
  export function isRoutedModelCompatibilityExcluded(slug: string): boolean {
@@ -501,7 +518,8 @@ export function applyNativeOpenAiContextOverride(entry: RawEntry, limits?: Nativ
501
518
  }
502
519
  // providerContextCaps.openai is a ceiling for native OpenAI rows regardless of where the
503
520
  // advertised window came from (#1430): preserved rows without a hardcoded override (e.g.
504
- // gpt-5.4-mini) must stay under the cap too, and auto-compaction follows the capped window.
521
+ // gpt-5.3-codex-spark) must stay under the cap too, and auto-compaction follows the capped
522
+ // window.
505
523
  // The per-model window narrows the same rows for the same reason.
506
524
  const currentContext = typeof entry.context_window === "number" ? entry.context_window : undefined;
507
525
  const cappedContext = narrowNativeMaxContextWindow(nativeSlug, currentContext, limits);