@bitkyc08/opencodex 2.14.1 → 2.14.2

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 (44) hide show
  1. package/gui/dist/assets/{index-DWhX3yMp.css → index-DUCH59lJ.css} +1 -1
  2. package/gui/dist/assets/index-DUyQeU1j.js +76 -0
  3. package/gui/dist/index.html +2 -2
  4. package/package.json +1 -1
  5. package/src/adapters/command-code.ts +15 -4
  6. package/src/adapters/cursor/request-builder.ts +54 -10
  7. package/src/adapters/cursor/tool-definitions.ts +24 -0
  8. package/src/adapters/kiro.ts +10 -1
  9. package/src/adapters/openai-chat.ts +5 -3
  10. package/src/adapters/openai-responses.ts +109 -0
  11. package/src/adapters/tool-catalog-nudge.ts +26 -4
  12. package/src/bridge.ts +50 -3
  13. package/src/cli/init.ts +4 -17
  14. package/src/codex/catalog/effort.ts +2 -1
  15. package/src/codex/catalog/metadata.ts +62 -12
  16. package/src/codex/catalog/native-models.ts +27 -0
  17. package/src/codex/catalog/parsing.ts +17 -2
  18. package/src/codex/catalog/provider-fetch.ts +47 -5
  19. package/src/codex/catalog/sync.ts +21 -7
  20. package/src/codex/catalog.ts +1 -1
  21. package/src/config.ts +79 -4
  22. package/src/generated/compatibility-version.json +48 -36
  23. package/src/lib/app-owned-memory-stores.ts +22 -0
  24. package/src/lib/tool-argument-integers.ts +158 -0
  25. package/src/oauth/nous.ts +58 -9
  26. package/src/providers/base-url-choices.ts +10 -0
  27. package/src/providers/command-code-efforts.ts +18 -0
  28. package/src/providers/model-rename-migration.ts +202 -0
  29. package/src/providers/model-rename-startup.ts +28 -0
  30. package/src/providers/openai-tier-startup.ts +31 -2
  31. package/src/providers/quota.ts +9 -2
  32. package/src/providers/registry.ts +12 -5
  33. package/src/responses/spill-store.ts +5 -1
  34. package/src/responses/state.ts +50 -2
  35. package/src/server/index.ts +2 -1
  36. package/src/server/management/api-key-usage.ts +31 -5
  37. package/src/server/management/logs-usage-routes.ts +48 -10
  38. package/src/server/management/provider-routes.ts +2 -1
  39. package/src/server/management/usage-summary-cache.ts +7 -1
  40. package/src/server/responses/collaboration.ts +12 -2
  41. package/src/server/responses/core.ts +33 -16
  42. package/src/server/startup-health-cache.ts +12 -0
  43. package/src/usage/log.ts +430 -12
  44. package/gui/dist/assets/index-DuaUVm_d.js +0 -76
@@ -62,3 +62,13 @@ export function matchBaseUrlChoice(
62
62
  }
63
63
  return choices.some(c => c.id === "custom") ? "custom" : choices[0]!.id;
64
64
  }
65
+
66
+ /** Moonshot/Kimi API endpoint presets (international default; China selectable). */
67
+ export const MOONSHOT_INTL_BASE_URL = "https://api.moonshot.ai/v1";
68
+ export const MOONSHOT_CN_BASE_URL = "https://api.moonshot.cn/v1";
69
+
70
+ export const MOONSHOT_BASE_URL_CHOICES: readonly ProviderBaseUrlChoice[] = [
71
+ { id: "international", label: "International (.ai)", baseUrl: MOONSHOT_INTL_BASE_URL },
72
+ { id: "china", label: "China (.cn)", baseUrl: MOONSHOT_CN_BASE_URL },
73
+ { id: "custom", label: "Custom" },
74
+ ];
@@ -13,6 +13,24 @@ const COMMAND_CODE_MODEL_EFFORTS = {
13
13
  efforts: ["high", "max"],
14
14
  profileUrl: "https://commandcode.ai/models/glm-5-2",
15
15
  },
16
+ // Muse Spark: CLI currently prints "has no adjustable reasoning effort" and
17
+ // blocks --effort locally, but the upstream /alpha/generate endpoint accepts
18
+ // reasoning_effort low..max for meta/muse-spark-1.2-contributor (verified
19
+ // 2026-08-13: direct upstream POST with low/medium/high/xhigh/max all 200,
20
+ // ultra 400; reasoningTokens differentiated 114..253; proxy previously stripped
21
+ // the field so effort changes had no effect).
22
+ "meta/muse-spark-1.2": {
23
+ efforts: ["low", "medium", "high", "xhigh", "max"],
24
+ profileUrl: "https://commandcode.ai/models/meta-muse-spark-1.2",
25
+ },
26
+ "meta/muse-spark-1.2-contributor": {
27
+ efforts: ["low", "medium", "high", "xhigh", "max"],
28
+ profileUrl: "https://commandcode.ai/models/meta-muse-spark-1.2-contributor",
29
+ },
30
+ "meta/muse-spark-1.1": {
31
+ efforts: ["low", "medium", "high", "xhigh", "max"],
32
+ profileUrl: "https://commandcode.ai/models/meta-muse-spark-1.1",
33
+ },
16
34
  } as const;
17
35
 
18
36
  /**
@@ -0,0 +1,202 @@
1
+ // Registry model renames do not reach a saved provider config on their own.
2
+ //
3
+ // `reconcileOAuthProviders` refuses to touch a row whose `authMode` is not
4
+ // `oauth` (src/oauth/index.ts), and `enrichProviderFromRegistry` is fill-only by
5
+ // design: it backfills a MISSING field and never rewrites a present one, so a
6
+ // user's hand-edited model list survives an upgrade. Both postures are correct.
7
+ // Their gap is the case where the registry did not ADD a model but RENAMED one:
8
+ // the saved row keeps a retired id forever, the supported id never appears, and
9
+ // the capability metadata stays keyed to an id the vendor is taking offline
10
+ // (issue #1610 — `qwen3.8-max-preview` persisted through the `qwen3.8-max`
11
+ // rename in six separate fields, including a reasoning ladder that had since
12
+ // diverged from the registry's).
13
+ //
14
+ // This migration is deliberately NOT general reconciliation. It rewrites exactly
15
+ // one thing: an id this file declares retired, on a provider that still carries
16
+ // the registry's transport, and only when the registry currently seeds the
17
+ // replacement. Everything else in the row is left alone.
18
+
19
+ import { PROVIDER_REGISTRY } from "./registry";
20
+ import type { OcxConfig, OcxProviderConfig } from "../types";
21
+
22
+ export interface ModelRename {
23
+ /** Registry provider id whose saved rows may carry the retired model id. */
24
+ provider: string;
25
+ from: string;
26
+ to: string;
27
+ /** Why the vendor retired it, for the startup warning and future readers. */
28
+ reason: string;
29
+ }
30
+
31
+ /**
32
+ * Renames already applied to `PROVIDER_REGISTRY`. An entry stays here after the
33
+ * registry moves on: it is what repairs configs saved before that move. Removing
34
+ * one strands every config that has not started since the rename shipped.
35
+ */
36
+ export const MODEL_RENAMES: readonly ModelRename[] = [
37
+ {
38
+ provider: "alibaba-token-plan",
39
+ from: "qwen3.8-max-preview",
40
+ to: "qwen3.8-max",
41
+ reason: "Alibaba shipped Qwen3.8-Max as stable and documents the preview endpoint as liable to be taken offline once preview concludes",
42
+ },
43
+ {
44
+ provider: "alibaba-token-plan-intl",
45
+ from: "qwen3.8-max-preview",
46
+ to: "qwen3.8-max",
47
+ reason: "Alibaba shipped Qwen3.8-Max as stable and documents the preview endpoint as liable to be taken offline once preview concludes",
48
+ },
49
+ ];
50
+
51
+ /** Provider fields that key metadata by model id. */
52
+ const MODEL_KEYED_RECORDS = [
53
+ "modelContextWindows",
54
+ "modelMaxOutputTokens",
55
+ "modelInputModalities",
56
+ "modelReasoningEfforts",
57
+ "modelDefaultReasoningEfforts",
58
+ "modelReasoningEffortMap",
59
+ ] as const;
60
+
61
+ /** Provider fields that are flat lists of model ids. */
62
+ const MODEL_ID_LISTS = [
63
+ "models",
64
+ "noVisionModels",
65
+ "noReasoningModels",
66
+ "noTemperatureModels",
67
+ "noTopPModels",
68
+ "noPenaltyModels",
69
+ "autoToolChoiceOnlyModels",
70
+ "preserveReasoningContentModels",
71
+ "thinkingBudgetModels",
72
+ "directReasoningEffortModels",
73
+ ] as const;
74
+
75
+ function renameInList(value: unknown, from: string, to: string): string[] | null {
76
+ if (!Array.isArray(value) || !value.includes(from)) return null;
77
+ const seen = new Set<string>();
78
+ const next: string[] = [];
79
+ // Rename in place to preserve ordering, and collapse a duplicate if the target
80
+ // id was already present alongside the retired one.
81
+ for (const entry of value) {
82
+ if (typeof entry !== "string") continue;
83
+ const mapped = entry === from ? to : entry;
84
+ if (seen.has(mapped)) continue;
85
+ seen.add(mapped);
86
+ next.push(mapped);
87
+ }
88
+ return next;
89
+ }
90
+
91
+ function renameInRecord(value: unknown, from: string, to: string): Record<string, unknown> | null {
92
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
93
+ const record = value as Record<string, unknown>;
94
+ if (!(from in record)) return null;
95
+ const next: Record<string, unknown> = {};
96
+ for (const [key, entry] of Object.entries(record)) {
97
+ const mapped = key === from ? to : key;
98
+ if (mapped in next) continue;
99
+ // An explicit entry already saved under the new id is the newer intent.
100
+ next[mapped] = key === from && to in record ? record[to] : entry;
101
+ }
102
+ return next;
103
+ }
104
+
105
+ /**
106
+ * `provider/model` rows in the top-level `disabledModels` list.
107
+ *
108
+ * The retired row is DROPPED rather than renamed: carrying its disabled state to
109
+ * the new id would hide the supported model behind a toggle the user set for a
110
+ * different model. An existing row for the new id is left untouched.
111
+ */
112
+ function renameDisabledModels(config: OcxConfig, rename: ModelRename): boolean {
113
+ const list = config.disabledModels;
114
+ if (!Array.isArray(list)) return false;
115
+ const retired = `${rename.provider}/${rename.from}`;
116
+ if (!list.includes(retired)) return false;
117
+ config.disabledModels = list.filter(entry => entry !== retired);
118
+ return true;
119
+ }
120
+
121
+ /**
122
+ * Only migrate a row that still points at the registry's own endpoint. A user who
123
+ * repointed `baseUrl` at a different vendor owns their model ids.
124
+ */
125
+ function providerStillMatchesRegistry(name: string, prov: OcxProviderConfig): boolean {
126
+ const entry = PROVIDER_REGISTRY.find(row => row.id === name);
127
+ if (!entry) return false;
128
+ if (!prov.baseUrl || !entry.baseUrl) return true;
129
+ const choices = entry.baseUrlChoices?.map(choice => choice.baseUrl) ?? [];
130
+ const known = [entry.baseUrl, ...choices]
131
+ .filter((url): url is string => typeof url === "string")
132
+ .map(url => url.replace(/\/+$/, ""));
133
+ return known.includes(prov.baseUrl.replace(/\/+$/, ""));
134
+ }
135
+
136
+ /** Guard against a stale rename: only apply when the registry actually seeds `to`. */
137
+ function registrySeedsTarget(rename: ModelRename): boolean {
138
+ const entry = PROVIDER_REGISTRY.find(row => row.id === rename.provider);
139
+ return !!entry?.models?.includes(rename.to);
140
+ }
141
+
142
+ export interface ModelRenameProjection {
143
+ config: OcxConfig;
144
+ changed: boolean;
145
+ warnings: string[];
146
+ }
147
+
148
+ /**
149
+ * Pure projection: apply every applicable rename and report what changed. The
150
+ * caller decides whether to persist.
151
+ */
152
+ export function projectModelRenames(
153
+ config: OcxConfig,
154
+ renames: readonly ModelRename[] = MODEL_RENAMES,
155
+ ): ModelRenameProjection {
156
+ const warnings: string[] = [];
157
+ let changed = false;
158
+
159
+ for (const rename of renames) {
160
+ const prov = config.providers?.[rename.provider];
161
+ if (!prov) continue;
162
+ if (!registrySeedsTarget(rename)) {
163
+ warnings.push(
164
+ `registry no longer seeds "${rename.to}" for "${rename.provider}"; skipping the `
165
+ + `"${rename.from}" rename rather than writing an id the registry does not know.`,
166
+ );
167
+ continue;
168
+ }
169
+ if (!providerStillMatchesRegistry(rename.provider, prov)) continue;
170
+
171
+ // Provider config is a closed interface, so index through one unknown-cast
172
+ // view rather than casting at each assignment.
173
+ const row = prov as unknown as Record<string, unknown>;
174
+ let touched = false;
175
+ for (const field of MODEL_ID_LISTS) {
176
+ const next = renameInList(row[field], rename.from, rename.to);
177
+ if (!next) continue;
178
+ row[field] = next;
179
+ touched = true;
180
+ }
181
+ for (const field of MODEL_KEYED_RECORDS) {
182
+ const next = renameInRecord(row[field], rename.from, rename.to);
183
+ if (!next) continue;
184
+ row[field] = next;
185
+ touched = true;
186
+ }
187
+ if (prov.defaultModel === rename.from) {
188
+ prov.defaultModel = rename.to;
189
+ touched = true;
190
+ }
191
+ if (renameDisabledModels(config, rename)) touched = true;
192
+
193
+ if (touched) {
194
+ changed = true;
195
+ warnings.push(
196
+ `renamed "${rename.provider}/${rename.from}" to "${rename.to}" in the saved config: ${rename.reason}.`,
197
+ );
198
+ }
199
+ }
200
+
201
+ return { config, changed, warnings };
202
+ }
@@ -0,0 +1,28 @@
1
+ import { saveConfig } from "../config";
2
+ import { projectModelRenames } from "./model-rename-migration";
3
+ import type { OcxConfig } from "../types";
4
+
5
+ export interface ModelRenameStartupDeps {
6
+ project: typeof projectModelRenames;
7
+ save: (config: OcxConfig) => void;
8
+ }
9
+
10
+ /**
11
+ * Apply registry model renames to the saved config at startup (issue #1610).
12
+ *
13
+ * No backup is taken, unlike the OpenAI tier and Alibaba region migrations: those
14
+ * rewrite credentials and provider identity, where a bad projection is not
15
+ * recoverable from the config alone. This one only rewrites model ids that the
16
+ * registry itself no longer seeds, and the pre-migration value is a string this
17
+ * file still names, so the change is reversible by hand.
18
+ */
19
+ export function runModelRenameStartupMigration(
20
+ config: OcxConfig,
21
+ deps: ModelRenameStartupDeps = { project: projectModelRenames, save: saveConfig },
22
+ ): OcxConfig {
23
+ const projection = deps.project(config);
24
+ for (const warning of projection.warnings) console.warn(`[model-rename-migration] ${warning}`);
25
+ if (!projection.changed) return projection.config;
26
+ deps.save(projection.config);
27
+ return projection.config;
28
+ }
@@ -1,4 +1,10 @@
1
- import { backupConfigBeforeOpenAiTierMigration, saveConfig } from "../config";
1
+ import {
2
+ backupConfigBeforeOpenAiTierMigration,
3
+ OpenAiTierBackupCollisionError,
4
+ OpenAiTierRollbackPreserveError,
5
+ preserveOpenAiTierRollbackSnapshot,
6
+ saveConfig,
7
+ } from "../config";
2
8
  import type { OcxConfig } from "../types";
3
9
  import { projectOpenAiTierMigration } from "./openai-tiers";
4
10
 
@@ -6,6 +12,23 @@ export interface OpenAiTierStartupDeps {
6
12
  project: typeof projectOpenAiTierMigration;
7
13
  backup: () => void;
8
14
  save: (config: OcxConfig) => void;
15
+ preserveRollback?: (error: OpenAiTierBackupCollisionError) => void;
16
+ }
17
+
18
+ function defaultPreserveRollback(error: OpenAiTierBackupCollisionError): void {
19
+ if (!error.configPath) throw error;
20
+ try {
21
+ const preserved = preserveOpenAiTierRollbackSnapshot(error.configPath);
22
+ console.warn(`[openai-provider-migration] Preserved rollback snapshot at ${preserved}`);
23
+ } catch (cause) {
24
+ if (
25
+ cause instanceof OpenAiTierRollbackPreserveError
26
+ && (cause.code === "missing" || cause.code === "not-rollback")
27
+ ) {
28
+ throw error;
29
+ }
30
+ throw cause;
31
+ }
9
32
  }
10
33
 
11
34
  const DEFAULT_DEPS: OpenAiTierStartupDeps = {
@@ -20,7 +43,13 @@ export function runOpenAiTierStartupMigration(
20
43
  ): OcxConfig {
21
44
  const projection = deps.project(config);
22
45
  if (!projection.changed) return projection.config;
23
- deps.backup();
46
+ try {
47
+ deps.backup();
48
+ } catch (error) {
49
+ if (!(error instanceof OpenAiTierBackupCollisionError)) throw error;
50
+ (deps.preserveRollback ?? defaultPreserveRollback)(error);
51
+ deps.backup();
52
+ }
24
53
  deps.save(projection.config);
25
54
  for (const warning of projection.warnings) console.warn(`[openai-provider-migration] ${warning}`);
26
55
  return projection.config;
@@ -783,9 +783,16 @@ async function fetchMoonshotQuota(provider: string, config: OcxProviderConfig):
783
783
  if (available === undefined || available < 0) return null;
784
784
  // Moonshot exposes no per-window quota ceiling, only a balance — report it
785
785
  // as a balance-only window (percent 0) rather than a fabricated utilization.
786
+ // Currency is host-scoped: China platform (api.moonshot.cn) bills in CNY;
787
+ // the international platform (api.moonshot.ai) bills in USD. Do not force
788
+ // either side into the other unit — the number is correct, only the unit
789
+ // must match the host.
790
+ const isChinaHost = host.startsWith("https://api.moonshot.cn");
791
+ const money = (n: number) => isChinaHost ? `¥${n.toFixed(2)}` : `$${n.toFixed(2)}`;
792
+ const unit = isChinaHost ? "CNY" : "USD";
786
793
  const label = voucher !== undefined && cash !== undefined
787
- ? `Balance ($${available.toFixed(2)} available, $${voucher.toFixed(2)} voucher)`
788
- : `Balance ($${available.toFixed(2)} available)`;
794
+ ? `Balance (${money(available)} ${unit} available, ${money(voucher)} voucher)`
795
+ : `Balance (${money(available)} ${unit} available)`;
789
796
  return report(provider, "moonshot:balance", {
790
797
  customWindows: [{ label, percent: 0 }],
791
798
  updatedAt: Date.now(),
@@ -6,6 +6,7 @@ import {
6
6
  QWEN_CLOUD_BASE_URL_CHOICES, QWEN_CLOUD_TOKEN_PLAN_BASE_URL,
7
7
  ALIBABA_INTL_BASE_URL_CHOICES, ALIBABA_INTL_TOKEN_PLAN_BASE_URL,
8
8
  ALIBABA_CODING_BASE_URL_CHOICES, ALIBABA_CODING_INTL_BASE_URL,
9
+ MOONSHOT_BASE_URL_CHOICES, MOONSHOT_INTL_BASE_URL,
9
10
  } from "./base-url-choices";
10
11
  import {
11
12
  CURSOR_STATIC_MODELS,
@@ -950,8 +951,8 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
950
951
  // devlog/model_update/260709_model_refresh/001_xai_lineup.md.
951
952
  // grok-4.20-multi-agent-0309 is intentionally absent: the OAuth chat-completions
952
953
  // transport returns 400 ("Multi Agent requests are not allowed on chat completions").
953
- // 260813: grok-4.6 added per the new docs.x.ai/developers/grok-4-6 page; specs mirrored
954
- // from grok-4.5 until the official capability/pricing tables settle.
954
+ // 260813: grok-4.6 added per docs.x.ai/developers/grok-4-6. Context/vision still match
955
+ // grok-4.5; the reasoning ladder does not — 4.6 adds the documented xhigh rung.
955
956
  models: ["grok-4.6", "grok-4.5", "grok-4.3", "grok-4.20-0309-reasoning", "grok-4.20-0309-non-reasoning", "grok-build-0.1", "grok-composer-2.5-fast"],
956
957
  defaultModel: "grok-4.5",
957
958
  // Vision lineup per docs.x.ai model-capabilities/images/understanding: the grok-4.x chat
@@ -973,8 +974,11 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
973
974
  // (docs.x.ai prompt-caching/multi-turn, verified 2026-07-13 — devlog/_plan/260713_grok_caching).
974
975
  // Models that never emit reasoning simply have no thinking parts to replay (no-op).
975
976
  preserveReasoningContentModels: ["grok-4.6", "grok-4.5", "grok-4.3", "grok-4.20-0309-reasoning"],
976
- // grok-4.5 reasoning is always-on with low/medium/high control (no off tier upstream).
977
- modelReasoningEfforts: { "grok-4.6": ["low", "medium", "high"], "grok-4.5": ["low", "medium", "high"] },
977
+ // grok-4.5 reasoning is always-on with low/medium/high (no off tier, no xhigh).
978
+ // grok-4.6 adds xhigh per docs.x.ai/developers/model-capabilities/text/reasoning;
979
+ // xAI documents high as the upstream default.
980
+ modelReasoningEfforts: { "grok-4.6": ["low", "medium", "high", "xhigh"], "grok-4.5": ["low", "medium", "high"] },
981
+ modelDefaultReasoningEfforts: { "grok-4.6": "high" },
978
982
  modelContextWindows: {
979
983
  "grok-4.6": 500_000,
980
984
  "grok-4.5": 500_000,
@@ -1870,7 +1874,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1870
1874
  note: "Model data frozen pending Tier-2 entitlement proof",
1871
1875
  },
1872
1876
  {
1873
- id: "moonshot", label: "Moonshot (Kimi API)", baseUrl: "https://api.moonshot.ai/v1", adapter: "openai-chat", authKind: "key",
1877
+ id: "moonshot", label: "Moonshot (Kimi API)", baseUrl: MOONSHOT_INTL_BASE_URL, adapter: "openai-chat", authKind: "key",
1878
+ allowBaseUrlOverride: true,
1879
+ baseUrlChoices: MOONSHOT_BASE_URL_CHOICES,
1874
1880
  dashboardUrl: "https://platform.moonshot.ai/console/api-keys", defaultModel: "kimi-k2.7-code", jawcodeBundle: "moonshot",
1875
1881
  models: KIMI_API_MODELS,
1876
1882
  modelContextWindows: KIMI_API_MODEL_CONTEXT_WINDOWS,
@@ -1882,6 +1888,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1882
1888
  noPenaltyModels: KIMI_API_MODELS,
1883
1889
  autoToolChoiceOnlyModels: ["kimi-k2.7-code", "kimi-k2.7-code-highspeed"],
1884
1890
  preserveReasoningContentModels: KIMI_API_MODELS,
1891
+ note: "International default (api.moonshot.ai). China accounts: choose China (.cn) or Custom for api.moonshot.cn.",
1885
1892
  },
1886
1893
  { id: "huggingface", label: "Hugging Face", baseUrl: "https://router.huggingface.co/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://huggingface.co/settings/tokens" },
1887
1894
  // 260715 NIM hardening (issue #126, devlog/_plan/260715_issue126_nim_kimi):
@@ -34,6 +34,7 @@ export interface ResponseSpillPayload {
34
34
  version: 1;
35
35
  responseId: string;
36
36
  createdAt: number;
37
+ clientThreadId?: string;
37
38
  items: unknown[];
38
39
  providers?: OcxProviderContinuationState;
39
40
  }
@@ -260,9 +261,11 @@ function validPayload(value: unknown, responseId: string): value is ResponseSpil
260
261
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
261
262
  const payload = value as Record<string, unknown>;
262
263
  const keys = Object.keys(payload);
263
- if (keys.some(key => !["version", "responseId", "createdAt", "items", "providers"].includes(key))) return false;
264
+ if (keys.some(key => !["version", "responseId", "createdAt", "clientThreadId", "items", "providers"].includes(key))) return false;
264
265
  if (payload.version !== 1 || payload.responseId !== responseId) return false;
265
266
  if (typeof payload.createdAt !== "number" || !Number.isFinite(payload.createdAt)) return false;
267
+ if (payload.clientThreadId !== undefined
268
+ && (typeof payload.clientThreadId !== "string" || payload.clientThreadId.trim().length === 0)) return false;
266
269
  if (!Array.isArray(payload.items)) return false;
267
270
  if (payload.providers !== undefined) {
268
271
  if (!payload.providers || typeof payload.providers !== "object" || Array.isArray(payload.providers)) return false;
@@ -284,6 +287,7 @@ export function writeResponseSpillDurably(
284
287
  version: 1,
285
288
  responseId,
286
289
  createdAt: state.createdAt,
290
+ ...(state.clientThreadId ? { clientThreadId: state.clientThreadId } : {}),
287
291
  items: state.items,
288
292
  ...(state.providers ? { providers: state.providers } : {}),
289
293
  };
@@ -37,6 +37,7 @@ const MAX_SNAPSHOT_REWRITE_ATTEMPTS = 4;
37
37
  interface ResidentResponseState {
38
38
  kind: "resident";
39
39
  createdAt: number;
40
+ clientThreadId?: string;
40
41
  items: unknown[];
41
42
  providers?: OcxProviderContinuationState;
42
43
  sizeBytes: number;
@@ -45,6 +46,7 @@ interface ResidentResponseState {
45
46
  interface SpilledResponseState {
46
47
  kind: "spill";
47
48
  createdAt: number;
49
+ clientThreadId?: string;
48
50
  providers?: OcxProviderContinuationState;
49
51
  spill: ResponseSpillRef;
50
52
  sizeBytes: number;
@@ -65,6 +67,7 @@ export type PreviousResponseReplayFailure = {
65
67
  };
66
68
 
67
69
  const states = new Map<string, StoredResponseState>();
70
+ const replayScopeMismatches = new WeakSet<object>();
68
71
  let storedResponseBytes = 0;
69
72
  let residentResponseBytes = 0;
70
73
  let oldestResidentId: string | undefined;
@@ -80,6 +83,7 @@ const spillCounters = { writes: 0, writeFailures: 0, readFailures: 0 };
80
83
  * snapshot files refused before parse.
81
84
  */
82
85
  const admissionCounters = { directSpills: 0, oversizedDrops: 0, snapshotOversizedRefusals: 0 };
86
+ let replayScopeMismatchDrops = 0;
83
87
 
84
88
  /** Test-only: admission-boundary counters (proves the new paths fire). */
85
89
  export function responseAdmissionCountersForTests(): Readonly<typeof admissionCounters> {
@@ -124,6 +128,7 @@ function measureResidentEntry(id: string, entry: ResidentInput): ResidentRespons
124
128
  const sizeBytes = serializedBytes({
125
129
  responseId: id,
126
130
  createdAt: entry.createdAt,
131
+ ...(entry.clientThreadId ? { clientThreadId: entry.clientThreadId } : {}),
127
132
  items: entry.items,
128
133
  ...(entry.providers ? { providers: entry.providers } : {}),
129
134
  });
@@ -224,6 +229,7 @@ function swapResidentForSpill(id: string, expected: ResidentResponseState, ref:
224
229
  const base: Omit<SpilledResponseState, "sizeBytes"> = {
225
230
  kind: "spill",
226
231
  createdAt: expected.createdAt,
232
+ ...(expected.clientThreadId ? { clientThreadId: expected.clientThreadId } : {}),
227
233
  ...(expected.providers ? { providers: expected.providers } : {}),
228
234
  spill: ref,
229
235
  };
@@ -244,12 +250,14 @@ function replaceSpillEntryAtomically(
244
250
  try {
245
251
  const ref = writeResponseSpillDurably(id, {
246
252
  createdAt: candidate.createdAt,
253
+ ...(candidate.clientThreadId ? { clientThreadId: candidate.clientThreadId } : {}),
247
254
  items: candidate.items,
248
255
  ...(candidate.providers ? { providers: candidate.providers } : {}),
249
256
  });
250
257
  const base: Omit<SpilledResponseState, "sizeBytes"> = {
251
258
  kind: "spill",
252
259
  createdAt: candidate.createdAt,
260
+ ...(candidate.clientThreadId ? { clientThreadId: candidate.clientThreadId } : {}),
253
261
  ...(candidate.providers ? { providers: candidate.providers } : {}),
254
262
  spill: ref,
255
263
  };
@@ -322,6 +330,7 @@ function admitOversizedCandidate(
322
330
  try {
323
331
  const ref = writeResponseSpillDurably(id, {
324
332
  createdAt: candidate.createdAt,
333
+ ...(candidate.clientThreadId ? { clientThreadId: candidate.clientThreadId } : {}),
325
334
  items: candidate.items,
326
335
  ...(candidate.providers ? { providers: candidate.providers } : {}),
327
336
  });
@@ -337,6 +346,7 @@ function admitOversizedCandidate(
337
346
  const base: Omit<SpilledResponseState, "sizeBytes"> = {
338
347
  kind: "spill",
339
348
  createdAt: candidate.createdAt,
349
+ ...(candidate.clientThreadId ? { clientThreadId: candidate.clientThreadId } : {}),
340
350
  ...(candidate.providers ? { providers: candidate.providers } : {}),
341
351
  spill: ref,
342
352
  };
@@ -385,6 +395,7 @@ function snapshotPath(): string {
385
395
 
386
396
  interface LegacySnapshotState {
387
397
  createdAt?: unknown;
398
+ clientThreadId?: unknown;
388
399
  items?: unknown;
389
400
  providers?: OcxProviderContinuationState;
390
401
  conversationId?: unknown;
@@ -405,11 +416,15 @@ function loadSnapshotEntry(id: string, value: unknown): void {
405
416
  if (!value || typeof value !== "object" || Array.isArray(value)) return;
406
417
  const rec = value as LegacySnapshotState & { kind?: unknown; spill?: unknown };
407
418
  if (typeof rec.createdAt !== "number" || !Number.isFinite(rec.createdAt)) return;
419
+ const clientThreadId = typeof rec.clientThreadId === "string" && rec.clientThreadId.trim().length > 0
420
+ ? rec.clientThreadId.trim()
421
+ : undefined;
408
422
  if (rec.kind === "spill") {
409
423
  if (!isSpillRef(rec.spill)) return;
410
424
  const base: Omit<SpilledResponseState, "sizeBytes"> = {
411
425
  kind: "spill",
412
426
  createdAt: rec.createdAt,
427
+ ...(clientThreadId ? { clientThreadId } : {}),
413
428
  ...(rec.providers ? { providers: rec.providers } : {}),
414
429
  spill: rec.spill,
415
430
  };
@@ -434,6 +449,7 @@ function loadSnapshotEntry(id: string, value: unknown): void {
434
449
  : undefined);
435
450
  const resident = measureResidentEntry(id, {
436
451
  createdAt: rec.createdAt,
452
+ ...(clientThreadId ? { clientThreadId } : {}),
437
453
  items: rec.items,
438
454
  ...(providers ? { providers } : {}),
439
455
  });
@@ -747,6 +763,7 @@ function pruneResponses(at = now()): void {
747
763
  try {
748
764
  const ref = writeResponseSpillDurably(oldestId, {
749
765
  createdAt: entry.createdAt,
766
+ ...(entry.clientThreadId ? { clientThreadId: entry.clientThreadId } : {}),
750
767
  items: entry.items,
751
768
  ...(entry.providers ? { providers: entry.providers } : {}),
752
769
  });
@@ -788,6 +805,7 @@ export function evictOldestResponseContinuationForBudget(): number {
788
805
  try {
789
806
  const ref = writeResponseSpillDurably(id, {
790
807
  createdAt: entry.createdAt,
808
+ ...(entry.clientThreadId ? { clientThreadId: entry.clientThreadId } : {}),
791
809
  items: entry.items,
792
810
  ...(entry.providers ? { providers: entry.providers } : {}),
793
811
  });
@@ -828,6 +846,7 @@ function materializeEntry(
828
846
  }
829
847
  const state = measureResidentEntry(id, {
830
848
  createdAt: result.payload.createdAt,
849
+ ...(result.payload.clientThreadId ? { clientThreadId: result.payload.clientThreadId } : {}),
831
850
  items: result.payload.items,
832
851
  ...(result.payload.providers ? { providers: result.payload.providers } : {}),
833
852
  });
@@ -840,7 +859,16 @@ function materializeEntry(
840
859
  return { ok: true, state };
841
860
  }
842
861
 
843
- export function expandPreviousResponseInput(body: unknown): unknown {
862
+ function normalizedClientThreadId(value: unknown): string | undefined {
863
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
864
+ }
865
+
866
+ function withoutPreviousResponseId(request: Record<string, unknown>): Record<string, unknown> {
867
+ const { previous_response_id: _previousResponseId, ...freshRequest } = request;
868
+ return freshRequest;
869
+ }
870
+
871
+ export function expandPreviousResponseInput(body: unknown, clientThreadId?: string): unknown {
844
872
  if (!body || typeof body !== "object" || Array.isArray(body)) return body;
845
873
  const request = body as Record<string, unknown>;
846
874
  const previousId = typeof request.previous_response_id === "string" ? request.previous_response_id : undefined;
@@ -854,6 +882,16 @@ export function expandPreviousResponseInput(body: unknown): unknown {
854
882
  replayFailures.set(request, materialized.failure);
855
883
  return body;
856
884
  }
885
+ const requestThreadId = normalizedClientThreadId(clientThreadId);
886
+ const storedThreadId = normalizedClientThreadId(materialized.state.clientThreadId);
887
+ // A Codex task must never inherit another task's continuation, nor a legacy unscoped entry.
888
+ // Unscoped callers retain backward-compatible replay only with other unscoped entries.
889
+ if (requestThreadId !== storedThreadId) {
890
+ const freshRequest = withoutPreviousResponseId(request);
891
+ replayScopeMismatches.add(freshRequest);
892
+ replayScopeMismatchDrops += 1;
893
+ return freshRequest;
894
+ }
857
895
  const expanded = {
858
896
  ...request,
859
897
  input: [...materialized.state.items, ...inputItems(request.input)],
@@ -873,6 +911,11 @@ export function previousResponseReplayPrefixLength(body: unknown): number {
873
911
  return replayedInputPrefixLengths.get(body) ?? 0;
874
912
  }
875
913
 
914
+ /** True when a stale or foreign previous_response_id was removed from this exact request body. */
915
+ export function previousResponseScopeMismatch(body: unknown): boolean {
916
+ return !!body && typeof body === "object" && replayScopeMismatches.has(body as object);
917
+ }
918
+
876
919
  export function previousResponseConversationId(responseId: string | undefined): string | undefined {
877
920
  return previousResponseProviderState(responseId)?.cursor?.conversationId;
878
921
  }
@@ -898,6 +941,7 @@ export interface ResponseStateMetrics {
898
941
  spillWrites: number;
899
942
  spillWriteFailures: number;
900
943
  spillReadFailures: number;
944
+ replayScopeMismatchDrops: number;
901
945
  }
902
946
 
903
947
  /**
@@ -940,6 +984,7 @@ export function responseStateMetrics(): ResponseStateMetrics {
940
984
  spillWrites: spillCounters.writes,
941
985
  spillWriteFailures: spillCounters.writeFailures,
942
986
  spillReadFailures: spillCounters.readFailures,
987
+ replayScopeMismatchDrops,
943
988
  };
944
989
  }
945
990
 
@@ -972,7 +1017,7 @@ export function rememberResponseState(
972
1017
  requestBody: unknown,
973
1018
  response: { id?: unknown; output?: unknown; status?: unknown; incomplete_details?: unknown },
974
1019
  providerState?: OcxProviderContinuationState | string,
975
- opts?: { force?: boolean },
1020
+ opts?: { force?: boolean; clientThreadId?: string },
976
1021
  ): void {
977
1022
  if (!requestBody || typeof requestBody !== "object" || Array.isArray(requestBody)) return;
978
1023
  const request = requestBody as Record<string, unknown>;
@@ -998,8 +1043,10 @@ export function rememberResponseState(
998
1043
  return !!item && typeof item === "object" && (item as { type?: unknown }).type === "function_call";
999
1044
  });
1000
1045
  }
1046
+ const clientThreadId = normalizedClientThreadId(opts?.clientThreadId);
1001
1047
  setResidentEntry(response.id, {
1002
1048
  createdAt: now(),
1049
+ ...(clientThreadId ? { clientThreadId } : {}),
1003
1050
  items: [...inputItems(request.input), ...response.output],
1004
1051
  // Always preserve the Cursor conversation id so the next tool-result turn can continue the SAME
1005
1052
  // Cursor conversation (multi-turn continuation). Separately track whether Cursor's own
@@ -1045,6 +1092,7 @@ export function clearResponseStateMemoryForTests(): void {
1045
1092
  spillCounters.writes = 0;
1046
1093
  spillCounters.writeFailures = 0;
1047
1094
  spillCounters.readFailures = 0;
1095
+ replayScopeMismatchDrops = 0;
1048
1096
  persistAttemptHookForTests = null;
1049
1097
  loaded = false;
1050
1098
  }
@@ -53,6 +53,7 @@ import { loadLabAutomationPolicy } from "../lab/automation/persistence";
53
53
  import { createProductionLabRouteExecutor } from "../lib/lab-live-route-production";
54
54
  import { runOpenAiTierStartupMigration } from "../providers/openai-tier-startup";
55
55
  import { runAlibabaRegionStartupMigration } from "../providers/alibaba-region-startup";
56
+ import { runModelRenameStartupMigration } from "../providers/model-rename-startup";
56
57
  import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
57
58
  import { providerContextCap } from "../providers/context-cap";
58
59
  import { providerCodexAccountMode } from "../providers/registry";
@@ -490,7 +491,7 @@ export function warnAgentTaskRecoveryStartup(config: {
490
491
 
491
492
  export function startServer(port?: number, deps: StartServerDeps = {}): Server<WsData> {
492
493
  const localAttestationSecret = deps.localAttestationSecret ?? createLocalAttestationSecret();
493
- const config = runAlibabaRegionStartupMigration(runOpenAiTierStartupMigration(loadConfig()));
494
+ const config = runModelRenameStartupMigration(runAlibabaRegionStartupMigration(runOpenAiTierStartupMigration(loadConfig())));
494
495
  warnAgentTaskRecoveryStartup(config);
495
496
  setLiveStateStoreConfig(config);
496
497
  applyProxyEnv(config);