@love-moon/ai-sdk 0.3.2 → 0.4.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/CHANGELOG.md +11 -0
- package/dist/built-in-backends.d.ts +1 -0
- package/dist/built-in-backends.js +6 -0
- package/dist/client.d.ts +15 -0
- package/dist/client.js +103 -1
- package/dist/external-provider-registry.js +4 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/manager/account.d.ts +6 -0
- package/dist/manager/account.js +121 -0
- package/dist/manager/auth-parser.d.ts +27 -0
- package/dist/manager/auth-parser.js +54 -0
- package/dist/manager/config.d.ts +6 -0
- package/dist/manager/config.js +32 -0
- package/dist/manager/index.d.ts +12 -0
- package/dist/manager/index.js +11 -0
- package/dist/manager/install.d.ts +9 -0
- package/dist/manager/install.js +117 -0
- package/dist/manager/manager.d.ts +51 -0
- package/dist/manager/manager.js +105 -0
- package/dist/manager/network.d.ts +8 -0
- package/dist/manager/network.js +46 -0
- package/dist/manager/paths.d.ts +6 -0
- package/dist/manager/paths.js +16 -0
- package/dist/manager/quota/cache.d.ts +9 -0
- package/dist/manager/quota/cache.js +33 -0
- package/dist/manager/quota/claude.d.ts +19 -0
- package/dist/manager/quota/claude.js +193 -0
- package/dist/manager/quota/codex.d.ts +27 -0
- package/dist/manager/quota/codex.js +182 -0
- package/dist/manager/quota/copilot.d.ts +64 -0
- package/dist/manager/quota/copilot.js +718 -0
- package/dist/manager/quota/external.d.ts +29 -0
- package/dist/manager/quota/external.js +176 -0
- package/dist/manager/quota/headers.d.ts +5 -0
- package/dist/manager/quota/headers.js +29 -0
- package/dist/manager/quota/kimi.d.ts +24 -0
- package/dist/manager/quota/kimi.js +230 -0
- package/dist/manager/types.d.ts +166 -0
- package/dist/manager/types.js +1 -0
- package/dist/providers/chat-web-session.d.ts +218 -0
- package/dist/providers/chat-web-session.js +584 -0
- package/dist/providers/claude-agent-sdk-session.d.ts +35 -1
- package/dist/providers/claude-agent-sdk-session.js +109 -1
- package/dist/providers/codex-app-server-session.d.ts +107 -0
- package/dist/providers/codex-app-server-session.js +479 -9
- package/dist/providers/copilot-sdk-session.d.ts +1 -1
- package/dist/resume/chat-web.d.ts +20 -0
- package/dist/resume/chat-web.js +44 -0
- package/dist/resume/index.js +2 -0
- package/dist/session-factory.d.ts +3 -1
- package/dist/session-factory.js +17 -4
- package/dist/shared.d.ts +159 -0
- package/dist/shared.js +111 -0
- package/dist/transports/codex-app-server-transport.d.ts +1 -0
- package/dist/transports/codex-app-server-transport.js +45 -1
- package/dist/worker.js +19 -5
- package/package.json +10 -3
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 3000;
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
function execCapture(cmd, args, timeoutMs) {
|
|
8
|
+
return new Promise((resolve) => {
|
|
9
|
+
const proc = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
10
|
+
let stdout = "";
|
|
11
|
+
let stderr = "";
|
|
12
|
+
let settled = false;
|
|
13
|
+
const timer = setTimeout(() => {
|
|
14
|
+
if (!settled) {
|
|
15
|
+
settled = true;
|
|
16
|
+
proc.kill("SIGKILL");
|
|
17
|
+
resolve({ code: null, stdout, stderr, error: new Error("timeout") });
|
|
18
|
+
}
|
|
19
|
+
}, timeoutMs);
|
|
20
|
+
proc.stdout?.on("data", (b) => (stdout += b.toString()));
|
|
21
|
+
proc.stderr?.on("data", (b) => (stderr += b.toString()));
|
|
22
|
+
proc.on("error", (err) => {
|
|
23
|
+
if (settled)
|
|
24
|
+
return;
|
|
25
|
+
settled = true;
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
resolve({ code: null, stdout, stderr, error: err });
|
|
28
|
+
});
|
|
29
|
+
proc.on("close", (code) => {
|
|
30
|
+
if (settled)
|
|
31
|
+
return;
|
|
32
|
+
settled = true;
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
resolve({ code, stdout, stderr });
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
async function which(bin, timeoutMs) {
|
|
39
|
+
const res = await execCapture("which", [bin], timeoutMs);
|
|
40
|
+
if (res.code !== 0)
|
|
41
|
+
return undefined;
|
|
42
|
+
const p = res.stdout.trim().split("\n")[0];
|
|
43
|
+
return p || undefined;
|
|
44
|
+
}
|
|
45
|
+
export async function checkInstall(tool, opts = {}) {
|
|
46
|
+
if (tool === "copilot") {
|
|
47
|
+
return checkCopilotSdkInstall();
|
|
48
|
+
}
|
|
49
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
50
|
+
const bin = tool; // codex / claude
|
|
51
|
+
const path = await which(bin, timeoutMs);
|
|
52
|
+
if (!path) {
|
|
53
|
+
return { installed: false, error: `${bin} not found in PATH` };
|
|
54
|
+
}
|
|
55
|
+
const res = await execCapture(bin, ["--version"], timeoutMs);
|
|
56
|
+
if (res.error || res.code !== 0) {
|
|
57
|
+
return {
|
|
58
|
+
installed: true,
|
|
59
|
+
path,
|
|
60
|
+
error: res.error?.message ?? (res.stderr.trim() || `exit ${res.code}`),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const rawLine = (res.stdout.trim() || res.stderr.trim()).split("\n")[0] ?? "";
|
|
64
|
+
return { installed: true, path, version: extractSemver(rawLine) ?? rawLine };
|
|
65
|
+
}
|
|
66
|
+
/** Pull the first semver-looking token out of an arbitrary `--version` line. */
|
|
67
|
+
function extractSemver(line) {
|
|
68
|
+
const m = /\d+\.\d+(?:\.\d+)?(?:[-+][\w.]+)?/.exec(line);
|
|
69
|
+
return m ? m[0] : undefined;
|
|
70
|
+
}
|
|
71
|
+
export async function checkInstallAll(opts) {
|
|
72
|
+
const [codex, claude, kimi, copilot] = await Promise.all([
|
|
73
|
+
checkInstall("codex", opts),
|
|
74
|
+
checkInstall("claude", opts),
|
|
75
|
+
checkInstall("kimi", opts),
|
|
76
|
+
checkInstall("copilot", opts),
|
|
77
|
+
]);
|
|
78
|
+
return { codex, claude, kimi, copilot };
|
|
79
|
+
}
|
|
80
|
+
async function checkCopilotSdkInstall() {
|
|
81
|
+
try {
|
|
82
|
+
const entryPath = require.resolve("@github/copilot-sdk");
|
|
83
|
+
const version = await findPackageVersionForEntry(entryPath, "@github/copilot-sdk");
|
|
84
|
+
return {
|
|
85
|
+
installed: true,
|
|
86
|
+
path: "@github/copilot-sdk",
|
|
87
|
+
version: version ?? "installed",
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
return {
|
|
92
|
+
installed: false,
|
|
93
|
+
error: `@github/copilot-sdk not available: ${err?.message ?? err}`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Exported for tests. */
|
|
98
|
+
export async function findPackageVersionForEntry(entryPath, packageName) {
|
|
99
|
+
let dir = dirname(entryPath);
|
|
100
|
+
while (true) {
|
|
101
|
+
const pkgPath = join(dir, "package.json");
|
|
102
|
+
try {
|
|
103
|
+
const payload = JSON.parse(await readFile(pkgPath, "utf8"));
|
|
104
|
+
if (payload.name === packageName) {
|
|
105
|
+
const version = typeof payload.version === "string" ? payload.version.trim() : "";
|
|
106
|
+
return version || undefined;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Walk upward until we hit the package root or filesystem root.
|
|
111
|
+
}
|
|
112
|
+
const parent = dirname(dir);
|
|
113
|
+
if (parent === dir)
|
|
114
|
+
return undefined;
|
|
115
|
+
dir = parent;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type GetCodexQuotaOptions } from "./quota/codex.js";
|
|
2
|
+
import { type GetClaudeQuotaOptions } from "./quota/claude.js";
|
|
3
|
+
import { type GetKimiQuotaOptions } from "./quota/kimi.js";
|
|
4
|
+
import { type GetCopilotQuotaOptions } from "./quota/copilot.js";
|
|
5
|
+
import { type GetExternalQuotaListOptions, type GetExternalQuotaOptions } from "./quota/external.js";
|
|
6
|
+
import type { AiManagerConfig, CodexAccount, CodexQuota, ClaudeQuota, ExternalQuota, ExternalQuotaList, KimiQuota, CopilotQuota, InstallStatus, NetworkStatus, SwitchResult, Tool } from "./types.js";
|
|
7
|
+
export interface AiManagerOptions {
|
|
8
|
+
/** Path to conductor config.yaml. Default: ~/.conductor/config.yaml */
|
|
9
|
+
configPath?: string;
|
|
10
|
+
/** Path to the active codex auth.json. Default: ~/.codex/auth.json */
|
|
11
|
+
codexAuthPath?: string;
|
|
12
|
+
/** Pre-loaded config; skips loading from disk if provided. */
|
|
13
|
+
config?: AiManagerConfig;
|
|
14
|
+
}
|
|
15
|
+
export declare class AiManager {
|
|
16
|
+
private readonly configPath;
|
|
17
|
+
private readonly codexAuthPath;
|
|
18
|
+
private readonly configOverride?;
|
|
19
|
+
private cached?;
|
|
20
|
+
constructor(opts?: AiManagerOptions);
|
|
21
|
+
/**
|
|
22
|
+
* Read the latest ai_manager config. Cached against the config file's mtime,
|
|
23
|
+
* so edits to ~/.conductor/config.yaml are picked up immediately without
|
|
24
|
+
* restarting the daemon, while a 30s polling cycle does not re-parse YAML
|
|
25
|
+
* on every action. Falls back to always-reload if stat() fails.
|
|
26
|
+
* The `config` constructor option short-circuits disk reads (used by tests).
|
|
27
|
+
*/
|
|
28
|
+
getConfig(): Promise<AiManagerConfig>;
|
|
29
|
+
/** Force the next getConfig() call to re-read the file from disk. */
|
|
30
|
+
reloadConfig(): Promise<AiManagerConfig>;
|
|
31
|
+
checkInstall(tool: Tool): Promise<InstallStatus>;
|
|
32
|
+
checkInstallAll(): Promise<Record<Tool, InstallStatus>>;
|
|
33
|
+
checkNetwork(tool: Tool): Promise<NetworkStatus>;
|
|
34
|
+
checkNetworkAll(): Promise<Record<Tool, NetworkStatus>>;
|
|
35
|
+
getCodexQuota(opts?: GetCodexQuotaOptions): Promise<CodexQuota>;
|
|
36
|
+
/**
|
|
37
|
+
* Read the last-cached codex quota for a specific auth file path, without
|
|
38
|
+
* any network call. Enables `list_accounts` to return a "last known"
|
|
39
|
+
* snapshot per account so the web UI can restore inactive-account quotas
|
|
40
|
+
* across page refreshes.
|
|
41
|
+
*/
|
|
42
|
+
readCachedCodexQuota(authPath: string): Promise<CodexQuota | null>;
|
|
43
|
+
getClaudeQuota(opts?: GetClaudeQuotaOptions): Promise<ClaudeQuota>;
|
|
44
|
+
getKimiQuota(opts?: GetKimiQuotaOptions): Promise<KimiQuota>;
|
|
45
|
+
getCopilotQuota(opts?: GetCopilotQuotaOptions): Promise<CopilotQuota>;
|
|
46
|
+
getExternalQuota(opts: GetExternalQuotaOptions): Promise<ExternalQuota>;
|
|
47
|
+
getExternalQuotaList(opts: GetExternalQuotaListOptions): Promise<ExternalQuotaList>;
|
|
48
|
+
listCodexAccounts(): Promise<CodexAccount[]>;
|
|
49
|
+
getCurrentCodexAccount(): Promise<CodexAccount | null>;
|
|
50
|
+
switchCodexAccount(name: string): Promise<SwitchResult>;
|
|
51
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import { loadAiManagerConfig } from "./config.js";
|
|
3
|
+
import { getCurrentCodexAccount, listCodexAccounts, switchCodexAccount } from "./account.js";
|
|
4
|
+
import { checkInstall, checkInstallAll } from "./install.js";
|
|
5
|
+
import { checkNetwork, checkNetworkAll } from "./network.js";
|
|
6
|
+
import { getCodexQuota, readCachedCodexQuota } from "./quota/codex.js";
|
|
7
|
+
import { getClaudeQuota } from "./quota/claude.js";
|
|
8
|
+
import { getKimiQuota } from "./quota/kimi.js";
|
|
9
|
+
import { getCopilotQuota } from "./quota/copilot.js";
|
|
10
|
+
import { getExternalQuota, getExternalQuotaList, } from "./quota/external.js";
|
|
11
|
+
import { DEFAULT_CODEX_AUTH, DEFAULT_CONDUCTOR_CONFIG } from "./paths.js";
|
|
12
|
+
export class AiManager {
|
|
13
|
+
configPath;
|
|
14
|
+
codexAuthPath;
|
|
15
|
+
configOverride;
|
|
16
|
+
cached;
|
|
17
|
+
constructor(opts = {}) {
|
|
18
|
+
this.configPath = opts.configPath ?? DEFAULT_CONDUCTOR_CONFIG;
|
|
19
|
+
this.codexAuthPath = opts.codexAuthPath ?? DEFAULT_CODEX_AUTH;
|
|
20
|
+
this.configOverride = opts.config;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Read the latest ai_manager config. Cached against the config file's mtime,
|
|
24
|
+
* so edits to ~/.conductor/config.yaml are picked up immediately without
|
|
25
|
+
* restarting the daemon, while a 30s polling cycle does not re-parse YAML
|
|
26
|
+
* on every action. Falls back to always-reload if stat() fails.
|
|
27
|
+
* The `config` constructor option short-circuits disk reads (used by tests).
|
|
28
|
+
*/
|
|
29
|
+
async getConfig() {
|
|
30
|
+
if (this.configOverride)
|
|
31
|
+
return this.configOverride;
|
|
32
|
+
try {
|
|
33
|
+
const st = await stat(this.configPath);
|
|
34
|
+
const mtimeMs = st.mtimeMs;
|
|
35
|
+
if (this.cached && this.cached.mtimeMs === mtimeMs) {
|
|
36
|
+
return this.cached.value;
|
|
37
|
+
}
|
|
38
|
+
const value = await loadAiManagerConfig(this.configPath);
|
|
39
|
+
this.cached = { mtimeMs, value };
|
|
40
|
+
return value;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// ENOENT or stat failure → bypass cache and let loadAiManagerConfig
|
|
44
|
+
// handle missing-file semantics (returns empty config).
|
|
45
|
+
this.cached = undefined;
|
|
46
|
+
return loadAiManagerConfig(this.configPath);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Force the next getConfig() call to re-read the file from disk. */
|
|
50
|
+
reloadConfig() {
|
|
51
|
+
this.cached = undefined;
|
|
52
|
+
return this.getConfig();
|
|
53
|
+
}
|
|
54
|
+
checkInstall(tool) {
|
|
55
|
+
return checkInstall(tool);
|
|
56
|
+
}
|
|
57
|
+
checkInstallAll() {
|
|
58
|
+
return checkInstallAll();
|
|
59
|
+
}
|
|
60
|
+
checkNetwork(tool) {
|
|
61
|
+
return checkNetwork(tool);
|
|
62
|
+
}
|
|
63
|
+
checkNetworkAll() {
|
|
64
|
+
return checkNetworkAll();
|
|
65
|
+
}
|
|
66
|
+
getCodexQuota(opts) {
|
|
67
|
+
return getCodexQuota({ codexAuthPath: this.codexAuthPath, ...opts });
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Read the last-cached codex quota for a specific auth file path, without
|
|
71
|
+
* any network call. Enables `list_accounts` to return a "last known"
|
|
72
|
+
* snapshot per account so the web UI can restore inactive-account quotas
|
|
73
|
+
* across page refreshes.
|
|
74
|
+
*/
|
|
75
|
+
readCachedCodexQuota(authPath) {
|
|
76
|
+
return readCachedCodexQuota(authPath);
|
|
77
|
+
}
|
|
78
|
+
getClaudeQuota(opts) {
|
|
79
|
+
return getClaudeQuota(opts);
|
|
80
|
+
}
|
|
81
|
+
getKimiQuota(opts) {
|
|
82
|
+
return getKimiQuota(opts);
|
|
83
|
+
}
|
|
84
|
+
getCopilotQuota(opts) {
|
|
85
|
+
return getCopilotQuota(opts);
|
|
86
|
+
}
|
|
87
|
+
getExternalQuota(opts) {
|
|
88
|
+
return getExternalQuota({ configPath: this.configPath, ...opts });
|
|
89
|
+
}
|
|
90
|
+
getExternalQuotaList(opts) {
|
|
91
|
+
return getExternalQuotaList({ configPath: this.configPath, ...opts });
|
|
92
|
+
}
|
|
93
|
+
async listCodexAccounts() {
|
|
94
|
+
const cfg = await this.getConfig();
|
|
95
|
+
return listCodexAccounts(cfg, this.codexAuthPath);
|
|
96
|
+
}
|
|
97
|
+
async getCurrentCodexAccount() {
|
|
98
|
+
const cfg = await this.getConfig();
|
|
99
|
+
return getCurrentCodexAccount(cfg, this.codexAuthPath);
|
|
100
|
+
}
|
|
101
|
+
async switchCodexAccount(name) {
|
|
102
|
+
const cfg = await this.getConfig();
|
|
103
|
+
return switchCodexAccount(cfg, name, this.codexAuthPath);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { NetworkStatus, Tool } from "./types.js";
|
|
2
|
+
export declare function checkNetwork(tool: Tool, opts?: {
|
|
3
|
+
timeoutMs?: number;
|
|
4
|
+
endpoint?: string;
|
|
5
|
+
}): Promise<NetworkStatus>;
|
|
6
|
+
export declare function checkNetworkAll(opts?: {
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
}): Promise<Record<Tool, NetworkStatus>>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const ENDPOINTS = {
|
|
2
|
+
codex: "https://chatgpt.com/",
|
|
3
|
+
claude: "https://api.anthropic.com/v1/messages",
|
|
4
|
+
kimi: "https://api.kimi.com/coding/v1/usages",
|
|
5
|
+
copilot: "https://api.githubcopilot.com/",
|
|
6
|
+
};
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
8
|
+
export async function checkNetwork(tool, opts = {}) {
|
|
9
|
+
const endpoint = opts.endpoint ?? ENDPOINTS[tool];
|
|
10
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
11
|
+
const controller = new AbortController();
|
|
12
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
13
|
+
const start = Date.now();
|
|
14
|
+
try {
|
|
15
|
+
// Unauthenticated HEAD; we only care whether the host is reachable.
|
|
16
|
+
// api.anthropic.com returns 401/405 on HEAD, which still counts as reachable.
|
|
17
|
+
const res = await fetch(endpoint, { method: "HEAD", signal: controller.signal });
|
|
18
|
+
const latencyMs = Date.now() - start;
|
|
19
|
+
return {
|
|
20
|
+
reachable: true,
|
|
21
|
+
latencyMs,
|
|
22
|
+
httpStatus: res.status,
|
|
23
|
+
endpoint,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
return {
|
|
28
|
+
reachable: false,
|
|
29
|
+
latencyMs: Date.now() - start,
|
|
30
|
+
endpoint,
|
|
31
|
+
error: err?.name === "AbortError" ? "timeout" : err?.message ?? String(err),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function checkNetworkAll(opts) {
|
|
39
|
+
const [codex, claude, kimi, copilot] = await Promise.all([
|
|
40
|
+
checkNetwork("codex", opts),
|
|
41
|
+
checkNetwork("claude", opts),
|
|
42
|
+
checkNetwork("kimi", opts),
|
|
43
|
+
checkNetwork("copilot", opts),
|
|
44
|
+
]);
|
|
45
|
+
return { codex, claude, kimi, copilot };
|
|
46
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function expandHome(p: string): string;
|
|
2
|
+
export declare const DEFAULT_CONDUCTOR_CONFIG: string;
|
|
3
|
+
export declare const DEFAULT_CODEX_AUTH: string;
|
|
4
|
+
export declare const DEFAULT_CODEX_CONFIG: string;
|
|
5
|
+
export declare const DEFAULT_KIMI_CREDENTIAL: string;
|
|
6
|
+
export declare const DEFAULT_QUOTA_CACHE_DIR: string;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
export function expandHome(p) {
|
|
4
|
+
if (!p)
|
|
5
|
+
return p;
|
|
6
|
+
if (p === "~")
|
|
7
|
+
return homedir();
|
|
8
|
+
if (p.startsWith("~/"))
|
|
9
|
+
return join(homedir(), p.slice(2));
|
|
10
|
+
return p;
|
|
11
|
+
}
|
|
12
|
+
export const DEFAULT_CONDUCTOR_CONFIG = join(homedir(), ".conductor", "config.yaml");
|
|
13
|
+
export const DEFAULT_CODEX_AUTH = join(homedir(), ".codex", "auth.json");
|
|
14
|
+
export const DEFAULT_CODEX_CONFIG = join(homedir(), ".codex", "config.toml");
|
|
15
|
+
export const DEFAULT_KIMI_CREDENTIAL = join(homedir(), ".kimi", "credentials", "kimi-code.json");
|
|
16
|
+
export const DEFAULT_QUOTA_CACHE_DIR = join(homedir(), ".conductor", "cache", "ai-manager");
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface CacheEntry<T> {
|
|
2
|
+
fetchedAt: number;
|
|
3
|
+
value: T;
|
|
4
|
+
}
|
|
5
|
+
export declare function fingerprintKey(parts: string[]): string;
|
|
6
|
+
export declare function readCache<T>(file: string): Promise<CacheEntry<T> | null>;
|
|
7
|
+
export declare function writeCache<T>(file: string, value: T): Promise<CacheEntry<T>>;
|
|
8
|
+
export declare function cacheFile(tool: string, fingerprint: string, dir?: string): string;
|
|
9
|
+
export declare function isFresh(entry: CacheEntry<unknown> | null, ttlSeconds: number): boolean;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { DEFAULT_QUOTA_CACHE_DIR } from "../paths.js";
|
|
5
|
+
export function fingerprintKey(parts) {
|
|
6
|
+
const h = createHash("sha1");
|
|
7
|
+
for (const p of parts)
|
|
8
|
+
h.update(p);
|
|
9
|
+
return h.digest("hex").slice(0, 16);
|
|
10
|
+
}
|
|
11
|
+
export async function readCache(file) {
|
|
12
|
+
try {
|
|
13
|
+
const raw = await readFile(file, "utf8");
|
|
14
|
+
return JSON.parse(raw);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export async function writeCache(file, value) {
|
|
21
|
+
const entry = { fetchedAt: Math.floor(Date.now() / 1000), value };
|
|
22
|
+
await mkdir(dirname(file), { recursive: true });
|
|
23
|
+
await writeFile(file, JSON.stringify(entry), "utf8");
|
|
24
|
+
return entry;
|
|
25
|
+
}
|
|
26
|
+
export function cacheFile(tool, fingerprint, dir = DEFAULT_QUOTA_CACHE_DIR) {
|
|
27
|
+
return join(dir, `quota-${tool}-${fingerprint}.json`);
|
|
28
|
+
}
|
|
29
|
+
export function isFresh(entry, ttlSeconds) {
|
|
30
|
+
if (!entry)
|
|
31
|
+
return false;
|
|
32
|
+
return Math.floor(Date.now() / 1000) - entry.fetchedAt < ttlSeconds;
|
|
33
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ClaudeQuota } from "../types.js";
|
|
2
|
+
export interface ClaudeCredential {
|
|
3
|
+
kind: "api-key" | "oauth";
|
|
4
|
+
token: string;
|
|
5
|
+
/** JSON source string (for fingerprinting). */
|
|
6
|
+
fingerprintInput: string;
|
|
7
|
+
}
|
|
8
|
+
export interface GetClaudeQuotaOptions {
|
|
9
|
+
/** Cache TTL in seconds. Default 60. */
|
|
10
|
+
ttlSeconds?: number;
|
|
11
|
+
forceRefresh?: boolean;
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
model?: string;
|
|
14
|
+
cacheDir?: string;
|
|
15
|
+
/** Override credential resolution (mainly for tests). */
|
|
16
|
+
credential?: ClaudeCredential;
|
|
17
|
+
}
|
|
18
|
+
export declare function resolveClaudeCredential(): Promise<ClaudeCredential | null>;
|
|
19
|
+
export declare function getClaudeQuota(opts?: GetClaudeQuotaOptions): Promise<ClaudeQuota>;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { headersToMap, num, str } from "./headers.js";
|
|
6
|
+
import { cacheFile, fingerprintKey, isFresh, readCache, writeCache } from "./cache.js";
|
|
7
|
+
const MESSAGES_URL = "https://api.anthropic.com/v1/messages";
|
|
8
|
+
const DEFAULT_TTL = 60;
|
|
9
|
+
const DEFAULT_TIMEOUT_MS = 20000;
|
|
10
|
+
const CLAUDE_CODE_SYSTEM_PROMPT = "You are Claude Code, Anthropic's official CLI for Claude.";
|
|
11
|
+
export async function resolveClaudeCredential() {
|
|
12
|
+
const envKey = process.env.ANTHROPIC_API_KEY;
|
|
13
|
+
if (envKey && envKey.length > 10) {
|
|
14
|
+
return { kind: "api-key", token: envKey, fingerprintInput: envKey };
|
|
15
|
+
}
|
|
16
|
+
if (process.platform === "darwin") {
|
|
17
|
+
const raw = await readMacKeychain();
|
|
18
|
+
if (raw) {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(raw);
|
|
21
|
+
const oauth = parsed?.claudeAiOauth ?? parsed;
|
|
22
|
+
if (oauth?.accessToken) {
|
|
23
|
+
return {
|
|
24
|
+
kind: "oauth",
|
|
25
|
+
token: oauth.accessToken,
|
|
26
|
+
fingerprintInput: oauth.accessToken,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// fall through
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// Linux/Windows fallback: look for ~/.claude/.credentials.json (best-effort).
|
|
36
|
+
try {
|
|
37
|
+
const raw = await readFile(join(homedir(), ".claude", ".credentials.json"), "utf8");
|
|
38
|
+
const parsed = JSON.parse(raw);
|
|
39
|
+
const oauth = parsed?.claudeAiOauth ?? parsed;
|
|
40
|
+
if (oauth?.accessToken) {
|
|
41
|
+
return {
|
|
42
|
+
kind: "oauth",
|
|
43
|
+
token: oauth.accessToken,
|
|
44
|
+
fingerprintInput: oauth.accessToken,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// ignore
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
function readMacKeychain() {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
const user = process.env.USER ?? "";
|
|
56
|
+
const proc = spawn("security", ["find-generic-password", "-a", user, "-s", "Claude Code-credentials", "-w"], { stdio: ["ignore", "pipe", "pipe"] });
|
|
57
|
+
let out = "";
|
|
58
|
+
proc.stdout.on("data", (b) => (out += b.toString()));
|
|
59
|
+
proc.on("error", () => resolve(null));
|
|
60
|
+
proc.on("close", (code) => resolve(code === 0 ? out.trim() : null));
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function utilizationToWindow(map, prefix) {
|
|
64
|
+
const util = num(map, `${prefix}-utilization`);
|
|
65
|
+
if (util === undefined)
|
|
66
|
+
return undefined;
|
|
67
|
+
const usedPercent = Math.max(0, Math.min(100, util * 100));
|
|
68
|
+
return {
|
|
69
|
+
usedPercent,
|
|
70
|
+
remainingPercent: 100 - usedPercent,
|
|
71
|
+
resetAt: num(map, `${prefix}-reset`),
|
|
72
|
+
status: str(map, `${prefix}-status`),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function parseClaudeHeaders(map) {
|
|
76
|
+
const fiveHour = utilizationToWindow(map, "anthropic-ratelimit-unified-5h") ?? {
|
|
77
|
+
usedPercent: 0,
|
|
78
|
+
remainingPercent: 0,
|
|
79
|
+
};
|
|
80
|
+
const weekly = utilizationToWindow(map, "anthropic-ratelimit-unified-7d") ?? {
|
|
81
|
+
usedPercent: 0,
|
|
82
|
+
remainingPercent: 0,
|
|
83
|
+
};
|
|
84
|
+
const weeklySonnet = utilizationToWindow(map, "anthropic-ratelimit-unified-7d_sonnet");
|
|
85
|
+
return {
|
|
86
|
+
overallStatus: str(map, "anthropic-ratelimit-unified-status"),
|
|
87
|
+
fiveHour,
|
|
88
|
+
weekly,
|
|
89
|
+
weeklySonnet,
|
|
90
|
+
overage: {
|
|
91
|
+
status: str(map, "anthropic-ratelimit-unified-overage-status"),
|
|
92
|
+
disabledReason: str(map, "anthropic-ratelimit-unified-overage-disabled-reason"),
|
|
93
|
+
},
|
|
94
|
+
raw: Object.fromEntries(Object.entries(map).filter(([k]) => k.startsWith("anthropic-ratelimit-"))),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
export async function getClaudeQuota(opts = {}) {
|
|
98
|
+
const ttl = opts.ttlSeconds ?? DEFAULT_TTL;
|
|
99
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
100
|
+
const credential = opts.credential ?? (await resolveClaudeCredential());
|
|
101
|
+
if (!credential) {
|
|
102
|
+
return emptyQuota("unknown", "no Claude credential found (ANTHROPIC_API_KEY env, macOS Keychain, or ~/.claude/.credentials.json)");
|
|
103
|
+
}
|
|
104
|
+
const fp = fingerprintKey(["claude", credential.kind, credential.fingerprintInput.slice(0, 32)]);
|
|
105
|
+
const file = cacheFile("claude", fp, opts.cacheDir);
|
|
106
|
+
if (!opts.forceRefresh && ttl > 0) {
|
|
107
|
+
const cached = await readCache(file);
|
|
108
|
+
if (isFresh(cached, ttl) && cached) {
|
|
109
|
+
return { ...cached.value, source: "cached" };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const isOauth = credential.kind === "oauth";
|
|
113
|
+
const headers = {
|
|
114
|
+
"anthropic-version": "2023-06-01",
|
|
115
|
+
"content-type": "application/json",
|
|
116
|
+
};
|
|
117
|
+
if (isOauth) {
|
|
118
|
+
headers["authorization"] = `Bearer ${credential.token}`;
|
|
119
|
+
headers["anthropic-beta"] = "oauth-2025-04-20";
|
|
120
|
+
headers["user-agent"] = "claude-cli/1.0.128 (external, cli)";
|
|
121
|
+
headers["x-app"] = "cli";
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
headers["x-api-key"] = credential.token;
|
|
125
|
+
}
|
|
126
|
+
const body = {
|
|
127
|
+
model: opts.model ?? "claude-sonnet-4-5",
|
|
128
|
+
max_tokens: 1,
|
|
129
|
+
messages: [{ role: "user", content: "hi" }],
|
|
130
|
+
};
|
|
131
|
+
if (isOauth) {
|
|
132
|
+
body.system = [{ type: "text", text: CLAUDE_CODE_SYSTEM_PROMPT }];
|
|
133
|
+
}
|
|
134
|
+
const controller = new AbortController();
|
|
135
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
136
|
+
let res;
|
|
137
|
+
try {
|
|
138
|
+
res = await fetch(MESSAGES_URL, {
|
|
139
|
+
method: "POST",
|
|
140
|
+
headers,
|
|
141
|
+
body: JSON.stringify(body),
|
|
142
|
+
signal: controller.signal,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
return await fallbackFromCache(file, ttl, err?.message ?? String(err));
|
|
148
|
+
}
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
try {
|
|
151
|
+
await res.body?.cancel();
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
// ignore
|
|
155
|
+
}
|
|
156
|
+
const map = headersToMap(res.headers);
|
|
157
|
+
if ((res.status === 401 || res.status === 403) && !Object.keys(map).some((k) => k.startsWith("anthropic-ratelimit-"))) {
|
|
158
|
+
return await fallbackFromCache(file, ttl, `auth failed: HTTP ${res.status}`);
|
|
159
|
+
}
|
|
160
|
+
const parsed = parseClaudeHeaders(map);
|
|
161
|
+
const fresh = {
|
|
162
|
+
tool: "claude",
|
|
163
|
+
fetchedAt: Math.floor(Date.now() / 1000),
|
|
164
|
+
source: "fresh",
|
|
165
|
+
...parsed,
|
|
166
|
+
};
|
|
167
|
+
if (Object.keys(fresh.raw ?? {}).length > 0) {
|
|
168
|
+
await writeCache(file, fresh);
|
|
169
|
+
return fresh;
|
|
170
|
+
}
|
|
171
|
+
return await fallbackFromCache(file, ttl, `no anthropic-ratelimit-* headers on HTTP ${res.status}`);
|
|
172
|
+
}
|
|
173
|
+
async function fallbackFromCache(file, ttl, error) {
|
|
174
|
+
const cached = await readCache(file);
|
|
175
|
+
if (cached) {
|
|
176
|
+
return {
|
|
177
|
+
...cached.value,
|
|
178
|
+
source: isFresh(cached, ttl) ? "cached" : "stale",
|
|
179
|
+
error,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
return emptyQuota("unknown", error);
|
|
183
|
+
}
|
|
184
|
+
function emptyQuota(source, error) {
|
|
185
|
+
return {
|
|
186
|
+
tool: "claude",
|
|
187
|
+
source,
|
|
188
|
+
error,
|
|
189
|
+
fetchedAt: Math.floor(Date.now() / 1000),
|
|
190
|
+
fiveHour: { usedPercent: 0, remainingPercent: 0 },
|
|
191
|
+
weekly: { usedPercent: 0, remainingPercent: 0 },
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CodexQuota } from "../types.js";
|
|
2
|
+
export interface GetCodexQuotaOptions {
|
|
3
|
+
codexAuthPath?: string;
|
|
4
|
+
/** Cache TTL in seconds. Default 60. Pass 0 to always bypass cache. */
|
|
5
|
+
ttlSeconds?: number;
|
|
6
|
+
/** If true, ignore cache and refetch. */
|
|
7
|
+
forceRefresh?: boolean;
|
|
8
|
+
/** Request timeout in ms. */
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
/** Model name; defaults to the one in ~/.codex/config.toml, then `gpt-5`. */
|
|
11
|
+
model?: string;
|
|
12
|
+
/** Override path to codex config.toml (to detect model). */
|
|
13
|
+
codexConfigPath?: string;
|
|
14
|
+
/** Override cache directory (mainly for tests). */
|
|
15
|
+
cacheDir?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function getCodexQuota(opts?: GetCodexQuotaOptions): Promise<CodexQuota>;
|
|
18
|
+
/**
|
|
19
|
+
* Read the on-disk quota cache for a given codex auth.json without triggering
|
|
20
|
+
* any network call. Returns whatever snapshot was last written (regardless of
|
|
21
|
+
* age) or `null` if nothing has been cached for this identity yet. Used by the
|
|
22
|
+
* daemon's `list_accounts` handler to surface "last known" quota for inactive
|
|
23
|
+
* accounts so the web UI can restore them across page refreshes.
|
|
24
|
+
*/
|
|
25
|
+
export declare function readCachedCodexQuota(codexAuthPath: string, opts?: {
|
|
26
|
+
cacheDir?: string;
|
|
27
|
+
}): Promise<CodexQuota | null>;
|