@iowarp/clio-coder 0.5.0 → 0.5.1
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/README.md +12 -9
- package/dist/{acp-KS7ARGV6.js → acp-X3N7JDGS.js} +2 -2
- package/dist/{agents-YOGJ2YEJ.js → agents-UYGSMAWR.js} +3 -3
- package/dist/assets/codewiki.json +1 -1
- package/dist/{chunk-EOJPDNUP.js → chunk-3OODBJ45.js} +1 -1
- package/dist/{chunk-R7FGRZNF.js → chunk-4D7QM7S5.js} +2 -2
- package/dist/{chunk-X4CEFOSG.js → chunk-6B76U65H.js} +4 -4
- package/dist/{chunk-LTWH2ACR.js → chunk-7FHSA7VB.js} +5 -5
- package/dist/{chunk-GRHOXDKB.js → chunk-7KI4ZSEB.js} +2 -2
- package/dist/{chunk-LYJJ6546.js → chunk-7VNLPEUG.js} +6 -6
- package/dist/{chunk-D65E25JG.js → chunk-A7G7OUND.js} +2 -2
- package/dist/{chunk-3JLTRPN7.js → chunk-AKQ3N3L4.js} +2 -2
- package/dist/{chunk-AVSR2HKI.js → chunk-BIQLF3NZ.js} +3 -3
- package/dist/{chunk-D3KWFOUT.js → chunk-DGMY2DWE.js} +2 -2
- package/dist/{chunk-IN7DGBVS.js → chunk-FBXTHPNZ.js} +2 -2
- package/dist/{chunk-EX57VVWW.js → chunk-GGEEG7CD.js} +2 -2
- package/dist/{chunk-V35KZDUZ.js → chunk-GW2KVAVI.js} +2 -2
- package/dist/{chunk-SJGS3GDI.js → chunk-KIAUZGKF.js} +2 -2
- package/dist/{chunk-CBAQPTDU.js → chunk-LFYA3HYH.js} +2 -2
- package/dist/{chunk-4VDHLCF5.js → chunk-M5FPIZQW.js} +2 -2
- package/dist/{chunk-R2UUNSNQ.js → chunk-O3ZAF7UY.js} +18 -5
- package/dist/{chunk-QNUQ7K7D.js → chunk-OXWRE2QJ.js} +2 -2
- package/dist/{chunk-55RMDDFM.js → chunk-PGQG7ZMW.js} +2 -2
- package/dist/{chunk-HQVN7G4F.js → chunk-QM7HFPQQ.js} +4 -4
- package/dist/{chunk-ZR5YA6ZF.js → chunk-QTYRMBW6.js} +2 -2
- package/dist/{chunk-3LT34CAM.js → chunk-SAL2SBP4.js} +8 -8
- package/dist/{chunk-OJXOA6YU.js → chunk-TL6LNERH.js} +2 -2
- package/dist/{chunk-U6YTUVOX.js → chunk-VMTCZ2GR.js} +2 -2
- package/dist/{chunk-OQ2EOAHA.js → chunk-VWKR2NN5.js} +5 -5
- package/dist/{chunk-QDUUOZ3S.js → chunk-WAISUJNQ.js} +2 -2
- package/dist/{chunk-XDHUDE5K.js → chunk-XOH3SD7H.js} +3 -3
- package/dist/{chunk-62UK7AGW.js → chunk-ZNTGRDFA.js} +281 -14
- package/dist/cli/index.js +17 -17
- package/dist/{clio-K2PBVCEM.js → clio-Z3JTEL2I.js} +2 -2
- package/dist/{config-Q5J7UBAB.js → config-QYIRZHI7.js} +8 -8
- package/dist/{config-graph-P3555574.js → config-graph-TS3SGGQW.js} +8 -8
- package/dist/{configure-KIJ32ZO6.js → configure-OX7KVJTQ.js} +2 -2
- package/dist/{context-LFBQOSXT.js → context-2BNF6HTP.js} +12 -12
- package/dist/{context-WBH3KSVR.js → context-GTMUGAR4.js} +7 -7
- package/dist/{context-clear-YOHMQ6SQ.js → context-clear-FVJQBBL7.js} +7 -7
- package/dist/{context-working-set-MRAEVDNV.js → context-working-set-MOE72SD6.js} +2 -2
- package/dist/{detail-RHJOTGN4.js → detail-QZM3QAAU.js} +8 -8
- package/dist/{dispatch-runner-NC7PYNIO.js → dispatch-runner-2KSQO5FU.js} +8 -8
- package/dist/{doctor-S5KZ2GTN.js → doctor-2U6YR5NH.js} +3 -3
- package/dist/{eval-3D6M36N7.js → eval-ZKK77JWZ.js} +3 -3
- package/dist/{evidence-6IYN52ET.js → evidence-FMP7KZZ2.js} +7 -7
- package/dist/{evidence-JVECPI3F.js → evidence-PJTOE7JA.js} +7 -7
- package/dist/{evidence-XNVPPLVK.js → evidence-POSEQW4Q.js} +9 -9
- package/dist/{evolve-TLPUDJDM.js → evolve-JT6PAMZE.js} +7 -7
- package/dist/{fleet-WKVBF5SZ.js → fleet-4XR2ZEOF.js} +16 -16
- package/dist/{fleet-6RJWIOEO.js → fleet-6JJKNIMV.js} +8 -8
- package/dist/{fleet-inspect-236FZPRB.js → fleet-inspect-SWJMFVDP.js} +9 -9
- package/dist/{fleet-preflight-NZQHABWO.js → fleet-preflight-KSIHWQAU.js} +4 -4
- package/dist/{fleet-verify-E3RO6A5W.js → fleet-verify-QM6XU2KL.js} +7 -7
- package/dist/{fleet-view-KMZQY7YJ.js → fleet-view-RLKFHFCM.js} +8 -8
- package/dist/gui/reads-worker.js +4 -4
- package/dist/{init-DV5B6HQG.js → init-VBUPHABR.js} +11 -11
- package/dist/{interactive-OXGYZYAS.js → interactive-JCVBPVYT.js} +1597 -503
- package/dist/{inventory-OEGIL6PY.js → inventory-54G7FSVY.js} +8 -8
- package/dist/{mcp-2GSPYQ54.js → mcp-EFCWUPUR.js} +3 -3
- package/dist/{memory-J3STUUIK.js → memory-5BPQSSOP.js} +7 -7
- package/dist/{models-Y3GMFJ2V.js → models-R6HILX6X.js} +3 -3
- package/dist/{monitor-LHNH577J.js → monitor-HVRKSFEQ.js} +10 -10
- package/dist/{orchestrator-DTAPXULA.js → orchestrator-OR7V4YWK.js} +26 -26
- package/dist/{preload-OJBMAIXF.js → preload-KLXFOX2F.js} +7 -7
- package/dist/{run-H64MEVTA.js → run-UIDRUYAY.js} +16 -16
- package/dist/{skills-eval-CQ23MYQK.js → skills-eval-JDAXOPWC.js} +7 -7
- package/dist/{slash-commands-ROG3KIFJ.js → slash-commands-MNKQ5QP3.js} +4 -4
- package/dist/{startup-background-KDWCQMWT.js → startup-background-PJMWTKVO.js} +7 -7
- package/dist/{system-ZDW44ZGX.js → system-IVVZRKGI.js} +2 -2
- package/dist/{targets-AJWA6E7Z.js → targets-RFRUMCQ5.js} +4 -4
- package/dist/{terminal-lease-YFUSDPAD.js → terminal-lease-V2N2BIKQ.js} +2 -2
- package/dist/{usage-XSILDHKF.js → usage-LA33SG3R.js} +9 -9
- package/dist/{wiki-generate-RP22WP5K.js → wiki-generate-HL73TICA.js} +10 -10
- package/dist/worker/entry.js +4 -4
- package/docs/architecture/architecture.md +1 -0
- package/docs/architecture/context-engine.md +2 -2
- package/docs/architecture/observability.md +6 -4
- package/docs/architecture/prompt-envelope-and-tools.md +1 -1
- package/docs/architecture/session-lifecycle.md +1 -1
- package/docs/architecture/tui-design.md +12 -9
- package/docs/gui/parity/02-slash-and-surfaces.md +1 -1
- package/docs/guide/commands-and-modes.md +91 -5
- package/docs/guide/configuration-and-targets.md +2 -2
- package/docs/guide/proactive-memory.md +3 -3
- package/package.json +1 -1
- package/src/cli/usage.ts +2 -2
- package/src/domains/gateway/mcp/client.ts +1 -1
- package/src/domains/observability/background-memory-usage.ts +1 -1
- package/src/domains/observability/cost.ts +3 -3
- package/src/domains/observability/extension.ts +1 -1
- package/src/domains/observability/metrics.ts +1 -1
- package/src/domains/quota/anthropic-max-provider.ts +134 -0
- package/src/domains/quota/anthropic-usage.ts +220 -0
- package/src/domains/quota/antigravity-provider.ts +339 -0
- package/src/domains/quota/cache.ts +87 -0
- package/src/domains/quota/claude-code-provider.ts +169 -0
- package/src/domains/quota/codex-provider.ts +237 -0
- package/src/domains/quota/presentation.ts +240 -0
- package/src/domains/quota/registry.ts +23 -0
- package/src/domains/quota/service.ts +93 -0
- package/src/domains/quota/summary-feed.ts +86 -0
- package/src/domains/quota/types.ts +84 -0
- package/src/domains/session/usage.ts +2 -2
- package/src/engine/acp/commands.ts +1 -1
- package/src/entry/orchestrator.ts +2 -2
- package/src/interactive/chat-loop-messages.ts +2 -2
- package/src/interactive/chat-loop.ts +3 -3
- package/src/interactive/dispatch-board.ts +21 -3
- package/src/interactive/footer/dashboard.ts +13 -0
- package/src/interactive/footer/key-hints.ts +2 -2
- package/src/interactive/footer/pages.ts +55 -20
- package/src/interactive/footer-panel.ts +1 -1
- package/src/interactive/interactive-application.ts +5 -2
- package/src/interactive/interactive-input-runtime.ts +2 -2
- package/src/interactive/interactive-presentation.ts +15 -0
- package/src/interactive/interactive-slash-runtime.ts +2 -2
- package/src/interactive/interactive-tickers.ts +7 -1
- package/src/interactive/overlay-general-openers.ts +12 -8
- package/src/interactive/overlay-key-routing.ts +6 -9
- package/src/interactive/overlay-lifecycle.ts +10 -6
- package/src/interactive/overlay-session-lifecycle.ts +3 -3
- package/src/interactive/quota-view.ts +229 -0
- package/src/interactive/session-last-turn.ts +1 -1
- package/src/interactive/session-usage-reseed.ts +2 -2
- package/src/interactive/side-question.ts +2 -2
- package/src/interactive/slash-commands.ts +8 -8
- package/src/interactive/turn-context.ts +1 -1
- package/src/interactive/turn-prewarm.ts +1 -1
- package/src/interactive/{cost-overlay.ts → usage-overlay.ts} +121 -33
- package/src/interactive/welcome-dashboard.ts +26 -3
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quota adapter for the Antigravity CLI used as a delegation target.
|
|
3
|
+
*
|
|
4
|
+
* Antigravity stores its own OAuth record; on Linux that is a plain file
|
|
5
|
+
* under the agy CLI's state directory rather than an OS credential store.
|
|
6
|
+
* This adapter reads it and never writes it.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately narrower than QuotaBubble's provider: there is no token
|
|
9
|
+
* refresh here. Refreshing would mean POSTing another product's refresh
|
|
10
|
+
* token to Google with client credentials scraped out of the agy binary,
|
|
11
|
+
* which is a new auth flow against someone else's account. When the stored
|
|
12
|
+
* token has expired the adapter says so and leaves renewal to the operator.
|
|
13
|
+
*
|
|
14
|
+
* The quota surface has two shapes. A project-scoped quota summary is
|
|
15
|
+
* preferred; the available-models response is the fallback when a summary
|
|
16
|
+
* carries no usable bucket.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFile } from "node:fs/promises";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
|
|
23
|
+
import { formatPlan, type QuotaProvider, type UsageSnapshot, type UsageWindow } from "./types.js";
|
|
24
|
+
|
|
25
|
+
export const ANTIGRAVITY_QUOTA_PROVIDER_ID = "antigravity";
|
|
26
|
+
|
|
27
|
+
const DISPLAY_NAME = "Antigravity";
|
|
28
|
+
const USER_AGENT = "antigravity";
|
|
29
|
+
const REQUEST_TIMEOUT_MS = 20_000;
|
|
30
|
+
const EXPIRY_SKEW_MS = 60_000;
|
|
31
|
+
const WINDOW_ORDER: Record<string, number> = { session: 0, weekly: 1 };
|
|
32
|
+
const MODEL_PREFIXES = ["gemini", "claude", "gpt", "image", "imagen"];
|
|
33
|
+
|
|
34
|
+
const ENDPOINTS = [
|
|
35
|
+
"https://daily-cloudcode-pa.googleapis.com",
|
|
36
|
+
"https://daily-cloudcode-pa.sandbox.googleapis.com",
|
|
37
|
+
"https://cloudcode-pa.googleapis.com",
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/** The subset of Antigravity's stored OAuth record this adapter needs. */
|
|
41
|
+
export interface AntigravityCredentials {
|
|
42
|
+
accessToken: string;
|
|
43
|
+
refreshToken: string | null;
|
|
44
|
+
/** Epoch milliseconds the access token expires, or null when unparseable. */
|
|
45
|
+
expiryMs: number | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface AntigravityQuotaOptions {
|
|
49
|
+
/** Override the credential file location; defaults to the agy CLI's token file. */
|
|
50
|
+
credentialsPath?: string;
|
|
51
|
+
readCredentials?: () => Promise<AntigravityCredentials | null>;
|
|
52
|
+
fetch?: typeof globalThis.fetch;
|
|
53
|
+
timeoutMs?: number;
|
|
54
|
+
now?: () => Date;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function defaultCredentialsPath(): string {
|
|
58
|
+
return join(homedir(), ".gemini", "antigravity-cli", "antigravity-oauth-token");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseCredentials(raw: unknown): AntigravityCredentials | null {
|
|
62
|
+
if (!isRecord(raw)) return null;
|
|
63
|
+
const token = raw.token;
|
|
64
|
+
if (!isRecord(token)) return null;
|
|
65
|
+
const accessToken = token.access_token;
|
|
66
|
+
if (typeof accessToken !== "string" || accessToken.length === 0) return null;
|
|
67
|
+
const refreshToken = token.refresh_token;
|
|
68
|
+
return {
|
|
69
|
+
accessToken,
|
|
70
|
+
refreshToken: typeof refreshToken === "string" && refreshToken.length > 0 ? refreshToken : null,
|
|
71
|
+
expiryMs: asEpochMs(token.expiry),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function readCredentialsFile(path: string): Promise<AntigravityCredentials | null> {
|
|
76
|
+
let text: string;
|
|
77
|
+
try {
|
|
78
|
+
text = await readFile(path, "utf8");
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
return parseCredentials(JSON.parse(text));
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Outcome of one endpoint round trip, keeping auth failure distinct from transport failure. */
|
|
90
|
+
type PostResult = { kind: "ok"; data: Record<string, unknown> } | { kind: "auth" } | { kind: "error"; message: string };
|
|
91
|
+
|
|
92
|
+
type CollectResult =
|
|
93
|
+
| { kind: "ok"; windows: UsageWindow[]; plan: string | null }
|
|
94
|
+
| { kind: "auth" }
|
|
95
|
+
| { kind: "error"; message: string };
|
|
96
|
+
|
|
97
|
+
/** Build the read-only Antigravity quota adapter. */
|
|
98
|
+
export function createAntigravityQuotaProvider(options: AntigravityQuotaOptions = {}): QuotaProvider {
|
|
99
|
+
const credentialsPath = options.credentialsPath ?? defaultCredentialsPath();
|
|
100
|
+
const readCredentials = options.readCredentials ?? (() => readCredentialsFile(credentialsPath));
|
|
101
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
102
|
+
const timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
103
|
+
const now = options.now ?? (() => new Date());
|
|
104
|
+
|
|
105
|
+
const snapshot = (
|
|
106
|
+
status: UsageSnapshot["status"],
|
|
107
|
+
message: string | null,
|
|
108
|
+
extra: Partial<UsageSnapshot> = {},
|
|
109
|
+
): UsageSnapshot => ({
|
|
110
|
+
providerId: ANTIGRAVITY_QUOTA_PROVIDER_ID,
|
|
111
|
+
displayName: DISPLAY_NAME,
|
|
112
|
+
status,
|
|
113
|
+
windows: [],
|
|
114
|
+
message,
|
|
115
|
+
fetchedAt: now().toISOString(),
|
|
116
|
+
...extra,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const post = async (url: string, token: string, body: unknown): Promise<PostResult> => {
|
|
120
|
+
const controller = new AbortController();
|
|
121
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
122
|
+
let response: Response;
|
|
123
|
+
try {
|
|
124
|
+
response = await doFetch(url, {
|
|
125
|
+
method: "POST",
|
|
126
|
+
headers: {
|
|
127
|
+
Authorization: `Bearer ${token}`,
|
|
128
|
+
"Content-Type": "application/json",
|
|
129
|
+
"User-Agent": USER_AGENT,
|
|
130
|
+
},
|
|
131
|
+
body: JSON.stringify(body),
|
|
132
|
+
signal: controller.signal,
|
|
133
|
+
});
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return { kind: "error", message: describeError(error, timeoutMs) };
|
|
136
|
+
} finally {
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (response.status === 401 || response.status === 403) {
|
|
141
|
+
await discardBody(response);
|
|
142
|
+
return { kind: "auth" };
|
|
143
|
+
}
|
|
144
|
+
if (response.status !== 200) {
|
|
145
|
+
await discardBody(response);
|
|
146
|
+
return { kind: "error", message: `HTTP ${response.status}` };
|
|
147
|
+
}
|
|
148
|
+
let data: unknown;
|
|
149
|
+
try {
|
|
150
|
+
data = await response.json();
|
|
151
|
+
} catch {
|
|
152
|
+
return { kind: "error", message: "Unexpected usage response" };
|
|
153
|
+
}
|
|
154
|
+
return isRecord(data) ? { kind: "ok", data } : { kind: "error", message: "Unexpected usage response" };
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const collect = async (base: string, token: string): Promise<CollectResult> => {
|
|
158
|
+
const load = await post(`${base}/v1internal:loadCodeAssist`, token, { metadata: { ideType: "ANTIGRAVITY" } });
|
|
159
|
+
if (load.kind !== "ok") return load;
|
|
160
|
+
|
|
161
|
+
const project = typeof load.data.cloudaicompanionProject === "string" ? load.data.cloudaicompanionProject : null;
|
|
162
|
+
const plan = tierPlan(load.data.currentTier);
|
|
163
|
+
|
|
164
|
+
if (project !== null) {
|
|
165
|
+
const summary = await post(`${base}/v1internal:retrieveUserQuotaSummary`, token, { project });
|
|
166
|
+
if (summary.kind === "auth") return summary;
|
|
167
|
+
if (summary.kind === "ok") {
|
|
168
|
+
const windows = windowsFromSummary(summary.data);
|
|
169
|
+
if (windows.length > 0) return { kind: "ok", windows, plan };
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const models = await post(`${base}/v1internal:fetchAvailableModels`, token, project === null ? {} : { project });
|
|
174
|
+
if (models.kind !== "ok") return models;
|
|
175
|
+
return { kind: "ok", windows: modelWindows(models.data), plan };
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
id: ANTIGRAVITY_QUOTA_PROVIDER_ID,
|
|
180
|
+
displayName: DISPLAY_NAME,
|
|
181
|
+
|
|
182
|
+
async detect(): Promise<boolean> {
|
|
183
|
+
return (await readCredentials()) !== null;
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
async fetch(): Promise<UsageSnapshot> {
|
|
187
|
+
const credentials = await readCredentials();
|
|
188
|
+
if (credentials === null) {
|
|
189
|
+
return snapshot("no_credentials", "No Antigravity credentials found");
|
|
190
|
+
}
|
|
191
|
+
if (credentials.expiryMs !== null && credentials.expiryMs <= now().getTime() + EXPIRY_SKEW_MS) {
|
|
192
|
+
return snapshot("expired", "Antigravity's stored token expired; run the agy command once to refresh it");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let sawAuthFailure = false;
|
|
196
|
+
let lastError: string | null = null;
|
|
197
|
+
for (const base of ENDPOINTS) {
|
|
198
|
+
const result = await collect(base, credentials.accessToken);
|
|
199
|
+
if (result.kind === "auth") {
|
|
200
|
+
sawAuthFailure = true;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (result.kind === "error") {
|
|
204
|
+
lastError = result.message;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (result.windows.length > 0) {
|
|
208
|
+
return snapshot("ok", null, { windows: result.windows, plan: result.plan });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (sawAuthFailure) return snapshot("expired", "Sign in with Antigravity again");
|
|
213
|
+
if (lastError !== null) return snapshot("error", lastError);
|
|
214
|
+
return snapshot("error", "Antigravity returned no quota");
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function tierPlan(raw: unknown): string | null {
|
|
220
|
+
if (!isRecord(raw)) return null;
|
|
221
|
+
const plan = formatPlan(typeof raw.name === "string" ? raw.name : null);
|
|
222
|
+
if (plan === null) return null;
|
|
223
|
+
return plan.toLowerCase() === DISPLAY_NAME.toLowerCase() ? null : plan;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Preserve every reported group; Gemini stays first for the compact summary. */
|
|
227
|
+
function windowsFromSummary(data: Record<string, unknown>): UsageWindow[] {
|
|
228
|
+
if (!Array.isArray(data.groups)) return [];
|
|
229
|
+
const groups = data.groups.filter(isRecord).sort((a, b) => Number(isGeminiGroup(b)) - Number(isGeminiGroup(a)));
|
|
230
|
+
return groups.flatMap((group, index) => {
|
|
231
|
+
const scope =
|
|
232
|
+
typeof group.displayName === "string" && group.displayName.trim()
|
|
233
|
+
? group.displayName.trim()
|
|
234
|
+
: `Model group ${index + 1}`;
|
|
235
|
+
return windowsFromGroup(group).map((window) => ({
|
|
236
|
+
...window,
|
|
237
|
+
key: index === 0 ? window.key : `group.${index}.${window.key}`,
|
|
238
|
+
scope,
|
|
239
|
+
}));
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function windowsFromGroup(group: Record<string, unknown>): UsageWindow[] {
|
|
244
|
+
if (!Array.isArray(group.buckets)) return [];
|
|
245
|
+
const windows: UsageWindow[] = [];
|
|
246
|
+
for (const bucket of group.buckets) {
|
|
247
|
+
if (!isRecord(bucket)) continue;
|
|
248
|
+
const window = typeof bucket.window === "string" ? bucket.window.toLowerCase() : null;
|
|
249
|
+
const usedPct = usedFromRemaining(bucket.remainingFraction);
|
|
250
|
+
if (usedPct === null) continue;
|
|
251
|
+
if (window === "5h") {
|
|
252
|
+
windows.push({ key: "session", label: "5h", short: "5h", usedPct, resetsAt: asIsoString(bucket.resetTime) });
|
|
253
|
+
} else if (window === "weekly") {
|
|
254
|
+
windows.push({ key: "weekly", label: "Weekly", short: "wk", usedPct, resetsAt: asIsoString(bucket.resetTime) });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return windows.sort((left, right) => (WINDOW_ORDER[left.key] ?? 2) - (WINDOW_ORDER[right.key] ?? 2));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function isGeminiGroup(group: Record<string, unknown>): boolean {
|
|
261
|
+
if (mentionsGemini(group.displayName) || mentionsGemini(group.description)) return true;
|
|
262
|
+
if (!Array.isArray(group.buckets)) return false;
|
|
263
|
+
return group.buckets.some((bucket) => {
|
|
264
|
+
if (!isRecord(bucket)) return false;
|
|
265
|
+
const id = typeof bucket.bucketId === "string" ? bucket.bucketId.toLowerCase() : "";
|
|
266
|
+
return id.startsWith("gemini-") || mentionsGemini(bucket.displayName);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function mentionsGemini(value: unknown): boolean {
|
|
271
|
+
return typeof value === "string" && value.toLowerCase().includes("gemini");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The fallback reports model quotas without declaring a window duration. */
|
|
275
|
+
function modelWindows(data: Record<string, unknown>): UsageWindow[] {
|
|
276
|
+
if (!isRecord(data.models)) return [];
|
|
277
|
+
const windows: UsageWindow[] = [];
|
|
278
|
+
for (const [name, info] of Object.entries(data.models)) {
|
|
279
|
+
if (!isRecord(info) || !isRecord(info.quotaInfo)) continue;
|
|
280
|
+
if (!MODEL_PREFIXES.some((prefix) => name.toLowerCase().startsWith(prefix))) continue;
|
|
281
|
+
const usedPct = usedFromRemaining(info.quotaInfo.remainingFraction);
|
|
282
|
+
if (usedPct === null) continue;
|
|
283
|
+
windows.push({
|
|
284
|
+
key: `model.${name}`,
|
|
285
|
+
label: "Model quota",
|
|
286
|
+
short: name,
|
|
287
|
+
scope: name,
|
|
288
|
+
usedPct,
|
|
289
|
+
resetsAt: asIsoString(info.quotaInfo.resetTime),
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
return windows;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function usedFromRemaining(raw: unknown): number | null {
|
|
296
|
+
const remaining = asNumber(raw);
|
|
297
|
+
if (remaining === null) return null;
|
|
298
|
+
return (1 - Math.min(1, Math.max(0, remaining))) * 100;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
302
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function asNumber(value: unknown): number | null {
|
|
306
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
307
|
+
if (typeof value === "string") {
|
|
308
|
+
const parsed = Number.parseFloat(value.trim());
|
|
309
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
310
|
+
}
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function asIsoString(value: unknown): string | null {
|
|
315
|
+
if (typeof value !== "string" || value.trim().length === 0) return null;
|
|
316
|
+
const parsed = new Date(value);
|
|
317
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function asEpochMs(value: unknown): number | null {
|
|
321
|
+
if (typeof value !== "string" || value.trim().length === 0) return null;
|
|
322
|
+
const parsed = Date.parse(value);
|
|
323
|
+
return Number.isNaN(parsed) ? null : parsed;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function describeError(error: unknown, timeoutMs: number): string {
|
|
327
|
+
if (error instanceof Error) {
|
|
328
|
+
return error.name === "AbortError" ? `timeout after ${timeoutMs}ms` : error.message;
|
|
329
|
+
}
|
|
330
|
+
return String(error);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function discardBody(response: Response): Promise<void> {
|
|
334
|
+
try {
|
|
335
|
+
await response.body?.cancel();
|
|
336
|
+
} catch {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TTL cache for the last good usage snapshot of each quota provider.
|
|
3
|
+
*
|
|
4
|
+
* Refresh is lazy by design. Per the quota decision log there is no
|
|
5
|
+
* standalone background timer: a caller asks for a snapshot, and the cache
|
|
6
|
+
* either serves a fresh one or spends one provider read. The cache lives in
|
|
7
|
+
* memory for this slice; persisting it across sessions is a later concern.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { QuotaProvider, UsageSnapshot } from "./types.js";
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_QUOTA_CACHE_TTL_MS = 5 * 60_000;
|
|
13
|
+
|
|
14
|
+
export interface QuotaCacheOptions {
|
|
15
|
+
ttlMs?: number;
|
|
16
|
+
/** Clock source in milliseconds, injected so tests can advance time. */
|
|
17
|
+
now?: () => number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface QuotaCache {
|
|
21
|
+
/** The cached snapshot when it is still inside the TTL, otherwise null. */
|
|
22
|
+
read(providerId: string): UsageSnapshot | null;
|
|
23
|
+
/** The last good snapshot regardless of age, marked stale when expired. */
|
|
24
|
+
readLastGood(providerId: string): UsageSnapshot | null;
|
|
25
|
+
/** Store a successful snapshot as the new last good value. */
|
|
26
|
+
write(snapshot: UsageSnapshot): void;
|
|
27
|
+
/** Drop one provider's entry, or every entry when no id is given. */
|
|
28
|
+
clear(providerId?: string): void;
|
|
29
|
+
/**
|
|
30
|
+
* Serve a fresh snapshot, reading from the provider only when the cached
|
|
31
|
+
* value has expired. A failed read falls back to the last good snapshot
|
|
32
|
+
* marked stale, so a transient outage does not erase known usage.
|
|
33
|
+
*/
|
|
34
|
+
resolve(provider: QuotaProvider): Promise<UsageSnapshot>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface CacheEntry {
|
|
38
|
+
snapshot: UsageSnapshot;
|
|
39
|
+
storedAtMs: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createQuotaCache(options: QuotaCacheOptions = {}): QuotaCache {
|
|
43
|
+
const ttlMs = options.ttlMs ?? DEFAULT_QUOTA_CACHE_TTL_MS;
|
|
44
|
+
const now = options.now ?? Date.now;
|
|
45
|
+
const entries = new Map<string, CacheEntry>();
|
|
46
|
+
|
|
47
|
+
const fresh = (entry: CacheEntry | undefined): boolean => entry !== undefined && now() - entry.storedAtMs < ttlMs;
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
read(providerId) {
|
|
51
|
+
const entry = entries.get(providerId);
|
|
52
|
+
return fresh(entry) && entry !== undefined ? entry.snapshot : null;
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
readLastGood(providerId) {
|
|
56
|
+
const entry = entries.get(providerId);
|
|
57
|
+
if (entry === undefined) return null;
|
|
58
|
+
return fresh(entry) ? entry.snapshot : { ...entry.snapshot, stale: true };
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
write(snapshot) {
|
|
62
|
+
entries.set(snapshot.providerId, { snapshot, storedAtMs: now() });
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
clear(providerId) {
|
|
66
|
+
if (providerId === undefined) entries.clear();
|
|
67
|
+
else entries.delete(providerId);
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
async resolve(provider) {
|
|
71
|
+
const cached = entries.get(provider.id);
|
|
72
|
+
if (fresh(cached) && cached !== undefined) return cached.snapshot;
|
|
73
|
+
|
|
74
|
+
const snapshot = await provider.fetch();
|
|
75
|
+
if (snapshot.status === "ok") {
|
|
76
|
+
entries.set(provider.id, { snapshot, storedAtMs: now() });
|
|
77
|
+
return snapshot;
|
|
78
|
+
}
|
|
79
|
+
if (cached === undefined) return snapshot;
|
|
80
|
+
return {
|
|
81
|
+
...cached.snapshot,
|
|
82
|
+
stale: true,
|
|
83
|
+
message: snapshot.message ?? cached.snapshot.message ?? null,
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quota adapter for the `claude-code` runtime.
|
|
3
|
+
*
|
|
4
|
+
* Clio does not store Claude Code credentials of its own: the runtime
|
|
5
|
+
* delegates authentication to the installed `claude` command, as
|
|
6
|
+
* `src/domains/providers/runtimes/claude/common.ts` states. This adapter
|
|
7
|
+
* therefore reads Claude Code's own OAuth token file and never writes it.
|
|
8
|
+
*
|
|
9
|
+
* The request and response parsing live in `anthropic-usage.ts`, shared
|
|
10
|
+
* with the `anthropic-max` adapter, because both spend the same kind of
|
|
11
|
+
* Anthropic subscription through the same endpoint.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFile } from "node:fs/promises";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
|
|
18
|
+
import { fetchAnthropicUsage } from "./anthropic-usage.js";
|
|
19
|
+
import { formatPlan, type QuotaProvider, type UsageSnapshot } from "./types.js";
|
|
20
|
+
|
|
21
|
+
export const CLAUDE_CODE_QUOTA_PROVIDER_ID = "claude-code";
|
|
22
|
+
|
|
23
|
+
const DISPLAY_NAME = "Claude Code";
|
|
24
|
+
const REQUEST_TIMEOUT_MS = 15_000;
|
|
25
|
+
|
|
26
|
+
/** The subset of Claude Code's stored OAuth record this adapter needs. */
|
|
27
|
+
export interface ClaudeCodeCredentials {
|
|
28
|
+
accessToken: string;
|
|
29
|
+
subscriptionType: string | null;
|
|
30
|
+
/** Epoch milliseconds the access token expires, or null when the record omits it. */
|
|
31
|
+
expiresAtMs: number | null;
|
|
32
|
+
/** Epoch milliseconds the refresh token expires, or null when the record omits it. */
|
|
33
|
+
refreshTokenExpiresAtMs: number | null;
|
|
34
|
+
/** True when a refresh token is present, meaning the `claude` command can renew itself. */
|
|
35
|
+
hasRefreshToken: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ClaudeCodeQuotaOptions {
|
|
39
|
+
/** Override the credential file location; defaults to `~/.claude/.credentials.json`. */
|
|
40
|
+
credentialsPath?: string;
|
|
41
|
+
/** Replace credential reading entirely, which is how tests supply a token. */
|
|
42
|
+
readCredentials?: () => Promise<ClaudeCodeCredentials | null>;
|
|
43
|
+
fetch?: typeof globalThis.fetch;
|
|
44
|
+
timeoutMs?: number;
|
|
45
|
+
now?: () => Date;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function defaultClaudeCredentialsPath(): string {
|
|
49
|
+
return join(homedir(), ".claude", ".credentials.json");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Extract the OAuth access token from a parsed credential document. */
|
|
53
|
+
export function parseClaudeCredentials(raw: unknown): ClaudeCodeCredentials | null {
|
|
54
|
+
if (!isRecord(raw)) return null;
|
|
55
|
+
const oauth = raw.claudeAiOauth;
|
|
56
|
+
if (!isRecord(oauth)) return null;
|
|
57
|
+
const token = oauth.accessToken;
|
|
58
|
+
if (typeof token !== "string" || token.length === 0) return null;
|
|
59
|
+
const subscription = oauth.subscriptionType;
|
|
60
|
+
const refreshToken = oauth.refreshToken;
|
|
61
|
+
return {
|
|
62
|
+
accessToken: token,
|
|
63
|
+
subscriptionType: typeof subscription === "string" ? subscription : null,
|
|
64
|
+
expiresAtMs: asNumber(oauth.expiresAt),
|
|
65
|
+
refreshTokenExpiresAtMs: asNumber(oauth.refreshTokenExpiresAt),
|
|
66
|
+
hasRefreshToken: typeof refreshToken === "string" && refreshToken.length > 0,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Why a stored token cannot be used, read from the record alone.
|
|
72
|
+
*
|
|
73
|
+
* Claude Code rewrites this file when its own command refreshes, so a stale
|
|
74
|
+
* `expiresAt` means the command has not run lately rather than that the
|
|
75
|
+
* subscription lapsed. The two cases need different advice, and neither
|
|
76
|
+
* needs a network call to detect.
|
|
77
|
+
*/
|
|
78
|
+
function expiryMessage(credentials: ClaudeCodeCredentials, nowMs: number): string | null {
|
|
79
|
+
const { expiresAtMs, refreshTokenExpiresAtMs, hasRefreshToken } = credentials;
|
|
80
|
+
if (expiresAtMs === null || expiresAtMs > nowMs) return null;
|
|
81
|
+
const refreshUsable = hasRefreshToken && (refreshTokenExpiresAtMs === null || refreshTokenExpiresAtMs > nowMs);
|
|
82
|
+
return refreshUsable
|
|
83
|
+
? "Claude Code's stored token expired; run the claude command once to refresh it"
|
|
84
|
+
: "Sign in with Claude Code again";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function readCredentialsFile(path: string): Promise<ClaudeCodeCredentials | null> {
|
|
88
|
+
let text: string;
|
|
89
|
+
try {
|
|
90
|
+
text = await readFile(path, "utf8");
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
return parseClaudeCredentials(JSON.parse(text));
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Build the read-only `claude-code` quota adapter. */
|
|
102
|
+
export function createClaudeCodeQuotaProvider(options: ClaudeCodeQuotaOptions = {}): QuotaProvider {
|
|
103
|
+
const credentialsPath = options.credentialsPath ?? defaultClaudeCredentialsPath();
|
|
104
|
+
const readCredentials = options.readCredentials ?? (() => readCredentialsFile(credentialsPath));
|
|
105
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
106
|
+
const timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
107
|
+
const now = options.now ?? (() => new Date());
|
|
108
|
+
|
|
109
|
+
const snapshot = (
|
|
110
|
+
status: UsageSnapshot["status"],
|
|
111
|
+
message: string | null,
|
|
112
|
+
extra: Partial<UsageSnapshot> = {},
|
|
113
|
+
): UsageSnapshot => ({
|
|
114
|
+
providerId: CLAUDE_CODE_QUOTA_PROVIDER_ID,
|
|
115
|
+
displayName: DISPLAY_NAME,
|
|
116
|
+
status,
|
|
117
|
+
windows: [],
|
|
118
|
+
message,
|
|
119
|
+
fetchedAt: now().toISOString(),
|
|
120
|
+
...extra,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
id: CLAUDE_CODE_QUOTA_PROVIDER_ID,
|
|
125
|
+
displayName: DISPLAY_NAME,
|
|
126
|
+
|
|
127
|
+
async detect(): Promise<boolean> {
|
|
128
|
+
return (await readCredentials()) !== null;
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
async fetch(): Promise<UsageSnapshot> {
|
|
132
|
+
const credentials = await readCredentials();
|
|
133
|
+
if (credentials === null) {
|
|
134
|
+
return snapshot("no_credentials", "No Claude Code credentials found");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// A token the record already declares expired cannot be spent, so skip the request.
|
|
138
|
+
const expired = expiryMessage(credentials, now().getTime());
|
|
139
|
+
if (expired !== null) {
|
|
140
|
+
return snapshot("expired", expired);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const usage = await fetchAnthropicUsage(credentials.accessToken, { fetch: doFetch, timeoutMs });
|
|
144
|
+
if (usage.status !== "ok") {
|
|
145
|
+
return snapshot(usage.status, usage.message ?? "Sign in with Claude Code again", {
|
|
146
|
+
retryAfterSeconds: usage.retryAfterSeconds,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return snapshot("ok", null, {
|
|
150
|
+
windows: usage.windows,
|
|
151
|
+
credits: usage.credits,
|
|
152
|
+
plan: formatPlan(credentials.subscriptionType),
|
|
153
|
+
});
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
159
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function asNumber(value: unknown): number | null {
|
|
163
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
164
|
+
if (typeof value === "string") {
|
|
165
|
+
const parsed = Number.parseFloat(value.trim());
|
|
166
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|