@bitkyc08/opencodex 2.14.0 → 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 (60) hide show
  1. package/README.md +55 -0
  2. package/gui/dist/assets/index-DUCH59lJ.css +1 -0
  3. package/gui/dist/assets/index-DUyQeU1j.js +76 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/src/adapters/command-code.ts +46 -6
  7. package/src/adapters/cursor/request-builder.ts +54 -10
  8. package/src/adapters/cursor/tool-definitions.ts +24 -0
  9. package/src/adapters/kiro.ts +10 -1
  10. package/src/adapters/openai-chat-url.ts +11 -0
  11. package/src/adapters/openai-chat.ts +7 -4
  12. package/src/adapters/openai-responses-url.ts +14 -0
  13. package/src/adapters/openai-responses.ts +111 -2
  14. package/src/adapters/tool-catalog-nudge.ts +26 -4
  15. package/src/bridge.ts +50 -3
  16. package/src/cli/init.ts +4 -17
  17. package/src/codex/auth-api.ts +2 -74
  18. package/src/codex/catalog/effort.ts +2 -1
  19. package/src/codex/catalog/metadata.ts +62 -12
  20. package/src/codex/catalog/native-models.ts +27 -0
  21. package/src/codex/catalog/parsing.ts +27 -8
  22. package/src/codex/catalog/provider-fetch.ts +47 -5
  23. package/src/codex/catalog/sync.ts +31 -8
  24. package/src/codex/catalog.ts +1 -1
  25. package/src/codex/features.ts +14 -3
  26. package/src/codex/model-cache.ts +7 -1
  27. package/src/codex/native-main-claim.ts +13 -2
  28. package/src/config.ts +79 -4
  29. package/src/generated/compatibility-version.json +74 -46
  30. package/src/lab/ledger/store.ts +0 -18
  31. package/src/lab/subject/installation-salt.ts +13 -2
  32. package/src/lib/app-owned-memory-stores.ts +22 -0
  33. package/src/lib/tool-argument-integers.ts +158 -0
  34. package/src/oauth/nous.ts +58 -9
  35. package/src/providers/base-url-choices.ts +10 -0
  36. package/src/providers/command-code-efforts.ts +18 -0
  37. package/src/providers/model-rename-migration.ts +202 -0
  38. package/src/providers/model-rename-startup.ts +28 -0
  39. package/src/providers/openai-tier-startup.ts +31 -2
  40. package/src/providers/quota.ts +9 -2
  41. package/src/providers/registry.ts +17 -10
  42. package/src/responses/spill-store.ts +5 -1
  43. package/src/responses/state.ts +50 -2
  44. package/src/router.ts +12 -1
  45. package/src/server/index.ts +3 -2
  46. package/src/server/management/api-key-usage.ts +31 -5
  47. package/src/server/management/config-routes.ts +51 -16
  48. package/src/server/management/logs-usage-routes.ts +48 -10
  49. package/src/server/management/provider-routes.ts +2 -1
  50. package/src/server/management/usage-summary-cache.ts +7 -1
  51. package/src/server/responses/collaboration.ts +12 -2
  52. package/src/server/responses/core.ts +33 -17
  53. package/src/server/responses/fetch-helpers.ts +12 -1
  54. package/src/server/responses/ws-upstream.ts +199 -0
  55. package/src/server/startup-health-cache.ts +12 -0
  56. package/src/usage/log.ts +430 -12
  57. package/src/vision/index.ts +25 -4
  58. package/src/vision/timeout-bounds.ts +9 -0
  59. package/gui/dist/assets/index-BNVYzdn0.css +0 -1
  60. package/gui/dist/assets/index-Co12XTT-.js +0 -76
@@ -0,0 +1,158 @@
1
+ // Schema-aware repair for integer tool arguments that arrive as floats (issue #1611).
2
+ //
3
+ // Grok serializes integer tool-call arguments through a float representation, so
4
+ // `yield_time_ms: 120000` leaves the provider as `120000.0`. Codex declares those
5
+ // fields as Rust integer types, so it rejects the call BEFORE running the tool:
6
+ //
7
+ // failed to parse function arguments: invalid type: floating point `120000.0`, expected u64
8
+ //
9
+ // That is a hard failure, not a degradation — the model gets no result and retries
10
+ // the same float. Nothing on the routed path reconciled argument values against the
11
+ // declared schema, so the `.0` passed straight through.
12
+ //
13
+ // The boundary this file draws is INTENT, not convenience:
14
+ // - an integral float in an integer-typed field is a representation artifact, and
15
+ // `120000.0` has exactly one integer reading, so it is repaired;
16
+ // - `1.5` in an integer field is a genuine disagreement with the schema and is left
17
+ // alone so it still fails, rather than being truncated into a plausible lie.
18
+ // Anything without a declared `integer` type is never touched.
19
+
20
+ /** JSON Schema subset we need; provider tool schemas are untrusted input. */
21
+ type SchemaNode = Record<string, unknown>;
22
+
23
+ function asSchema(value: unknown): SchemaNode | undefined {
24
+ return value && typeof value === "object" && !Array.isArray(value)
25
+ ? value as SchemaNode
26
+ : undefined;
27
+ }
28
+
29
+ /** True when the node declares `integer`, including `["integer","null"]` unions. */
30
+ function declaresInteger(schema: SchemaNode): boolean {
31
+ const type = schema.type;
32
+ if (type === "integer") return true;
33
+ return Array.isArray(type) && type.includes("integer");
34
+ }
35
+
36
+ /**
37
+ * Resolve a local `$ref` (`#/$defs/Foo`, `#/definitions/Foo`).
38
+ *
39
+ * Only same-document refs are followed: a remote ref is not fetchable here, and a
40
+ * schema we cannot resolve must leave its values untouched rather than guessed at.
41
+ */
42
+ function resolveRef(schema: SchemaNode, root: SchemaNode, seen: Set<string>): SchemaNode | undefined {
43
+ const ref = schema.$ref;
44
+ if (typeof ref !== "string" || !ref.startsWith("#/")) return schema;
45
+ if (seen.has(ref)) return undefined; // cyclic $ref: stop rather than recurse forever
46
+ seen.add(ref);
47
+ let node: unknown = root;
48
+ for (const rawSegment of ref.slice(2).split("/")) {
49
+ const segment = rawSegment.replace(/~1/g, "/").replace(/~0/g, "~");
50
+ const current = asSchema(node);
51
+ if (!current) return undefined;
52
+ node = current[segment];
53
+ }
54
+ const resolved = asSchema(node);
55
+ return resolved ? resolveRef(resolved, root, seen) : undefined;
56
+ }
57
+
58
+ /** Composition keywords whose branches may carry the integer declaration. */
59
+ const COMPOSITION_KEYS = ["anyOf", "oneOf", "allOf"] as const;
60
+
61
+ function compositionBranches(schema: SchemaNode): SchemaNode[] {
62
+ const branches: SchemaNode[] = [];
63
+ for (const key of COMPOSITION_KEYS) {
64
+ const value = schema[key];
65
+ if (!Array.isArray(value)) continue;
66
+ for (const branch of value) {
67
+ const node = asSchema(branch);
68
+ if (node) branches.push(node);
69
+ }
70
+ }
71
+ return branches;
72
+ }
73
+
74
+ /**
75
+ * A JS number can only represent integers exactly up to 2^53-1. Beyond that a
76
+ * rewrite would emit a silently different value, so the original text stays.
77
+ */
78
+ function safelyIntegral(value: number): boolean {
79
+ return Number.isInteger(value) && Math.abs(value) <= Number.MAX_SAFE_INTEGER;
80
+ }
81
+
82
+ interface CoerceResult {
83
+ value: unknown;
84
+ changed: boolean;
85
+ }
86
+
87
+ function coerceValue(value: unknown, schema: SchemaNode | undefined, root: SchemaNode, depth: number): CoerceResult {
88
+ // A hostile or deeply nested schema must not blow the stack.
89
+ if (depth > 64) return { value, changed: false };
90
+ const resolved = schema ? resolveRef(schema, root, new Set()) : undefined;
91
+
92
+ if (typeof value === "number") {
93
+ if (!resolved) return { value, changed: false };
94
+ const integerDeclared = declaresInteger(resolved)
95
+ || compositionBranches(resolved).some(declaresInteger);
96
+ // Not an integer field, already an integer, non-integral, or unrepresentable:
97
+ // in every one of those cases the received value is the right thing to keep.
98
+ if (!integerDeclared || !safelyIntegral(value)) return { value, changed: false };
99
+ // `120000.0` and `120000` are the same JS number; the difference is only in the
100
+ // serialized text, which is repaired by re-stringifying the parsed value.
101
+ return { value, changed: true };
102
+ }
103
+
104
+ if (Array.isArray(value)) {
105
+ const itemSchema = resolved ? asSchema(resolved.items) : undefined;
106
+ let changed = false;
107
+ const next = value.map(entry => {
108
+ const result = coerceValue(entry, itemSchema, root, depth + 1);
109
+ if (result.changed) changed = true;
110
+ return result.value;
111
+ });
112
+ return changed ? { value: next, changed } : { value, changed: false };
113
+ }
114
+
115
+ const object = asSchema(value);
116
+ if (!object) return { value, changed: false };
117
+
118
+ const properties = resolved ? asSchema(resolved.properties) : undefined;
119
+ const additional = resolved ? asSchema(resolved.additionalProperties) : undefined;
120
+ let changed = false;
121
+ const next: Record<string, unknown> = {};
122
+ for (const [key, entry] of Object.entries(object)) {
123
+ const childSchema = asSchema(properties?.[key]) ?? additional;
124
+ const result = coerceValue(entry, childSchema, root, depth + 1);
125
+ if (result.changed) changed = true;
126
+ next[key] = result.value;
127
+ }
128
+ return changed ? { value: next, changed } : { value, changed: false };
129
+ }
130
+
131
+ /**
132
+ * Repair integral floats in a tool-call arguments STRING against the tool's declared
133
+ * parameter schema.
134
+ *
135
+ * Returns the original string unchanged when there is no schema, the payload does not
136
+ * parse, or nothing needed repair — so an unaffected call keeps its exact bytes and
137
+ * this stays a no-op for every provider that already emits real integers.
138
+ */
139
+ export function coerceIntegerToolArguments(
140
+ args: string,
141
+ parameters: Record<string, unknown> | undefined,
142
+ ): string {
143
+ if (!parameters || !args) return args;
144
+ // Cheap reject: a payload with no fractional-looking number cannot need repair.
145
+ if (!/\d\.\d/.test(args)) return args;
146
+ let parsed: unknown;
147
+ try {
148
+ parsed = JSON.parse(args);
149
+ } catch {
150
+ // Malformed or still-streaming arguments are not this function's problem; the
151
+ // existing paths already handle them.
152
+ return args;
153
+ }
154
+ const root = parameters as SchemaNode;
155
+ const result = coerceValue(parsed, root, root, 0);
156
+ if (!result.changed) return args;
157
+ return JSON.stringify(result.value);
158
+ }
package/src/oauth/nous.ts CHANGED
@@ -39,6 +39,7 @@ import { join } from "node:path";
39
39
  import type { OAuthController, OAuthCredentials } from "./types";
40
40
  import { getAuthStorePath } from "./store";
41
41
  import { atomicWriteFile, hardenConfigDir, hardenExistingSecret } from "../config";
42
+ import { BOUNDED_BODY_MAX_BYTES, readBoundedResponseBytes } from "../lib/bounded-body";
42
43
 
43
44
  export const NOUS_PORTAL_BASE_URL = "https://portal.nousresearch.com";
44
45
  export const NOUS_INFERENCE_BASE_URL = "https://inference-api.nousresearch.com/v1";
@@ -87,6 +88,44 @@ interface NousJwtPayload {
87
88
  [key: string]: unknown;
88
89
  }
89
90
 
91
+ async function readOAuthBytes(response: Response, signal: AbortSignal): Promise<Uint8Array> {
92
+ const { bytes, oversized } = await readBoundedResponseBytes(response, {
93
+ maxBytes: BOUNDED_BODY_MAX_BYTES,
94
+ signal,
95
+ });
96
+ if (oversized) {
97
+ throw new NousTokenError(
98
+ response.status,
99
+ "response_too_large",
100
+ `Nous Portal OAuth response exceeded the ${BOUNDED_BODY_MAX_BYTES}-byte limit`,
101
+ );
102
+ }
103
+ return bytes;
104
+ }
105
+
106
+ function parseOAuthJson(bytes: Uint8Array): unknown {
107
+ const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
108
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
109
+ ? parsed as Record<string, unknown>
110
+ : {};
111
+ }
112
+
113
+ async function readOAuthJson(response: Response, signal: AbortSignal): Promise<unknown> {
114
+ return parseOAuthJson(await readOAuthBytes(response, signal));
115
+ }
116
+
117
+ async function readOAuthJsonOrEmpty(response: Response, signal: AbortSignal): Promise<unknown> {
118
+ const bytes = await readOAuthBytes(response, signal);
119
+ try {
120
+ return parseOAuthJson(bytes);
121
+ } catch {
122
+ // Preserve the pre-PR behavior for empty/HTML/malformed JSON only. Body
123
+ // read failures, timeouts, caller cancellation, and size-limit errors have
124
+ // already escaped readOAuthBytes and must retain their real identity.
125
+ return {};
126
+ }
127
+ }
128
+
90
129
  // ── Durable refresh-intent (review blocker #2) ──────────────────────────────
91
130
  // A refresh-intent file records that we submitted `refreshToken` to the Portal
92
131
  // and whether we are certain the rotated token was persisted. It lives next to
@@ -495,6 +534,7 @@ async function requestDeviceAuthorization(signal?: AbortSignal): Promise<{
495
534
  expiresInMs: number;
496
535
  intervalMs: number;
497
536
  }> {
537
+ const effectiveSignal = requestSignal(signal);
498
538
  const response = await fetch(`${resolvePortalBaseUrl()}/api/oauth/device/code`, {
499
539
  method: "POST",
500
540
  headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" },
@@ -503,14 +543,16 @@ async function requestDeviceAuthorization(signal?: AbortSignal): Promise<{
503
543
  scope: NOUS_OAUTH_SCOPE,
504
544
  }),
505
545
  redirect: "error",
506
- signal: requestSignal(signal),
546
+ signal: effectiveSignal,
507
547
  });
508
- if (!response.ok) throw tokenErrorFromPayload(response.status, await response.json().catch(() => ({})));
548
+ if (!response.ok) {
549
+ throw tokenErrorFromPayload(response.status, await readOAuthJsonOrEmpty(response, effectiveSignal));
550
+ }
509
551
  // A successful HTTP response may still carry an empty/HTML/non-JSON body.
510
552
  // Fall back to an empty object so the required-field check below produces the
511
553
  // clear "missing required fields" validation error instead of leaking a raw
512
554
  // JSON parser exception.
513
- const payload = (await response.json().catch(() => ({}))) as NousDeviceAuthorizationResponse;
555
+ const payload = await readOAuthJsonOrEmpty(response, effectiveSignal) as NousDeviceAuthorizationResponse;
514
556
  const userCode = nonEmptyString(payload.user_code);
515
557
  const deviceCode = nonEmptyString(payload.device_code);
516
558
  const verificationUri = nonEmptyString(payload.verification_uri_complete) ?? nonEmptyString(payload.verification_uri);
@@ -549,6 +591,7 @@ async function pollForToken(
549
591
  while (Date.now() < deadline) {
550
592
  if (signal?.aborted) throw new Error("Login cancelled");
551
593
  let response: Response;
594
+ const effectiveSignal = requestSignal(signal);
552
595
  try {
553
596
  response = await fetch(`${resolvePortalBaseUrl()}/api/oauth/token`, {
554
597
  method: "POST",
@@ -559,7 +602,7 @@ async function pollForToken(
559
602
  grant_type: "urn:ietf:params:oauth:grant-type:device_code",
560
603
  }),
561
604
  redirect: "error",
562
- signal: requestSignal(signal),
605
+ signal: effectiveSignal,
563
606
  });
564
607
  } catch (netErr) {
565
608
  // Genuine cancellation must abort immediately. Any other transport-level
@@ -571,13 +614,15 @@ async function pollForToken(
571
614
  if (await sleepUntilDeadline(waitMs)) continue;
572
615
  break;
573
616
  }
617
+ // Parse under the same deadline that covered the request headers. Keep the
618
+ // read outside the fetch retry catch: a bounded-reader error or caller
619
+ // cancellation is an observed response failure, not a safe poll retry.
620
+ const payload = await readOAuthJsonOrEmpty(response, effectiveSignal) as NousTokenResponse;
574
621
  // Parse once and pass the payload through to the failure path (review #8),
575
622
  // so we never try to re-read a body that has already been consumed.
576
623
  // Normalize a successful-but-non-object body (for example valid JSON
577
624
  // `null`) to an empty object so the required-field validation below
578
625
  // produces a terminal NousTokenError instead of a raw TypeError.
579
- const parsed = (await response.json().catch(() => ({}))) as unknown;
580
- const payload = (parsed && typeof parsed === "object" ? parsed : {}) as NousTokenResponse;
581
626
  if (Date.now() >= deadline) break;
582
627
  if (response.ok) return parseTokenPayload(payload, "");
583
628
  const error = payload.error;
@@ -670,6 +715,7 @@ export async function refreshNousToken(refreshToken: string, signal?: AbortSigna
670
715
  }
671
716
 
672
717
  let response: Response;
718
+ const effectiveSignal = requestSignal(signal);
673
719
  try {
674
720
  response = await fetch(`${baseUrl}/api/oauth/token`, {
675
721
  method: "POST",
@@ -683,7 +729,7 @@ export async function refreshNousToken(refreshToken: string, signal?: AbortSigna
683
729
  client_id: NOUS_OAUTH_CLIENT_ID,
684
730
  }),
685
731
  redirect: "error",
686
- signal: requestSignal(signal),
732
+ signal: effectiveSignal,
687
733
  });
688
734
  } catch (netErr) {
689
735
  // The request may have reached the server and rotated the token even on a
@@ -703,7 +749,6 @@ export async function refreshNousToken(refreshToken: string, signal?: AbortSigna
703
749
 
704
750
  if (!response.ok) {
705
751
  const status = response.status;
706
- const payload = await response.json().catch(() => ({}));
707
752
  // The request reached the Portal's token endpoint. A non-2xx response does
708
753
  // NOT establish that the single-use refresh token was not consumed: 429
709
754
  // rate limits, unknown/custom 4xx, and gateway-generated client-class
@@ -719,6 +764,7 @@ export async function refreshNousToken(refreshToken: string, signal?: AbortSigna
719
764
  // The pre-dispatch "submitted" intent is still on disk, which also
720
765
  // blocks replay; surface the original HTTP error below.
721
766
  }
767
+ const payload = await readOAuthJsonOrEmpty(response, effectiveSignal);
722
768
  throw tokenErrorFromPayload(status, payload);
723
769
  }
724
770
 
@@ -727,7 +773,10 @@ export async function refreshNousToken(refreshToken: string, signal?: AbortSigna
727
773
  // replay it. On success we deliberately LEAVE the intent as "submitted"
728
774
  // (the store clears it once the rotated token is persisted).
729
775
  try {
730
- const creds = parseTokenPayload((await response.json()) as NousTokenResponse, refreshToken);
776
+ const creds = parseTokenPayload(
777
+ (await readOAuthJson(response, effectiveSignal)) as NousTokenResponse,
778
+ refreshToken,
779
+ );
731
780
  return creds;
732
781
  } catch (e) {
733
782
  try {
@@ -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(),