@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.
- package/README.md +55 -0
- package/gui/dist/assets/index-DUCH59lJ.css +1 -0
- package/gui/dist/assets/index-DUyQeU1j.js +76 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/command-code.ts +46 -6
- package/src/adapters/cursor/request-builder.ts +54 -10
- package/src/adapters/cursor/tool-definitions.ts +24 -0
- package/src/adapters/kiro.ts +10 -1
- package/src/adapters/openai-chat-url.ts +11 -0
- package/src/adapters/openai-chat.ts +7 -4
- package/src/adapters/openai-responses-url.ts +14 -0
- package/src/adapters/openai-responses.ts +111 -2
- package/src/adapters/tool-catalog-nudge.ts +26 -4
- package/src/bridge.ts +50 -3
- package/src/cli/init.ts +4 -17
- package/src/codex/auth-api.ts +2 -74
- package/src/codex/catalog/effort.ts +2 -1
- package/src/codex/catalog/metadata.ts +62 -12
- package/src/codex/catalog/native-models.ts +27 -0
- package/src/codex/catalog/parsing.ts +27 -8
- package/src/codex/catalog/provider-fetch.ts +47 -5
- package/src/codex/catalog/sync.ts +31 -8
- package/src/codex/catalog.ts +1 -1
- package/src/codex/features.ts +14 -3
- package/src/codex/model-cache.ts +7 -1
- package/src/codex/native-main-claim.ts +13 -2
- package/src/config.ts +79 -4
- package/src/generated/compatibility-version.json +74 -46
- package/src/lab/ledger/store.ts +0 -18
- package/src/lab/subject/installation-salt.ts +13 -2
- package/src/lib/app-owned-memory-stores.ts +22 -0
- package/src/lib/tool-argument-integers.ts +158 -0
- package/src/oauth/nous.ts +58 -9
- package/src/providers/base-url-choices.ts +10 -0
- package/src/providers/command-code-efforts.ts +18 -0
- package/src/providers/model-rename-migration.ts +202 -0
- package/src/providers/model-rename-startup.ts +28 -0
- package/src/providers/openai-tier-startup.ts +31 -2
- package/src/providers/quota.ts +9 -2
- package/src/providers/registry.ts +17 -10
- package/src/responses/spill-store.ts +5 -1
- package/src/responses/state.ts +50 -2
- package/src/router.ts +12 -1
- package/src/server/index.ts +3 -2
- package/src/server/management/api-key-usage.ts +31 -5
- package/src/server/management/config-routes.ts +51 -16
- package/src/server/management/logs-usage-routes.ts +48 -10
- package/src/server/management/provider-routes.ts +2 -1
- package/src/server/management/usage-summary-cache.ts +7 -1
- package/src/server/responses/collaboration.ts +12 -2
- package/src/server/responses/core.ts +33 -17
- package/src/server/responses/fetch-helpers.ts +12 -1
- package/src/server/responses/ws-upstream.ts +199 -0
- package/src/server/startup-health-cache.ts +12 -0
- package/src/usage/log.ts +430 -12
- package/src/vision/index.ts +25 -4
- package/src/vision/timeout-bounds.ts +9 -0
- package/gui/dist/assets/index-BNVYzdn0.css +0 -1
- 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:
|
|
546
|
+
signal: effectiveSignal,
|
|
507
547
|
});
|
|
508
|
-
if (!response.ok)
|
|
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 =
|
|
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:
|
|
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:
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
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;
|
package/src/providers/quota.ts
CHANGED
|
@@ -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 (
|
|
788
|
-
: `Balance (
|
|
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(),
|