char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somsubscription: add usage limits for opencode-goaa0b468

main
8.2 KiB217 linesraw
1import type { AuthResult } from "@earendil-works/pi-ai";
2import * as j from "@char/justin";
3import type { Provider, SubscriptionUsage, UsageWindow } from "./types.ts";
4
5const object = (value: unknown): Record<string, unknown> =>
6  value !== null && typeof value === "object" && !Array.isArray(value)
7    ? value as Record<string, unknown>
8    : {};
9const finite = (value: unknown): value is number =>
10  typeof value === "number" && Number.isFinite(value);
11
12const goWindowOf = j.validation.compile(j.union(
13  j.obj({ percent: j.number, resetsAt: j.optional(j.union(j.string, j.null)) }),
14  j.obj({ usagePercent: j.number, resetInSec: j.optional(j.union(j.number, j.null)) }),
15));
16
17export function usageWindows(provider: Provider, value: unknown, now = Date.now()): UsageWindow[] {
18  const data = object(value);
19  const windows: UsageWindow[] = [];
20  const add = (label: string, percent: unknown, reset: unknown) => {
21    if (!finite(percent) || percent < 0) return;
22    const date = new Date(
23      typeof reset === "string"
24        ? Date.parse(reset)
25        : finite(reset) && reset > 0
26        ? reset * 1000
27        : NaN,
28    );
29    windows.push({
30      label,
31      usedPercent: percent,
32      resetsAt: Number.isFinite(date.getTime()) ? date.toISOString() : null,
33    });
34  };
35  if (provider === "anthropic") {
36    for (
37      const [key, label] of [
38        ["five_hour", "5 hours"],
39        ["seven_day", "Weekly"],
40        ["seven_day_opus", "Weekly · Opus"],
41        ["seven_day_sonnet", "Weekly · Sonnet"],
42        ["seven_day_oauth_apps", "Weekly · OAuth apps"],
43      ]
44    ) {
45      const window = object(data[key]);
46      add(label, window.utilization, window.resets_at);
47    }
48    if (Array.isArray(data.limits)) {
49      for (const raw of data.limits) {
50        const limit = object(raw);
51        if (limit.is_active === false) continue;
52        const model = object(object(limit.scope).model).display_name;
53        if (typeof model !== "string") continue;
54        const label = `Weekly · ${model}`;
55        if (!windows.some((window) => window.label === label)) {
56          add(label, limit.percent, limit.resets_at);
57        }
58      }
59    }
60  } else if (provider === "opencode-go") {
61    const usage = object(data.usage);
62    for (
63      const [key, label] of [["rolling", "5 hours"], ["weekly", "Weekly"], ["monthly", "Monthly"]]
64    ) {
65      // older Go endpoints report relative reset times and use top-level *Usage fields.
66      const window = goWindowOf(usage[key] ?? data[`${key}Usage`]).value;
67      if (!window) continue;
68      if ("percent" in window) {
69        add(label, window.percent, window.resetsAt);
70      } else {
71        const seconds = window.resetInSec;
72        add(
73          label,
74          window.usagePercent,
75          finite(seconds) && seconds >= 0 ? now / 1000 + seconds : null,
76        );
77      }
78    }
79  } else {
80    const limits = [{ label: "", rate_limit: data.rate_limit }];
81    if (Array.isArray(data.additional_rate_limits)) {
82      for (const raw of data.additional_rate_limits) {
83        const extra = object(raw);
84        const label = extra.limit_name ?? extra.metered_feature;
85        if (typeof label === "string") limits.push({ label, rate_limit: extra.rate_limit });
86      }
87    }
88    for (const { label, rate_limit } of limits) {
89      const rate = object(rate_limit);
90      for (
91        const [key, fallback] of [["primary_window", "Session"], ["secondary_window", "Weekly"]]
92      ) {
93        const window = object(rate[key]);
94        const seconds = window.limit_window_seconds;
95        const duration = finite(seconds) && seconds > 0
96          ? seconds % 86400 === 0 ? `${seconds / 86400} days` : `${seconds / 3600} hours`
97          : fallback;
98        add(label ? `${label} · ${duration}` : duration, window.used_percent, window.reset_at);
99      }
100    }
101  }
102  return windows;
103}
104
105export function subscriptionUsage({
106  auth,
107  fetch: request = fetch,
108  now = Date.now,
109}: {
110  auth: (provider: string, signal?: AbortSignal) => Promise<AuthResult | undefined>;
111  fetch?: typeof fetch;
112  now?: () => number;
113}) {
114  const cache = new Map<Provider, {
115    token: string;
116    until: number;
117    result: SubscriptionUsage;
118  }>();
119  const pending = new Map<Provider, Promise<SubscriptionUsage>>();
120
121  async function load(provider: Provider): Promise<SubscriptionUsage> {
122    const signal = AbortSignal.timeout(15_000);
123    let resolved: AuthResult | undefined;
124    try {
125      resolved = await auth(provider, signal);
126    } catch {
127      return {
128        status: "error",
129        message: "Could not refresh login. Try signing in again in Settings.",
130      };
131    }
132    if (!resolved?.auth.apiKey || (provider !== "opencode-go" && resolved.source !== "OAuth")) {
133      cache.delete(provider);
134      return {
135        status: "unavailable",
136        message: provider === "opencode-go"
137          ? "Add your OpenCode Go API key in Settings to view subscription limits."
138          : "Subscription limits require an OAuth login in Settings; API keys do not expose them.",
139      };
140    }
141    const token = resolved.auth.apiKey;
142    const cached = cache.get(provider);
143    if (cached?.token === token && cached.until > now()) return cached.result;
144    let result: SubscriptionUsage;
145    let ttl = 60_000;
146    try {
147      const headers = new Headers({ authorization: `Bearer ${token}`, accept: "application/json" });
148      const url = provider === "anthropic"
149        ? "https://api.anthropic.com/api/oauth/usage"
150        : provider === "opencode-go"
151        ? "https://opencode.ai/inference/go/v1/usage"
152        : "https://chatgpt.com/backend-api/wham/usage";
153      if (provider === "anthropic") {
154        headers.set("anthropic-beta", "oauth-2025-04-20");
155      } else if (provider !== "opencode-go") {
156        try {
157          const payload = JSON.parse(
158            atob(token.split(".")[1].replaceAll("-", "+").replaceAll("_", "/")),
159          );
160          const account = payload["https://api.openai.com/auth"]?.chatgpt_account_id;
161          if (typeof account !== "string" || !account) throw new Error("Missing account");
162          headers.set("ChatGPT-Account-Id", account);
163        } catch {
164          return {
165            status: "error",
166            message: "Could not identify the ChatGPT account. Sign in again in Settings.",
167          };
168        }
169      }
170      const response = await request(url, { headers, signal, redirect: "error" });
171      if (!response.ok) {
172        const message = response.status === 401 || response.status === 403
173          ? provider === "opencode-go"
174            ? "Usage access was denied. Check your OpenCode Go API key and subscription in Settings."
175            : provider === "anthropic"
176            ? "Usage access was denied. Sign in again in Settings; Claude may need the user:profile scope."
177            : "Usage access was denied. Sign in again in Settings."
178          : response.status === 429
179          ? "Usage is rate limited. Try again in a few minutes."
180          : `Usage service returned HTTP ${response.status}.`;
181        if (response.status === 429) {
182          const retry = response.headers.get("retry-after") ?? "";
183          const seconds = Number(retry);
184          const until = retry.trim() && Number.isFinite(seconds)
185            ? now() + seconds * 1000
186            : Date.parse(retry);
187          ttl = Math.max(5 * 60_000, Number.isFinite(until) ? until - now() : 0);
188        }
189        await response.body?.cancel();
190        result = { status: "error", message };
191      } else {
192        const windows = usageWindows(provider, await response.json(), now());
193        result = windows.length
194          ? { status: "ok", fetchedAt: new Date(now()).toISOString(), windows }
195          : {
196            status: "unavailable",
197            message: "No subscription usage windows were reported for this account.",
198          };
199      }
200    } catch {
201      result = {
202        status: "error",
203        message: "Could not reach the usage service. Try again shortly.",
204      };
205    }
206    cache.set(provider, { token, until: now() + ttl, result });
207    return result;
208  }
209
210  return (provider: Provider): Promise<SubscriptionUsage> => {
211    const existing = pending.get(provider);
212    if (existing) return existing;
213    const next = load(provider).finally(() => pending.delete(provider));
214    pending.set(provider, next);
215    return next;
216  };
217}