@bitkyc08/opencodex 2.14.1 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/gui/dist/assets/index-B5T5ADgY.js +76 -0
  2. package/gui/dist/assets/{index-DWhX3yMp.css → index-DUCH59lJ.css} +1 -1
  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/effort-map.ts +4 -5
  7. package/src/adapters/cursor/request-builder.ts +55 -11
  8. package/src/adapters/cursor/tool-definitions.ts +24 -0
  9. package/src/adapters/kiro.ts +10 -1
  10. package/src/adapters/openai-chat.ts +5 -3
  11. package/src/adapters/openai-responses.ts +109 -0
  12. package/src/adapters/tool-catalog-nudge.ts +26 -4
  13. package/src/bridge.ts +50 -3
  14. package/src/cli/init.ts +4 -17
  15. package/src/codex/catalog/effort.ts +2 -1
  16. package/src/codex/catalog/metadata.ts +62 -12
  17. package/src/codex/catalog/native-models.ts +27 -0
  18. package/src/codex/catalog/parsing.ts +17 -2
  19. package/src/codex/catalog/provider-fetch.ts +47 -5
  20. package/src/codex/catalog/sync.ts +21 -7
  21. package/src/codex/catalog.ts +1 -1
  22. package/src/config.ts +79 -4
  23. package/src/generated/compatibility-version.json +54 -42
  24. package/src/generated/model-metadata.ts +1 -1
  25. package/src/lib/app-owned-memory-stores.ts +22 -0
  26. package/src/lib/tool-argument-integers.ts +158 -0
  27. package/src/oauth/index.ts +3 -0
  28. package/src/oauth/nous.ts +58 -9
  29. package/src/providers/antigravity-models.ts +93 -28
  30. package/src/providers/base-url-choices.ts +10 -0
  31. package/src/providers/command-code-efforts.ts +18 -0
  32. package/src/providers/model-rename-migration.ts +255 -0
  33. package/src/providers/model-rename-startup.ts +28 -0
  34. package/src/providers/openai-tier-startup.ts +31 -2
  35. package/src/providers/quota.ts +9 -2
  36. package/src/providers/registry.ts +13 -6
  37. package/src/responses/spill-store.ts +5 -1
  38. package/src/responses/state.ts +50 -2
  39. package/src/server/index.ts +2 -1
  40. package/src/server/management/api-key-usage.ts +31 -5
  41. package/src/server/management/logs-usage-routes.ts +48 -10
  42. package/src/server/management/provider-routes.ts +2 -1
  43. package/src/server/management/usage-summary-cache.ts +7 -1
  44. package/src/server/responses/collaboration.ts +12 -2
  45. package/src/server/responses/core.ts +33 -16
  46. package/src/server/startup-health-cache.ts +12 -0
  47. package/src/usage/expected-prices.ts +13 -0
  48. package/src/usage/log.ts +430 -12
  49. package/gui/dist/assets/index-DuaUVm_d.js +0 -76
@@ -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,
@@ -1393,7 +1397,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
1393
1397
  // 2026-07-10: defaultModel is frozen pending Vertex-specific Tier-2 evidence; Gemini API
1394
1398
  // evidence from ai.google.dev does not establish Vertex publisher availability.
1395
1399
  { id: "google-vertex", label: "Google Vertex AI", adapter: "google", baseUrl: "https://aiplatform.googleapis.com", authKind: "key", dashboardUrl: "https://console.cloud.google.com/vertex-ai", defaultModel: "gemini-3-pro", googleMode: "vertex", jawcodeBundle: "google", extraMetadataAliases: ["gemini-vertex"] },
1396
- { id: "google-antigravity", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.6-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] },
1400
+ { id: "google-antigravity", label: "Google Antigravity", adapter: "google", baseUrl: "https://daily-cloudcode-pa.googleapis.com", authKind: "oauth", dashboardUrl: "https://antigravity.google", models: ANTIGRAVITY_MODELS, liveModels: true, defaultModel: "gemini-3.7-flash", modelContextWindows: ANTIGRAVITY_MODEL_CONTEXT_WINDOWS, modelInputModalities: ANTIGRAVITY_MODEL_INPUT_MODALITIES, modelReasoningEfforts: ANTIGRAVITY_MODEL_EFFORTS, googleMode: "cloud-code-assist", jawcodeBundle: "google", extraMetadataAliases: ["antigravity", "gemini-antigravity"] },
1397
1401
  { id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" },
1398
1402
  { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" },
1399
1403
  { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", allowPrivateNetworkByDefault: true, allowBaseUrlOverride: true, featured: true, note: "Local — key usually blank" },
@@ -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);
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  currentUsageLogRevision,
3
3
  readUsageSnapshotForManagement,
4
- usageLogRevisionKey,
4
+ usageLogIdentityKey,
5
5
  type PersistedUsageEntry,
6
6
  } from "../../usage/log";
7
7
 
@@ -110,7 +110,7 @@ export function rollupApiKeyUsage(
110
110
  * create/rename/delete. The compact rollup is a handful of counters per key, so
111
111
  * caching it costs nothing; a new row changes the revision and invalidates it.
112
112
  */
113
- let rollupCache: { revisionKey: string; expiresAt: number; snapshot: ApiKeyUsageSnapshot } | null = null;
113
+ let rollupCache: { revisionKey: string; expiresAt: number; lastSeenSize?: number; snapshot: ApiKeyUsageSnapshot } | null = null;
114
114
 
115
115
  /**
116
116
  * The rollup is a function of the log AND of the clock: a request ages out of
@@ -136,6 +136,29 @@ export function clearApiKeyUsageCacheForTests(): void {
136
136
  * `attributionSince`. Key management working matters more than usage numbers
137
137
  * being present, and the GUI already treats an absent field as "no data".
138
138
  */
139
+ export function cacheApiKeyUsageFromSnapshot(
140
+ entries: PersistedUsageEntry[],
141
+ configuredIds: string[],
142
+ identityKey: string,
143
+ lastSeenSize: number,
144
+ truncated: boolean,
145
+ maxReadBytes: number | undefined,
146
+ now: number = Date.now(),
147
+ ): ApiKeyUsageSnapshot {
148
+ const idsKey = JSON.stringify([configuredIds, maxReadBytes]);
149
+ const rolled = {
150
+ ...rollupApiKeyUsage(entries, configuredIds, now),
151
+ ...(truncated ? { historyTruncated: true as const } : {}),
152
+ };
153
+ rollupCache = {
154
+ revisionKey: `${identityKey}|${idsKey}`,
155
+ expiresAt: now + ROLLUP_CACHE_TTL_MS,
156
+ lastSeenSize,
157
+ snapshot: rolled,
158
+ };
159
+ return rolled;
160
+ }
161
+
139
162
  export async function readApiKeyUsageRollup(configuredIds: string[], maxReadBytes?: number): Promise<ApiKeyUsageSnapshot> {
140
163
  // JSON rather than a joined string: ids are only validated as non-empty
141
164
  // strings, so `["a\0b","c"]` and `["a","b\0c"]` join to the same value and one
@@ -143,8 +166,10 @@ export async function readApiKeyUsageRollup(configuredIds: string[], maxReadByte
143
166
  const idsKey = JSON.stringify([configuredIds, maxReadBytes]);
144
167
  const now = Date.now();
145
168
  try {
146
- const observedKey = `${usageLogRevisionKey(currentUsageLogRevision())}|${idsKey}`;
147
- if (rollupCache?.revisionKey === observedKey && now < rollupCache.expiresAt) {
169
+ const observed = currentUsageLogRevision();
170
+ const observedKey = `${usageLogIdentityKey(observed)}|${idsKey}`;
171
+ const observedSize = observed?.size ?? 0;
172
+ if (rollupCache?.revisionKey === observedKey && now < rollupCache.expiresAt && observedSize >= (rollupCache.lastSeenSize ?? 0)) {
148
173
  return rollupCache.snapshot;
149
174
  }
150
175
 
@@ -154,8 +179,9 @@ export async function readApiKeyUsageRollup(configuredIds: string[], maxReadByte
154
179
  ...(snapshot.truncatedPrefixBytes > 0 || snapshot.entriesTruncated ? { historyTruncated: true as const } : {}),
155
180
  };
156
181
  rollupCache = {
157
- revisionKey: `${usageLogRevisionKey(snapshot.revision)}|${idsKey}`,
182
+ revisionKey: `${usageLogIdentityKey(snapshot.revision)}|${idsKey}`,
158
183
  expiresAt: now + ROLLUP_CACHE_TTL_MS,
184
+ lastSeenSize: snapshot.revision?.size ?? 0,
159
185
  snapshot: rolled,
160
186
  };
161
187
  return rolled;
@@ -50,6 +50,7 @@ import {
50
50
  import {
51
51
  currentUsageLogRevision,
52
52
  readUsageSnapshotForManagement,
53
+ usageLogIdentityKey,
53
54
  usageLogRevisionKey,
54
55
  type PersistedUsageEntry,
55
56
  } from "../../usage/log";
@@ -84,6 +85,7 @@ import {
84
85
  getUsageSummaryCacheEntry,
85
86
  setUsageSummaryCacheEntry,
86
87
  } from "./usage-summary-cache";
88
+ import { cacheApiKeyUsageFromSnapshot } from "./api-key-usage";
87
89
 
88
90
  const USAGE_DAY_MS = 86_400_000;
89
91
  function usageEntryMatchesSurface(entry: PersistedUsageEntry, surface: UsageSurface): boolean {
@@ -215,12 +217,16 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
215
217
  try {
216
218
  const cacheKey = `${range}:${surface}`;
217
219
  const effectiveReadLimit = config.managementUsageMaxReadBytes ?? 64 * 1024 * 1024;
218
- const observedRevisionKey = `${usageLogRevisionKey(currentUsageLogRevision())}\0${effectiveReadLimit}`;
220
+ const observed = currentUsageLogRevision();
221
+ const identityKey = `${usageLogIdentityKey(observed)}\0${effectiveReadLimit}`;
222
+ const observedSize = observed?.size ?? 0;
219
223
  const cached = getUsageSummaryCacheEntry(cacheKey);
220
224
  if (cached
221
- && cached.revisionKey === observedRevisionKey
225
+ && cached.identityKey === identityKey
226
+ && cached.maxReadBytes === effectiveReadLimit
222
227
  && cached.overlayVersion === userCostOverlayVersion()
223
- && now < cached.expiresAt) {
228
+ && now < cached.freshUntil
229
+ && observedSize >= cached.lastSeenSize) {
224
230
  return jsonResponse(refreshedUsageSummary(cached.summary, range, now));
225
231
  }
226
232
  if (cached) discardUsageSummaryCacheEntry(cacheKey);
@@ -249,13 +255,45 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
249
255
  // mixed-price entry under either version.
250
256
  return jsonResponse(summary);
251
257
  }
252
- setUsageSummaryCacheEntry(cacheKey, {
253
- revisionKey: `${usageLogRevisionKey(snapshot.revision)}\0${effectiveReadLimit}`,
254
- overlayVersion,
255
- expiresAt: usageSummaryExpiresAt(snapshot.entries, range, surface, now),
256
- revisionReadAt,
257
- summary,
258
- });
258
+ const freshUntil = now + 60_000;
259
+ const snapshotIdentity = `${usageLogIdentityKey(snapshot.revision)}\0${effectiveReadLimit}`;
260
+ const revisionKey = `${usageLogRevisionKey(snapshot.revision)}\0${effectiveReadLimit}`;
261
+ const lastSeenSize = snapshot.revision?.size ?? 0;
262
+ const ranges: UsageRange[] = ["7d", "30d", "all"];
263
+ const surfaces: UsageSurface[] = ["all", "codex", "claude", "grok"];
264
+ for (const nextRange of ranges) {
265
+ for (const nextSurface of surfaces) {
266
+ const nextSummary = nextRange === range && nextSurface === surface ? summary : {
267
+ ...summarizeUsage(snapshot.entries, nextRange, now, nextSurface),
268
+ historyTruncated: summary.historyTruncated,
269
+ truncatedPrefixBytes: summary.truncatedPrefixBytes,
270
+ entriesTruncated: summary.entriesTruncated,
271
+ entriesDropped: summary.entriesDropped,
272
+ snapshotWindowStart: summary.snapshotWindowStart,
273
+ snapshotWindowEnd: summary.snapshotWindowEnd,
274
+ };
275
+ setUsageSummaryCacheEntry(`${nextRange}:${nextSurface}`, {
276
+ revisionKey,
277
+ identityKey: snapshotIdentity,
278
+ maxReadBytes: effectiveReadLimit,
279
+ overlayVersion,
280
+ expiresAt: usageSummaryExpiresAt(snapshot.entries, nextRange, nextSurface, now),
281
+ freshUntil,
282
+ lastSeenSize,
283
+ revisionReadAt,
284
+ summary: nextSummary,
285
+ });
286
+ }
287
+ }
288
+ cacheApiKeyUsageFromSnapshot(
289
+ snapshot.entries,
290
+ (config.apiKeys ?? []).map(key => key.id),
291
+ usageLogIdentityKey(snapshot.revision),
292
+ snapshot.revision?.size ?? 0,
293
+ snapshot.truncatedPrefixBytes > 0 || snapshot.entriesTruncated,
294
+ effectiveReadLimit,
295
+ now,
296
+ );
259
297
  return jsonResponse(summary);
260
298
  } catch {
261
299
  return jsonResponse({
@@ -27,7 +27,7 @@ import {
27
27
  submitManualLoginCode,
28
28
  upsertOAuthProvider,
29
29
  } from "../../oauth";
30
- import { removeCredential } from "../../oauth/store";
30
+ import { replaceProviderAccountSet } from "../../oauth/store";
31
31
  import { providerDestinationResolvedError } from "../../lib/destination-policy";
32
32
  import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
33
33
  import { ProviderOutboundPolicyError, providerOutboundGet, providerOutboundPost, providerRedirectError } from "../../lib/provider-outbound";
@@ -765,6 +765,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
765
765
  const droppedCustomModels = dropProviderCustomModels(config, name);
766
766
  setProviderContextCap(config, name, false);
767
767
  save(config);
768
+ await replaceProviderAccountSet(name, null);
768
769
  reconcileLiveStateStores();
769
770
  const { clearModelCache: clearCache } = await import("../../codex/model-cache");
770
771
  clearCache(name);
@@ -8,11 +8,17 @@ export type CachedUsageSummary = UsageSummary & {
8
8
  entriesDropped: number;
9
9
  };
10
10
 
11
- interface UsageSummaryCacheEntry {
11
+ export interface UsageSummaryCacheEntry {
12
12
  revisionKey: string;
13
+ /** path/dev/ino/birthtime only; appends keep this stable. */
14
+ identityKey: string;
15
+ maxReadBytes: number;
13
16
  /** userCostOverlayVersion() when the summary was computed; overlay edits invalidate the entry. */
14
17
  overlayVersion: number;
15
18
  expiresAt: number;
19
+ /** Generation freshness: ignore size/mtime until this instant. */
20
+ freshUntil: number;
21
+ lastSeenSize: number;
16
22
  summary: CachedUsageSummary;
17
23
  revisionReadAt: number;
18
24
  sizeBytes: number;