@love-moon/ai-sdk 0.7.4 → 0.7.5

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # @love-moon/ai-sdk
2
2
 
3
+ ## 0.7.5
4
+
5
+ ### Patch Changes
6
+
7
+ - f91a5df: Read Codex weekly quota from the official app-server account rate-limit RPC, ignore model-specific buckets, and invalidate legacy response-header caches.
8
+
3
9
  ## 0.7.4
4
10
 
5
11
  ### Patch Changes
@@ -7,25 +7,28 @@ export interface GetCodexQuotaOptions {
7
7
  forceRefresh?: boolean;
8
8
  /** Request timeout in ms. */
9
9
  timeoutMs?: number;
10
- /** Model name; defaults to the one in ~/.codex/config.toml, then `gpt-5`. */
10
+ /** @deprecated Quota is account-scoped and no longer fetched through a model response. */
11
11
  model?: string;
12
- /** Override path to codex config.toml (to detect model). */
12
+ /** @deprecated The Codex app-server reads its own config from CODEX_HOME. */
13
13
  codexConfigPath?: string;
14
14
  /** Override cache directory (mainly for tests). */
15
15
  cacheDir?: string;
16
+ /** Override the app-server reader (tests only). */
17
+ rateLimitsReader?: (options: {
18
+ codexAuthPath: string;
19
+ timeoutMs: number;
20
+ }) => Promise<unknown>;
16
21
  }
17
- /** Exported for protocol compatibility tests. */
18
- export declare function parseCodexHeaders(map: Record<string, string>, extras: {
22
+ /** Normalize Codex app-server `account/rateLimits/read` into the manager shape. */
23
+ export declare function parseCodexRateLimitsResponse(response: unknown, extras?: {
19
24
  email?: string;
20
25
  accountId?: string;
21
26
  }): Omit<CodexQuota, "tool" | "fetchedAt" | "source">;
22
27
  export declare function getCodexQuota(opts?: GetCodexQuotaOptions): Promise<CodexQuota>;
23
28
  /**
24
29
  * Read the on-disk quota cache for a given codex auth.json without triggering
25
- * any network call. Returns whatever snapshot was last written (regardless of
26
- * age) or `null` if nothing has been cached for this identity yet. Used by the
27
- * daemon's `list_accounts` handler to surface "last known" quota for inactive
28
- * accounts so the web UI can restore them across page refreshes.
30
+ * any network call. Only app-server snapshots are considered; legacy caches
31
+ * from response headers are intentionally ignored.
29
32
  */
30
33
  export declare function readCachedCodexQuota(codexAuthPath: string, opts?: {
31
34
  cacheDir?: string;
@@ -1,77 +1,72 @@
1
- import { readFile } from "node:fs/promises";
1
+ import { basename, dirname } from "node:path";
2
2
  import { parseAuthFile } from "../auth-parser.js";
3
- import { DEFAULT_CODEX_AUTH, DEFAULT_CODEX_CONFIG } from "../paths.js";
4
- import { bool, headersToMap, num, str } from "./headers.js";
3
+ import { DEFAULT_CODEX_AUTH } from "../paths.js";
4
+ import { CodexAppServerTransport } from "../../transports/codex-app-server-transport.js";
5
5
  import { cacheFile, fingerprintKey, isFresh, readCache, writeCache } from "./cache.js";
6
- /** Best-effort: read the configured model from ~/.codex/config.toml. */
7
- async function readConfiguredModel(path) {
8
- try {
9
- const raw = await readFile(path, "utf8");
10
- // Minimal top-level key scan: `model = "..."` (skip lines inside [tables]).
11
- let inRootTable = true;
12
- for (const line of raw.split(/\r?\n/)) {
13
- const trimmed = line.trim();
14
- if (trimmed.startsWith("#") || trimmed === "")
15
- continue;
16
- if (/^\[/.test(trimmed)) {
17
- inRootTable = false;
18
- continue;
19
- }
20
- if (!inRootTable)
21
- continue;
22
- const m = /^model\s*=\s*"([^"]+)"/.exec(trimmed);
23
- if (m)
24
- return m[1];
25
- }
26
- }
27
- catch {
28
- // ignore
29
- }
30
- return undefined;
31
- }
32
- const RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
33
6
  const DEFAULT_TTL = 60; // seconds
34
7
  const DEFAULT_TIMEOUT_MS = 15000;
35
- function windowFromHeaders(map, prefix) {
36
- const suffixes = [
37
- "used-percent",
38
- "reset-after-seconds",
39
- "reset-at",
40
- "window-minutes",
41
- ];
42
- if (!suffixes.some((suffix) => map[`${prefix}-${suffix}`] !== undefined)) {
8
+ const WEEKLY_WINDOW_MINUTES = 7 * 24 * 60;
9
+ // The old cache was populated from /backend-api/codex/responses headers. Keep
10
+ // the new app-server snapshots in a separate namespace so an upgrade cannot
11
+ // surface a model-specific response limit as the account's weekly limit.
12
+ const CACHE_TOOL = "codex-app-server";
13
+ function finiteNumber(value) {
14
+ if (typeof value !== "number" && (typeof value !== "string" || !value.trim())) {
43
15
  return undefined;
44
16
  }
45
- const usedPercent = num(map, `${prefix}-used-percent`) ?? 0;
17
+ const parsed = typeof value === "number" ? value : Number(value);
18
+ return Number.isFinite(parsed) ? parsed : undefined;
19
+ }
20
+ function nonEmptyString(value) {
21
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
22
+ }
23
+ function quotaWindow(value) {
24
+ if (!value || typeof value !== "object")
25
+ return undefined;
26
+ const usedPercent = finiteNumber(value.usedPercent);
27
+ if (usedPercent === undefined)
28
+ return undefined;
29
+ const resetAt = finiteNumber(value.resetsAt);
30
+ const now = Math.floor(Date.now() / 1000);
46
31
  return {
47
32
  usedPercent,
48
33
  remainingPercent: Math.max(0, 100 - usedPercent),
49
- resetAfterSeconds: num(map, `${prefix}-reset-after-seconds`),
50
- resetAt: num(map, `${prefix}-reset-at`),
51
- windowMinutes: num(map, `${prefix}-window-minutes`),
34
+ resetAt,
35
+ resetAfterSeconds: resetAt === undefined ? undefined : Math.max(0, resetAt - now),
36
+ windowMinutes: finiteNumber(value.windowDurationMins),
52
37
  };
53
38
  }
54
- /** Exported for protocol compatibility tests. */
55
- export function parseCodexHeaders(map, extras) {
56
- const primary = windowFromHeaders(map, "x-codex-primary");
57
- const secondary = windowFromHeaders(map, "x-codex-secondary");
58
- // Codex CLI 0.144 removed the 5h limit. Its only Codex window is now the
59
- // 10080-minute weekly window in `primary`, with `secondary` absent. Older
60
- // responses used primary=5h and secondary=weekly, so prefer an explicitly
61
- // weekly-sized window and then fall back to the legacy secondary position.
62
- const weekly = [primary, secondary].find((window) => (window?.windowMinutes ?? 0) >= 7 * 24 * 60) ?? secondary ?? primary ?? { usedPercent: 0, remainingPercent: 0 };
39
+ /** Normalize Codex app-server `account/rateLimits/read` into the manager shape. */
40
+ export function parseCodexRateLimitsResponse(response, extras = {}) {
41
+ const payload = response && typeof response === "object"
42
+ ? response
43
+ : {};
44
+ // `rateLimits` is the official backward-compatible account bucket. Do not
45
+ // pick a model-specific entry from rateLimitsByLimitId: e.g. Spark can have
46
+ // a different weekly percentage from the main Codex account limit.
47
+ const snapshot = payload.rateLimits && typeof payload.rateLimits === "object"
48
+ ? payload.rateLimits
49
+ : undefined;
50
+ const windows = [quotaWindow(snapshot?.primary), quotaWindow(snapshot?.secondary)];
51
+ const weekly = windows.find((window) => (window?.windowMinutes ?? 0) >= WEEKLY_WINDOW_MINUTES);
52
+ const credits = snapshot?.credits && typeof snapshot.credits === "object"
53
+ ? {
54
+ hasCredits: snapshot.credits.hasCredits === true,
55
+ balance: nonEmptyString(snapshot.credits.balance),
56
+ unlimited: snapshot.credits.unlimited === true,
57
+ }
58
+ : undefined;
59
+ const limitId = nonEmptyString(snapshot?.limitId);
60
+ const limitName = nonEmptyString(snapshot?.limitName);
63
61
  return {
64
- plan: str(map, "x-codex-plan-type"),
65
- activeLimit: str(map, "x-codex-active-limit"),
62
+ plan: nonEmptyString(snapshot?.planType),
63
+ activeLimit: limitName ?? limitId,
66
64
  weekly,
67
- credits: {
68
- hasCredits: bool(map, "x-codex-credits-has-credits") ?? false,
69
- balance: str(map, "x-codex-credits-balance"),
70
- unlimited: bool(map, "x-codex-credits-unlimited") ?? false,
71
- },
65
+ credits,
72
66
  email: extras.email,
73
67
  accountId: extras.accountId,
74
- raw: Object.fromEntries(Object.entries(map).filter(([k]) => k.startsWith("x-codex-"))),
68
+ raw: payload,
69
+ ...(!weekly ? { error: "Codex rate-limit response has no weekly window" } : {}),
75
70
  };
76
71
  }
77
72
  export async function getCodexQuota(opts = {}) {
@@ -82,81 +77,75 @@ export async function getCodexQuota(opts = {}) {
82
77
  if (!authInfo.accessToken) {
83
78
  return emptyQuota("unknown", "codex auth.json missing access_token");
84
79
  }
85
- const fp = fingerprintKey(["codex", authInfo.identityFingerprint ?? "unknown"]);
86
- const file = cacheFile("codex", fp, opts.cacheDir);
80
+ const fp = fingerprintKey([CACHE_TOOL, authInfo.identityFingerprint ?? "unknown"]);
81
+ const file = cacheFile(CACHE_TOOL, fp, opts.cacheDir);
87
82
  if (!opts.forceRefresh && ttl > 0) {
88
83
  const cached = await readCache(file);
89
84
  if (isFresh(cached, ttl) && cached) {
90
- return { ...withoutLegacyFiveHour(cached.value), source: "cached" };
85
+ return { ...cached.value, source: "cached" };
91
86
  }
92
87
  }
93
- const model = opts.model ??
94
- (await readConfiguredModel(opts.codexConfigPath ?? DEFAULT_CODEX_CONFIG)) ??
95
- "gpt-5";
96
- const body = JSON.stringify({
97
- model,
98
- instructions: "Reply with exactly one character.",
99
- input: [
100
- { type: "message", role: "user", content: [{ type: "input_text", text: "." }] },
101
- ],
102
- stream: true,
103
- store: false,
104
- });
105
- const controller = new AbortController();
106
- const timer = setTimeout(() => controller.abort(), timeoutMs);
107
- let res;
88
+ let response;
108
89
  try {
109
- res = await fetch(RESPONSES_URL, {
110
- method: "POST",
111
- headers: {
112
- Authorization: `Bearer ${authInfo.accessToken}`,
113
- "Content-Type": "application/json",
114
- "OpenAI-Beta": "responses=experimental",
115
- ...(authInfo.accountId ? { "chatgpt-account-id": authInfo.accountId } : {}),
116
- },
117
- body,
118
- signal: controller.signal,
90
+ response = await (opts.rateLimitsReader ?? readCodexRateLimits)({
91
+ codexAuthPath,
92
+ timeoutMs,
119
93
  });
120
94
  }
121
- catch (err) {
122
- clearTimeout(timer);
123
- return await fallbackFromCache(file, ttl, err?.message ?? String(err));
124
- }
125
- // Abort the body stream immediately once headers are in; they're all we need.
126
- try {
127
- await res.body?.cancel();
128
- }
129
- catch {
130
- // ignore
131
- }
132
- clearTimeout(timer);
133
- const map = headersToMap(res.headers);
134
- if (res.status === 401 || res.status === 403) {
135
- return await fallbackFromCache(file, ttl, `auth failed: HTTP ${res.status}`);
95
+ catch (error) {
96
+ return fallbackFromCache(file, ttl, errorMessage(error));
136
97
  }
137
- const parsed = parseCodexHeaders(map, {
98
+ const parsed = parseCodexRateLimitsResponse(response, {
138
99
  email: authInfo.email,
139
100
  accountId: authInfo.accountId,
140
101
  });
141
- const now = Math.floor(Date.now() / 1000);
102
+ if (!parsed.weekly) {
103
+ return fallbackFromCache(file, ttl, parsed.error ?? "Codex weekly quota unavailable");
104
+ }
142
105
  const fresh = {
143
106
  tool: "codex",
144
- fetchedAt: now,
107
+ fetchedAt: Math.floor(Date.now() / 1000),
145
108
  source: "fresh",
146
109
  ...parsed,
147
110
  };
148
- // Only persist if we actually got rate-limit headers — otherwise the upstream was unhealthy.
149
- if (Object.keys(fresh.raw ?? {}).length > 0) {
150
- await writeCache(file, fresh);
151
- return fresh;
111
+ await writeCache(file, fresh);
112
+ return fresh;
113
+ }
114
+ async function readCodexRateLimits(options) {
115
+ if (basename(options.codexAuthPath) !== "auth.json") {
116
+ throw new Error(`Codex app-server quota requires an active auth.json path; got ${options.codexAuthPath}`);
117
+ }
118
+ const transport = new CodexAppServerTransport({
119
+ env: { CODEX_HOME: dirname(options.codexAuthPath) },
120
+ ignoreCodexApiKey: true,
121
+ });
122
+ try {
123
+ return await withTimeout(transport.request("account/rateLimits/read"), options.timeoutMs, "Codex account/rateLimits/read timed out");
124
+ }
125
+ finally {
126
+ await transport.close();
127
+ }
128
+ }
129
+ async function withTimeout(promise, timeoutMs, message) {
130
+ let timer;
131
+ try {
132
+ return await Promise.race([
133
+ promise,
134
+ new Promise((_resolve, reject) => {
135
+ timer = setTimeout(() => reject(new Error(message)), timeoutMs);
136
+ }),
137
+ ]);
138
+ }
139
+ finally {
140
+ if (timer)
141
+ clearTimeout(timer);
152
142
  }
153
- return await fallbackFromCache(file, ttl, `no x-codex-* headers on HTTP ${res.status}`);
154
143
  }
155
144
  async function fallbackFromCache(file, ttl, error) {
156
145
  const cached = await readCache(file);
157
146
  if (cached) {
158
147
  return {
159
- ...withoutLegacyFiveHour(cached.value),
148
+ ...cached.value,
160
149
  source: isFresh(cached, ttl) ? "cached" : "stale",
161
150
  error,
162
151
  };
@@ -165,23 +154,21 @@ async function fallbackFromCache(file, ttl, error) {
165
154
  }
166
155
  /**
167
156
  * Read the on-disk quota cache for a given codex auth.json without triggering
168
- * any network call. Returns whatever snapshot was last written (regardless of
169
- * age) or `null` if nothing has been cached for this identity yet. Used by the
170
- * daemon's `list_accounts` handler to surface "last known" quota for inactive
171
- * accounts so the web UI can restore them across page refreshes.
157
+ * any network call. Only app-server snapshots are considered; legacy caches
158
+ * from response headers are intentionally ignored.
172
159
  */
173
160
  export async function readCachedCodexQuota(codexAuthPath, opts = {}) {
174
161
  try {
175
162
  const authInfo = await parseAuthFile(codexAuthPath);
176
163
  if (!authInfo.identityFingerprint)
177
164
  return null;
178
- const fp = fingerprintKey(["codex", authInfo.identityFingerprint]);
179
- const file = cacheFile("codex", fp, opts.cacheDir);
165
+ const fp = fingerprintKey([CACHE_TOOL, authInfo.identityFingerprint]);
166
+ const file = cacheFile(CACHE_TOOL, fp, opts.cacheDir);
180
167
  const entry = await readCache(file);
181
168
  if (!entry)
182
169
  return null;
183
170
  return {
184
- ...withoutLegacyFiveHour(entry.value),
171
+ ...entry.value,
185
172
  source: "cached",
186
173
  fetchedAt: entry.fetchedAt,
187
174
  };
@@ -190,9 +177,8 @@ export async function readCachedCodexQuota(codexAuthPath, opts = {}) {
190
177
  return null;
191
178
  }
192
179
  }
193
- function withoutLegacyFiveHour(quota) {
194
- const { fiveHour: _legacyFiveHour, ...current } = quota;
195
- return current;
180
+ function errorMessage(error) {
181
+ return error instanceof Error ? error.message : String(error);
196
182
  }
197
183
  function emptyQuota(source, error) {
198
184
  return {
@@ -200,6 +186,5 @@ function emptyQuota(source, error) {
200
186
  source,
201
187
  error,
202
188
  fetchedAt: Math.floor(Date.now() / 1000),
203
- weekly: { usedPercent: 0, remainingPercent: 0 },
204
189
  };
205
190
  }
@@ -37,9 +37,7 @@ export interface CodexQuota {
37
37
  tool: "codex";
38
38
  plan?: string;
39
39
  activeLimit?: string;
40
- /** @deprecated Codex CLI 0.144+ no longer exposes a 5h limit. */
41
- fiveHour?: QuotaWindow;
42
- weekly: QuotaWindow;
40
+ weekly?: QuotaWindow;
43
41
  credits?: {
44
42
  hasCredits: boolean;
45
43
  balance?: string;
@@ -49,7 +47,7 @@ export interface CodexQuota {
49
47
  source: QuotaSource;
50
48
  email?: string;
51
49
  accountId?: string;
52
- raw?: Record<string, string>;
50
+ raw?: Record<string, unknown>;
53
51
  error?: string;
54
52
  }
55
53
  export interface ClaudeQuota {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@love-moon/ai-sdk",
3
- "version": "0.7.4",
3
+ "version": "0.7.5",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/lovemoon-ai/conductor.git"
@@ -32,12 +32,12 @@
32
32
  "zod": "^4.1.5"
33
33
  },
34
34
  "optionalDependencies": {
35
- "@love-moon/chat-web": "^0.7.4"
35
+ "@love-moon/chat-web": "^0.7.5"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/node": "^22.10.2",
39
39
  "tsx": "^4.20.6",
40
40
  "typescript": "^5.6.3"
41
41
  },
42
- "gitCommitId": "b9a27b4"
42
+ "gitCommitId": "702c7c4"
43
43
  }