@bitkyc08/opencodex 2.40.0 → 2.41.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.
- package/README.md +4 -0
- package/gui/dist/assets/index-B2YjLA-i.css +1 -0
- package/gui/dist/assets/{index-BHe2rl_C.js → index-aPup8CKb.js} +20 -20
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/meta.svg +1 -0
- package/package.json +4 -3
- package/src/adapters/cursor/catalog.ts +71 -29
- package/src/adapters/cursor/claude-id.ts +76 -0
- package/src/adapters/cursor/discovery.ts +16 -3
- package/src/adapters/cursor/effort-map.ts +27 -12
- package/src/adapters/google.ts +39 -2
- package/src/adapters/openai-responses.ts +14 -1
- package/src/cli/claude.ts +11 -2
- package/src/cli/connect.ts +7 -1
- package/src/cli/registry.ts +1 -1
- package/src/cli/status.ts +19 -4
- package/src/client/connect.ts +5 -1
- package/src/client/hub-client.ts +29 -5
- package/src/clients/config-export.ts +12 -2
- package/src/codex/catalog/aggregation.ts +8 -0
- package/src/codex/catalog/metadata.ts +5 -0
- package/src/codex/catalog/parsing.ts +2 -0
- package/src/codex/catalog/provider-fetch.ts +163 -26
- package/src/codex/catalog.ts +1 -1
- package/src/codex/convergence-types.ts +1 -0
- package/src/codex/desired-state.ts +18 -11
- package/src/combos/failover.ts +185 -6
- package/src/combos/index.ts +6 -0
- package/src/combos/resolve.ts +43 -6
- package/src/config.ts +5 -1
- package/src/generated/compatibility-version.json +85 -61
- package/src/generated/model-metadata.ts +1 -1
- package/src/grok/sync.ts +10 -2
- package/src/integrations/cursor-effort-table.ts +143 -0
- package/src/integrations/state.ts +1 -1
- package/src/integrations/writer.ts +2 -2
- package/src/lib/app-owned-memory-stores.ts +27 -8
- package/src/lib/bounded-body.ts +16 -1
- package/src/oauth/generic-account-failover.ts +2 -2
- package/src/oauth/index.ts +11 -0
- package/src/oauth/meta-muse.ts +235 -0
- package/src/providers/antigravity-models.ts +71 -13
- package/src/providers/command-code-efforts.ts +15 -0
- package/src/providers/free-directory.ts +4 -1
- package/src/providers/registry.ts +116 -8
- package/src/responses/code-mode-helper-compat.ts +4 -1
- package/src/responses/state.ts +5 -4
- package/src/server/auth-cors.ts +241 -56
- package/src/server/chat-completions.ts +11 -2
- package/src/server/chat-native.ts +30 -4
- package/src/server/claude-messages.ts +17 -3
- package/src/server/effort-row.ts +131 -0
- package/src/server/index.ts +67 -38
- package/src/server/management/api-key-rotation.ts +2 -1
- package/src/server/management/api-key-usage.ts +97 -43
- package/src/server/management/context.ts +3 -0
- package/src/server/management/cursor-integration-routes.ts +36 -7
- package/src/server/management/logs-usage-routes.ts +64 -87
- package/src/server/management/provider-routes.ts +218 -1
- package/src/server/management/route-registry.ts +1 -0
- package/src/server/management/usage-aggregate-cache.ts +464 -0
- package/src/server/management/usage-summary-cache.ts +4 -0
- package/src/server/models-capabilities.ts +60 -5
- package/src/server/responses/core.ts +61 -7
- package/src/types/config.ts +10 -1
- package/src/types/tools.ts +12 -9
- package/src/usage/expected-prices.ts +43 -7
- package/src/usage/ledger-scanner.ts +448 -0
- package/src/usage/log.ts +1 -1
- package/src/usage/summary.ts +915 -655
- package/src/web-search/index.ts +1 -1
- package/gui/dist/assets/index-CJSb3HPe.css +0 -1
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor's local-agent effort table, read from the installed bundle.
|
|
3
|
+
*
|
|
4
|
+
* Cursor Private Inference decides which model rows get a Reasoning control from a table
|
|
5
|
+
* compiled into extensions/cursor-agent-exec/dist/main.js, not from the gateway's
|
|
6
|
+
* reasoning_effort list (devlog 260902_cursor_bundle_effort_table/000). Reading that table
|
|
7
|
+
* from the install the dashboard already detects lets the prediction follow a Cursor update
|
|
8
|
+
* instead of a hand-copied mirror. Read-only, size-bounded, cached by (path, mtime, size);
|
|
9
|
+
* any parse failure yields null so the caller falls back to the static mirror.
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync, statSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import type { CursorInstall } from "./cursor-detect";
|
|
14
|
+
|
|
15
|
+
export interface CursorEffortFamily {
|
|
16
|
+
id: string;
|
|
17
|
+
pattern: RegExp;
|
|
18
|
+
/** [] = family matched but Cursor shows no control. */
|
|
19
|
+
ladder: readonly string[];
|
|
20
|
+
param?: "reasoning_effort" | "output_config.effort";
|
|
21
|
+
defaultValue?: string;
|
|
22
|
+
outputCap?: number;
|
|
23
|
+
requiresReasoningCapability: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CursorBareGpt5Rule {
|
|
27
|
+
pattern: RegExp;
|
|
28
|
+
ladder: readonly string[];
|
|
29
|
+
defaultValue: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CursorEffortTable {
|
|
33
|
+
families: readonly CursorEffortFamily[];
|
|
34
|
+
/** The bare gpt-5 / gpt-5.x rule that runs when no family matched. */
|
|
35
|
+
bareGpt5: CursorBareGpt5Rule | null;
|
|
36
|
+
version: string | null;
|
|
37
|
+
bundlePath: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const BUNDLE_MAX_BYTES = 32 * 1024 * 1024;
|
|
41
|
+
|
|
42
|
+
/** Bundle path under the install root cursor-detect reports. */
|
|
43
|
+
export function cursorAgentBundlePath(install: Pick<CursorInstall, "path">, platform: string = process.platform): string {
|
|
44
|
+
const tail = ["extensions", "cursor-agent-exec", "dist", "main.js"];
|
|
45
|
+
return platform === "darwin"
|
|
46
|
+
? join(install.path, "Contents", "Resources", "app", ...tail)
|
|
47
|
+
: join(install.path, "resources", "app", ...tail);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parse the family table out of the minified source:
|
|
52
|
+
* const w={param:"reasoning_effort",values:[...],defaultValue:"medium"};
|
|
53
|
+
* ...const T={...},k={...},S={...},b=[{id:"...",matches:e=>/.../u.test(e),effort:k,outputCap:128e3},...];
|
|
54
|
+
* Identifier names are minifier-assigned, so binding is by structure: every
|
|
55
|
+
* <ident>={param:"...",values:[...],defaultValue:"..."} is an effort constant, and a family's
|
|
56
|
+
* effort: is either such an identifier or an inline object.
|
|
57
|
+
*/
|
|
58
|
+
export function parseCursorEffortTable(source: string): Omit<CursorEffortTable, "version" | "bundlePath"> | null {
|
|
59
|
+
const constants = new Map<string, { param: string; values: string[]; defaultValue: string }>();
|
|
60
|
+
const constRe = /(?:const |,)([A-Za-z_$][\w$]*)=\{param:"(reasoning_effort|output_config\.effort)",values:\[([^\]]*)\],defaultValue:"([a-z]+)"\}/gu;
|
|
61
|
+
for (const m of source.matchAll(constRe)) {
|
|
62
|
+
constants.set(m[1]!, { param: m[2]!, values: splitStrings(m[3]!), defaultValue: m[4]! });
|
|
63
|
+
}
|
|
64
|
+
const tableStart = source.indexOf('=[{id:"anthropic-');
|
|
65
|
+
if (tableStart === -1) return null;
|
|
66
|
+
const tableEnd = source.indexOf("];", tableStart);
|
|
67
|
+
if (tableEnd === -1) return null;
|
|
68
|
+
const body = source.slice(tableStart + 2, tableEnd + 1);
|
|
69
|
+
const entryRe = /\{id:"([^"]+)",matches:e=>\/((?:\\\/|[^/])+)\/([a-z]*)\.test\(e\)((?:,(?:effort:(?:[A-Za-z_$][\w$]*|\{[^}]*\})|outputCap:[\de.]+|effortRequiresReasoningCapability:!0))*)\}/gu;
|
|
70
|
+
const families: CursorEffortFamily[] = [];
|
|
71
|
+
// Every "{id:" opener in the window must be consumed by entryRe. A build that adds a
|
|
72
|
+
// property to one family would otherwise drop that family silently and the caller would
|
|
73
|
+
// report a bundle-sourced "no control" for it instead of falling back to the mirror.
|
|
74
|
+
const openers = body.split('{id:"').length - 1;
|
|
75
|
+
for (const m of body.matchAll(entryRe)) {
|
|
76
|
+
let pattern: RegExp;
|
|
77
|
+
try { pattern = new RegExp(m[2]!, m[3]!); } catch { return null; }
|
|
78
|
+
const tail = m[4]!;
|
|
79
|
+
const effortRef = /effort:([A-Za-z_$][\w$]*)(?:,|$)/u.exec(tail)?.[1];
|
|
80
|
+
const inline = /effort:\{param:"([^"]+)",values:\[([^\]]*)\],defaultValue:"([a-z]+)"\}/u.exec(tail);
|
|
81
|
+
const effort = inline
|
|
82
|
+
? { param: inline[1]!, values: splitStrings(inline[2]!), defaultValue: inline[3]! }
|
|
83
|
+
: effortRef ? constants.get(effortRef) : undefined;
|
|
84
|
+
if (effortRef && !inline && !effort) return null; // unknown constant: structure changed
|
|
85
|
+
const cap = /outputCap:([\de.]+)/u.exec(tail)?.[1];
|
|
86
|
+
families.push({
|
|
87
|
+
id: m[1]!,
|
|
88
|
+
pattern,
|
|
89
|
+
ladder: effort?.values ?? [],
|
|
90
|
+
...(effort ? { param: effort.param as CursorEffortFamily["param"], defaultValue: effort.defaultValue } : {}),
|
|
91
|
+
...(cap ? { outputCap: Number(cap) } : {}),
|
|
92
|
+
requiresReasoningCapability: tail.includes("effortRequiresReasoningCapability:!0"),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (families.length === 0 || families.length !== openers) return null;
|
|
96
|
+
// The tested variable and the returned constant are minifier-assigned names; bind by shape.
|
|
97
|
+
const bareRe = /if\(\/(\^gpt-5[^/]+)\/([a-z]*)\.test\([A-Za-z_$][\w$]*\)\)return ([A-Za-z_$][\w$]*)\}/u.exec(source);
|
|
98
|
+
const bareConst = bareRe ? constants.get(bareRe[3]!) : undefined;
|
|
99
|
+
let bareGpt5: CursorBareGpt5Rule | null = null;
|
|
100
|
+
if (bareRe && bareConst) {
|
|
101
|
+
let pattern: RegExp;
|
|
102
|
+
try { pattern = new RegExp(bareRe[1]!, bareRe[2]!); } catch { return null; }
|
|
103
|
+
bareGpt5 = { pattern, ladder: bareConst.values, defaultValue: bareConst.defaultValue };
|
|
104
|
+
}
|
|
105
|
+
return { families, bareGpt5 };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function splitStrings(list: string): string[] {
|
|
109
|
+
return [...list.matchAll(/"([^"]+)"/gu)].map(m => m[1]!);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface CursorEffortTableDeps {
|
|
113
|
+
platform: string;
|
|
114
|
+
stat(path: string): { mtimeMs: number; size: number } | null;
|
|
115
|
+
readText(path: string): string | null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function realCursorEffortTableDeps(): CursorEffortTableDeps {
|
|
119
|
+
return {
|
|
120
|
+
platform: process.platform,
|
|
121
|
+
stat: path => { try { const s = statSync(path); return { mtimeMs: s.mtimeMs, size: s.size }; } catch { return null; } },
|
|
122
|
+
readText: path => { try { return readFileSync(path, "utf8"); } catch { return null; } },
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let cache: { key: string; table: CursorEffortTable | null } | null = null;
|
|
127
|
+
|
|
128
|
+
/** Table from the Private Inference install, else null (caller falls back to the static mirror). */
|
|
129
|
+
export function loadCursorEffortTable(install: CursorInstall | undefined, deps: CursorEffortTableDeps = realCursorEffortTableDeps()): CursorEffortTable | null {
|
|
130
|
+
if (!install) return null;
|
|
131
|
+
const bundlePath = cursorAgentBundlePath(install, deps.platform);
|
|
132
|
+
const st = deps.stat(bundlePath);
|
|
133
|
+
if (!st || st.size > BUNDLE_MAX_BYTES) return null;
|
|
134
|
+
const key = `${bundlePath}|${st.mtimeMs}|${st.size}`;
|
|
135
|
+
if (cache?.key === key) return cache.table;
|
|
136
|
+
const text = deps.readText(bundlePath);
|
|
137
|
+
const parsed = text ? parseCursorEffortTable(text) : null;
|
|
138
|
+
const table = parsed ? { ...parsed, version: install.version, bundlePath } : null;
|
|
139
|
+
cache = { key, table };
|
|
140
|
+
return table;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function resetCursorEffortTableCacheForTests(): void { cache = null; }
|
|
@@ -369,7 +369,7 @@ export function exportContextOf(input: {
|
|
|
369
369
|
* loopback, and every client we write into deserves the same answer the
|
|
370
370
|
* export command already gives.
|
|
371
371
|
*/
|
|
372
|
-
baseUrl: opencodeProxyBaseUrl(input.port, input.config.hostname),
|
|
372
|
+
baseUrl: opencodeProxyBaseUrl(input.port, input.config.hostname, input.config),
|
|
373
373
|
models: input.models,
|
|
374
374
|
config: input.config,
|
|
375
375
|
};
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
13
|
import { dirname } from "node:path";
|
|
14
14
|
import { EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../clients/config-export";
|
|
15
|
-
import {
|
|
15
|
+
import { shouldInjectApiAuthHeader } from "../codex/inject";
|
|
16
16
|
import type { OcxConfig } from "../types";
|
|
17
17
|
import { PARSE_FAILED, defaultIntegrationIO, loadTarget, parseConfig, type IntegrationIO } from "./config-io";
|
|
18
18
|
import {
|
|
@@ -290,7 +290,7 @@ function applyOrRefreshIntegration(
|
|
|
290
290
|
if (io.statKind(detectDir) !== "dir") {
|
|
291
291
|
return refuse(clientId, "not_installed", "absent", `${clientId} is not installed`);
|
|
292
292
|
}
|
|
293
|
-
if (isLoopbackOnly(clientId) &&
|
|
293
|
+
if (isLoopbackOnly(clientId) && shouldInjectApiAuthHeader(input.config)) {
|
|
294
294
|
return refuse(clientId, "non_loopback", classified.state,
|
|
295
295
|
`The generated ${clientId} integration is loopback-only and does not emit the admission header a non-loopback bind requires. Give it loopback access instead, through a tunnel or a local forwarder.`);
|
|
296
296
|
}
|
|
@@ -38,6 +38,10 @@ import {
|
|
|
38
38
|
discardRetainedUsageSnapshot,
|
|
39
39
|
retainedUsageSnapshotStats,
|
|
40
40
|
} from "../usage/log";
|
|
41
|
+
import {
|
|
42
|
+
discardRetainedUsageAggregate,
|
|
43
|
+
usageAggregateRetainedStats,
|
|
44
|
+
} from "../server/management/usage-aggregate-cache";
|
|
41
45
|
import {
|
|
42
46
|
cursorBlobRetainedStoreSnapshot,
|
|
43
47
|
evictOldestCursorBlobForBudget,
|
|
@@ -61,18 +65,33 @@ function ringSnapshot(metrics: { entries: number; bytes: number; oldestAt: numbe
|
|
|
61
65
|
};
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
/**
|
|
68
|
+
/** Legacy parsed tail and streaming aggregate share one stable public store id. */
|
|
65
69
|
function usageSnapshotRetainedStoreSnapshot(): RetainedStoreSnapshot {
|
|
66
|
-
const
|
|
70
|
+
const legacy = retainedUsageSnapshotStats();
|
|
71
|
+
const aggregate = usageAggregateRetainedStats();
|
|
72
|
+
const oldest = [legacy.oldestAt, aggregate.oldestAt]
|
|
73
|
+
.filter((value): value is number => value !== null)
|
|
74
|
+
.sort((a, b) => a - b)[0] ?? null;
|
|
67
75
|
return {
|
|
68
|
-
count:
|
|
69
|
-
bytes:
|
|
70
|
-
evictableBytes:
|
|
71
|
-
pinnedBytes:
|
|
72
|
-
oldestAt:
|
|
76
|
+
count: legacy.count + aggregate.count,
|
|
77
|
+
bytes: legacy.bytes + aggregate.bytes,
|
|
78
|
+
evictableBytes: legacy.bytes + aggregate.evictableBytes,
|
|
79
|
+
pinnedBytes: aggregate.pinnedBytes,
|
|
80
|
+
oldestAt: oldest,
|
|
73
81
|
};
|
|
74
82
|
}
|
|
75
83
|
|
|
84
|
+
function evictOldestUsageSnapshot(): number {
|
|
85
|
+
const legacy = retainedUsageSnapshotStats();
|
|
86
|
+
const aggregate = usageAggregateRetainedStats();
|
|
87
|
+
if (legacy.bytes > 0
|
|
88
|
+
&& (aggregate.evictableBytes === 0
|
|
89
|
+
|| (legacy.oldestAt ?? Number.POSITIVE_INFINITY) <= (aggregate.oldestAt ?? Number.POSITIVE_INFINITY))) {
|
|
90
|
+
return discardRetainedUsageSnapshot();
|
|
91
|
+
}
|
|
92
|
+
return discardRetainedUsageAggregate();
|
|
93
|
+
}
|
|
94
|
+
|
|
76
95
|
function providerDebugSnapshot(): RetainedStoreSnapshot {
|
|
77
96
|
return ringSnapshot(debugBufferMetrics());
|
|
78
97
|
}
|
|
@@ -154,7 +173,7 @@ export const APP_OWNED_RETAINED_STORE_REGISTRATIONS = [
|
|
|
154
173
|
id: "usage_snapshot",
|
|
155
174
|
category: "caches",
|
|
156
175
|
snapshot: usageSnapshotRetainedStoreSnapshot,
|
|
157
|
-
evictOldest:
|
|
176
|
+
evictOldest: evictOldestUsageSnapshot,
|
|
158
177
|
},
|
|
159
178
|
{
|
|
160
179
|
id: "cursor_blobs",
|
package/src/lib/bounded-body.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { idleDeadline } from "./abort";
|
|
2
|
+
|
|
1
3
|
/** Maximum number of response-body bytes that may be retained for an error. */
|
|
2
4
|
export const BOUNDED_BODY_MAX_BYTES = 65_536;
|
|
3
5
|
|
|
@@ -49,6 +51,8 @@ export interface BoundedBytesOptions {
|
|
|
49
51
|
signal?: AbortSignal;
|
|
50
52
|
/** Maximum number of raw bytes retained from the response body. */
|
|
51
53
|
maxBytes: number;
|
|
54
|
+
/** Deadline between non-empty raw chunks. Omitted means no body-read deadline. */
|
|
55
|
+
inactivityTimeoutMs?: number;
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
export interface BoundedBytesResult {
|
|
@@ -136,6 +140,15 @@ export async function readBoundedResponseBytes(
|
|
|
136
140
|
let retainedBytes = 0;
|
|
137
141
|
let mustCancel = false;
|
|
138
142
|
let cancelReason: unknown;
|
|
143
|
+
const inactivityReason = new DOMException("Response body stalled", "TimeoutError");
|
|
144
|
+
let rejectForInactivity: ((reason: unknown) => void) | undefined;
|
|
145
|
+
const inactive = new Promise<never>((_resolve, reject) => {
|
|
146
|
+
rejectForInactivity = reject;
|
|
147
|
+
});
|
|
148
|
+
const inactivity = options.inactivityTimeoutMs === undefined
|
|
149
|
+
? null
|
|
150
|
+
: idleDeadline(options.inactivityTimeoutMs, () => rejectForInactivity?.(inactivityReason));
|
|
151
|
+
inactivity?.reset();
|
|
139
152
|
|
|
140
153
|
let rejectForAbort: ((reason: unknown) => void) | undefined;
|
|
141
154
|
const aborted = new Promise<never>((_resolve, reject) => {
|
|
@@ -151,7 +164,7 @@ export async function readBoundedResponseBytes(
|
|
|
151
164
|
const read = reader.read();
|
|
152
165
|
// Observe a late read rejection when abort/cancellation wins the race.
|
|
153
166
|
void read.catch(() => undefined);
|
|
154
|
-
const outcome = await Promise.race([read, aborted]);
|
|
167
|
+
const outcome = await Promise.race([read, aborted, inactive]);
|
|
155
168
|
if (signal?.aborted) {
|
|
156
169
|
mustCancel = true;
|
|
157
170
|
cancelReason = signal.reason;
|
|
@@ -163,6 +176,7 @@ export async function readBoundedResponseBytes(
|
|
|
163
176
|
return { bytes: retained.subarray(0, retainedBytes), oversized: false };
|
|
164
177
|
}
|
|
165
178
|
if (!value || value.byteLength === 0) continue;
|
|
179
|
+
inactivity?.reset();
|
|
166
180
|
|
|
167
181
|
if (value.byteLength > maxBytes - retainedBytes) {
|
|
168
182
|
mustCancel = true;
|
|
@@ -187,6 +201,7 @@ export async function readBoundedResponseBytes(
|
|
|
187
201
|
cancelReason = error;
|
|
188
202
|
throw error;
|
|
189
203
|
} finally {
|
|
204
|
+
inactivity?.cancel();
|
|
190
205
|
signal?.removeEventListener("abort", onAbort);
|
|
191
206
|
if (mustCancel) cancelWithoutWaiting(reader, cancelReason);
|
|
192
207
|
try {
|
|
@@ -202,11 +202,11 @@ export function rotateGenericOAuthAccountOn429(
|
|
|
202
202
|
// A single stored account has nowhere to go; rotating to itself would just replay the 429.
|
|
203
203
|
if (!set || set.accounts.length < 2) return null;
|
|
204
204
|
|
|
205
|
-
const parsed = parseRetryAfterMs(retryAfterHeader, now);
|
|
205
|
+
const parsed = parseRetryAfterMs(retryAfterHeader, now, { preserveImmediate: true });
|
|
206
206
|
// An account whose allowance is provably spent gets a reset-aligned cooldown instead of
|
|
207
207
|
// the default minute: retrying it every 60s until the window rolls over is pure waste.
|
|
208
208
|
// A Retry-After from upstream still wins — it is the server's own instruction.
|
|
209
|
-
const exhausted = parsed ===
|
|
209
|
+
const exhausted = parsed === undefined ? exhaustedCooldownMs(providerName, failedAccountId, now) : null;
|
|
210
210
|
const cooldownMs = exhausted ?? Math.min(parsed ?? DEFAULT_COOLDOWN_MS, MAX_COOLDOWN_MS);
|
|
211
211
|
health.set(healthKey(providerName, failedAccountId), {
|
|
212
212
|
cooldownUntil: now + cooldownMs,
|
package/src/oauth/index.ts
CHANGED
|
@@ -39,6 +39,7 @@ import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity"
|
|
|
39
39
|
import { loginCursor, refreshCursorToken } from "./cursor";
|
|
40
40
|
import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot";
|
|
41
41
|
import { loginCommandCode, refreshCommandCodeToken } from "./command-code";
|
|
42
|
+
import { loginMetaMuse, refreshMetaMuseToken } from "./meta-muse";
|
|
42
43
|
import { ANTIGRAVITY_REQUEST_UA } from "../adapters/google-antigravity-wire";
|
|
43
44
|
import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive";
|
|
44
45
|
import { apiKeyPoolEntryId, sanitizeApiKeyValue } from "../providers/api-keys";
|
|
@@ -228,6 +229,16 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
|
|
|
228
229
|
providerConfig: oauthConfig("kimi"),
|
|
229
230
|
defaultModel: oauthDefaultModel("kimi"),
|
|
230
231
|
},
|
|
232
|
+
"meta-muse": {
|
|
233
|
+
login: ctrl => loginMetaMuse(ctrl),
|
|
234
|
+
refresh: refreshMetaMuseToken,
|
|
235
|
+
providerConfig: oauthConfig("meta-muse"),
|
|
236
|
+
defaultModel: oauthDefaultModel("meta-muse"),
|
|
237
|
+
// Static API key that Meta scopes to its own CLI. Never generate unattended traffic
|
|
238
|
+
// on it — same posture as anthropic, for the same reason: the vendor restricts use
|
|
239
|
+
// outside its own client, so every exchange stays attributable to a user action.
|
|
240
|
+
defaultRefreshPolicy: "disabled",
|
|
241
|
+
},
|
|
231
242
|
nous: {
|
|
232
243
|
// Nous Portal device-grant login (RFC 8628) against portal.nousresearch.com.
|
|
233
244
|
// The access token is the per-request inference JWT (scope inference:invoke).
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Meta Muse Code credential import.
|
|
3
|
+
*
|
|
4
|
+
* The Muse Code CLI signs in through a browser device-approval flow and stores the
|
|
5
|
+
* result in two places: `~/.config/muse/auth.json` is a POINTER carrying no secret, and
|
|
6
|
+
* the secret itself lives in the macOS Keychain under service
|
|
7
|
+
* `ai.meta.dev.credentials`, account `meta`.
|
|
8
|
+
*
|
|
9
|
+
* Two measured facts shape this module (devlog/_plan/260903_muse_spark_plan_oauth/003):
|
|
10
|
+
*
|
|
11
|
+
* 1. The Keychain payload holds BOTH an `access_token` and an `api_key`, and only the
|
|
12
|
+
* `api_key` authenticates the Model API — the OAuth access token returns 401
|
|
13
|
+
* `invalid_api_key`. So this is a static-key credential, not a refreshable one.
|
|
14
|
+
* 2. Meta scopes that credential to the Muse Code CLI in writing. Reusing it here is an
|
|
15
|
+
* UNSUPPORTED path the repository owner opted into deliberately, which is why the
|
|
16
|
+
* warning below fires before anything is read and why the provider sits in the GUI's
|
|
17
|
+
* HIGH_RISK ToS map.
|
|
18
|
+
*
|
|
19
|
+
* This module never spawns the CLI. A login that finds no credential explains what to
|
|
20
|
+
* run rather than running it: `muse login` is interactive with no machine-readable mode,
|
|
21
|
+
* so a spawned child could outlive cancellation, and polling for the pointer file would
|
|
22
|
+
* be satisfied instantly by the one already on disk — reimporting the OLD account on a
|
|
23
|
+
* force-login.
|
|
24
|
+
*/
|
|
25
|
+
import { homedir } from "node:os";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { sanitizeApiKeyValue } from "../providers/api-keys";
|
|
28
|
+
import type { OAuthController, OAuthCredentials } from "./types";
|
|
29
|
+
|
|
30
|
+
const MUSE_POINTER_PATH = join(homedir(), ".config", "muse", "auth.json");
|
|
31
|
+
const KEYCHAIN_SERVICE = "ai.meta.dev.credentials";
|
|
32
|
+
const KEYCHAIN_ACCOUNT = "meta";
|
|
33
|
+
const MODELS_URL = "https://api.meta.ai/v1/models";
|
|
34
|
+
const VALIDATE_TIMEOUT_MS = 10_000;
|
|
35
|
+
const KEYCHAIN_TIMEOUT_MS = 5_000;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Shown BEFORE any credential is read.
|
|
39
|
+
*
|
|
40
|
+
* `login-cli.ts` passes `onProgress` straight to `console.log` and never reads the
|
|
41
|
+
* registry note, so this is the CLI's only warning surface. The GUI ignores it because
|
|
42
|
+
* `OAuthTosWarningModal` has already been acknowledged by then.
|
|
43
|
+
*/
|
|
44
|
+
const CONSENT_WARNING = [
|
|
45
|
+
"Meta scopes the Muse Code credential to the Muse Code CLI.",
|
|
46
|
+
"Using it here is UNSUPPORTED: Meta does not authorize subscription coverage outside its own CLI,",
|
|
47
|
+
"how these calls settle is not observable from the API, and you should treat every call as billable.",
|
|
48
|
+
"The imported key is copied into OpenCodex's auth store (~/.opencodex/auth.json, 0600).",
|
|
49
|
+
"Supported alternative: the meta-model provider with your own key (META_MODEL_API_KEY).",
|
|
50
|
+
].join(" ");
|
|
51
|
+
|
|
52
|
+
/** The Keychain payload. `access_token` is deliberately unused — it 401s (003 §B). */
|
|
53
|
+
interface MuseKeychainSecret {
|
|
54
|
+
api_key?: unknown;
|
|
55
|
+
access_token?: unknown;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface MusePointer {
|
|
59
|
+
providers?: { meta?: { mechanism?: unknown; storage?: unknown; user_email?: unknown } };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Injected so tests never touch the real Keychain, filesystem, platform, or network. */
|
|
63
|
+
export interface MuseImportDeps {
|
|
64
|
+
platform?: string;
|
|
65
|
+
readPointer?: () => Promise<string | null>;
|
|
66
|
+
readKeychain?: (signal?: AbortSignal) => Promise<string | null>;
|
|
67
|
+
fetchImpl?: typeof fetch;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function defaultReadPointer(): Promise<string | null> {
|
|
71
|
+
try {
|
|
72
|
+
return await Bun.file(MUSE_POINTER_PATH).text();
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* `security` can block indefinitely — the Keychain may raise an interactive approval
|
|
80
|
+
* prompt, and on a headless or locked machine nobody answers it. Without a deadline the
|
|
81
|
+
* login would hang before the validation timeout below is even created, so the bound
|
|
82
|
+
* lives here rather than only around the fetch.
|
|
83
|
+
*/
|
|
84
|
+
async function defaultReadKeychain(signal?: AbortSignal): Promise<string | null> {
|
|
85
|
+
const deadline = signal
|
|
86
|
+
? AbortSignal.any([signal, AbortSignal.timeout(KEYCHAIN_TIMEOUT_MS)])
|
|
87
|
+
: AbortSignal.timeout(KEYCHAIN_TIMEOUT_MS);
|
|
88
|
+
let proc: Bun.Subprocess<"ignore", "pipe", "pipe"> | undefined;
|
|
89
|
+
try {
|
|
90
|
+
proc = Bun.spawn(
|
|
91
|
+
["security", "find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", KEYCHAIN_ACCOUNT, "-w"],
|
|
92
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
93
|
+
);
|
|
94
|
+
const child = proc;
|
|
95
|
+
const finished = Promise.all([new Response(child.stdout).text(), child.exited]);
|
|
96
|
+
const timedOut = new Promise<null>((resolve) => {
|
|
97
|
+
if (deadline.aborted) { resolve(null); return; }
|
|
98
|
+
deadline.addEventListener("abort", () => resolve(null), { once: true });
|
|
99
|
+
});
|
|
100
|
+
const settled = await Promise.race([finished, timedOut]);
|
|
101
|
+
if (settled === null) return null;
|
|
102
|
+
const [out, code] = settled;
|
|
103
|
+
if (code !== 0) return null;
|
|
104
|
+
const trimmed = out.trim();
|
|
105
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
} finally {
|
|
109
|
+
// A prompt still on screen keeps the child alive after the race resolves.
|
|
110
|
+
if (proc && proc.exitCode === null) { try { proc.kill(); } catch { /* already gone */ } }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const INSTALL_HINT =
|
|
115
|
+
"Install it from https://dev.meta.ai/install.sh, run `muse login`, then retry.";
|
|
116
|
+
|
|
117
|
+
function normalizedEmail(value: unknown): string | undefined {
|
|
118
|
+
if (typeof value !== "string") return undefined;
|
|
119
|
+
const trimmed = value.trim().toLowerCase();
|
|
120
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Import the credential the Muse Code CLI already holds.
|
|
125
|
+
*
|
|
126
|
+
* Every refusal names what the user should do. None of them includes the credential.
|
|
127
|
+
*/
|
|
128
|
+
export async function loginMetaMuse(
|
|
129
|
+
ctrl: OAuthController = {},
|
|
130
|
+
deps: MuseImportDeps = {},
|
|
131
|
+
): Promise<OAuthCredentials> {
|
|
132
|
+
// Before ANY read: the CLI has no other warning surface.
|
|
133
|
+
ctrl.onProgress?.(CONSENT_WARNING);
|
|
134
|
+
|
|
135
|
+
const platform = deps.platform ?? process.platform;
|
|
136
|
+
if (platform !== "darwin") {
|
|
137
|
+
throw new Error(
|
|
138
|
+
"Meta Muse Code login is macOS-only: the CLI stores its credential in the macOS Keychain, "
|
|
139
|
+
+ "and no other platform's storage has been verified. Use the meta-model provider with your own key instead.",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const pointerRaw = await (deps.readPointer ?? defaultReadPointer)();
|
|
144
|
+
if (pointerRaw === null) {
|
|
145
|
+
throw new Error(`Muse Code CLI credential not found at ${MUSE_POINTER_PATH}. ${INSTALL_HINT}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let pointer: MusePointer;
|
|
149
|
+
try {
|
|
150
|
+
pointer = JSON.parse(pointerRaw) as MusePointer;
|
|
151
|
+
} catch {
|
|
152
|
+
throw new Error(`Muse Code credential file at ${MUSE_POINTER_PATH} is not valid JSON. Run \`muse login\` to rewrite it.`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const meta = pointer.providers?.meta;
|
|
156
|
+
if (!meta || meta.mechanism !== "oauth") {
|
|
157
|
+
throw new Error("The Muse Code credential file has no signed-in Meta account. Run `muse login`, then retry.");
|
|
158
|
+
}
|
|
159
|
+
// A different storage backend is a shape we have not measured; refuse rather than guess.
|
|
160
|
+
if (meta.storage !== "keychain") {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`Muse Code stored its credential with an unsupported backend (${String(meta.storage)}); only the macOS Keychain is verified.`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const secretRaw = await (deps.readKeychain ?? defaultReadKeychain)(ctrl.signal);
|
|
167
|
+
if (secretRaw === null) {
|
|
168
|
+
throw new Error(
|
|
169
|
+
"Could not read the Muse Code credential from the macOS Keychain within 5s. Approve the Keychain prompt, or run `muse login` again.",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
let secret: MuseKeychainSecret;
|
|
174
|
+
try {
|
|
175
|
+
secret = JSON.parse(secretRaw) as MuseKeychainSecret;
|
|
176
|
+
} catch {
|
|
177
|
+
throw new Error("The Muse Code Keychain entry is not valid JSON. Run `muse login` to rewrite it.");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// access_token is present but 401s against the Model API (003 §B) — never fall back to it.
|
|
181
|
+
const apiKey = sanitizeApiKeyValue(secret.api_key);
|
|
182
|
+
if (!apiKey) {
|
|
183
|
+
throw new Error("The Muse Code Keychain entry carries no usable API key. Run `muse login` again.");
|
|
184
|
+
}
|
|
185
|
+
if (!/^LLM\|\d+\|[A-Za-z0-9_-]{10,}$/.test(apiKey)) {
|
|
186
|
+
throw new Error("The Muse Code credential is not in the expected Meta API key format. Run `muse login` again.");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
ctrl.onProgress?.("Validating the imported Meta credential…");
|
|
190
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
191
|
+
// ctrl.signal is OPTIONAL and the CLI controller supplies none: AbortSignal.any([undefined])
|
|
192
|
+
// throws a TypeError, which would fail every CLI login right after the warning printed.
|
|
193
|
+
const signal = ctrl.signal
|
|
194
|
+
? AbortSignal.any([ctrl.signal, AbortSignal.timeout(VALIDATE_TIMEOUT_MS)])
|
|
195
|
+
: AbortSignal.timeout(VALIDATE_TIMEOUT_MS);
|
|
196
|
+
let response: Response;
|
|
197
|
+
try {
|
|
198
|
+
response = await fetchImpl(MODELS_URL, {
|
|
199
|
+
headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
|
|
200
|
+
signal,
|
|
201
|
+
});
|
|
202
|
+
} catch (error) {
|
|
203
|
+
if (ctrl.signal?.aborted) throw ctrl.signal.reason ?? new DOMException("Meta Muse login aborted", "AbortError");
|
|
204
|
+
throw new Error(`Could not reach the Meta Model API to validate the credential: ${(error as Error).message}`);
|
|
205
|
+
}
|
|
206
|
+
if (!response.ok) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`The Muse Code credential was rejected by the Meta Model API (HTTP ${response.status}). Run \`muse login\` again.`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
access: apiKey,
|
|
214
|
+
// Static key: there is nothing to exchange, so refresh carries the same value.
|
|
215
|
+
refresh: apiKey,
|
|
216
|
+
expires: Number.MAX_SAFE_INTEGER,
|
|
217
|
+
// `email`, not `accountId`: the account list masks email for display, and store.ts
|
|
218
|
+
// already falls back to it for slot identity, so multi-account still works.
|
|
219
|
+
...(normalizedEmail(meta.user_email) ? { email: normalizedEmail(meta.user_email) } : {}),
|
|
220
|
+
source: "local-cli",
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Static-key refresh, exactly like Command Code's.
|
|
226
|
+
*
|
|
227
|
+
* This deliberately does NOT re-read the Keychain. Generic refresh writes its result into
|
|
228
|
+
* the slot being refreshed, so if the user ran `muse login` with a DIFFERENT account in
|
|
229
|
+
* between, a re-import would silently overwrite one stored identity with another. Only an
|
|
230
|
+
* explicit login may import.
|
|
231
|
+
*/
|
|
232
|
+
export async function refreshMetaMuseToken(apiKey: string): Promise<OAuthCredentials> {
|
|
233
|
+
if (!apiKey) throw new Error("Meta Muse Code API key missing; run `ocx login meta-muse`");
|
|
234
|
+
return { access: apiKey, refresh: apiKey, expires: Number.MAX_SAFE_INTEGER, source: "local-cli" };
|
|
235
|
+
}
|