authorgravatar for saiansh2525@gmail.comYash Singh <saiansh2525@gmail.com> 2026-09-29 15:28:50-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-29 15:28:50-05:00
log1a553d0f5dd2aa39a64895ec770dc51ef1448b1a
tree630ef60d6e04c7d408965c12d0338c3a673aeccd
parent451afcb22d93f06cb24f9bc16703404564952553
signature Signed by PGP key B5690EEEBB952194

fix(shared): merge OpenCode Go limits by credential (#14209)


5 files changed, 130 insertions(+), 32 deletions(-)

apps/server/src/provider/Layers/OpenCodeProvider.test.ts+6
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import * as NodeAssert from "node:assert/strict";1import * as NodeAssert from "node:assert/strict";
2import * as NodeCrypto from "node:crypto";
23
3import * as NodeServices from "@effect/platform-node/NodeServices";4import * as NodeServices from "@effect/platform-node/NodeServices";
4import { it } from "@effect/vitest";5import { it } from "@effect/vitest";
...@@ -71,6 +72,10 @@ it.effect("reads Go limits with the instance's XDG credentials and preserves res...@@ -71,6 +72,10 @@ it.effect("reads Go limits with the instance's XDG credentials and preserves res
71 Effect.provide(NodeServices.layer),72 Effect.provide(NodeServices.layer),
72 );73 );
73 NodeAssert.equal(limits.unavailable, undefined);74 NodeAssert.equal(limits.unavailable, undefined);
75 NodeAssert.equal(
76 limits.credentialFingerprint,
77 NodeCrypto.createHash("sha256").update("opencode-go\0instance-key").digest("hex"),
78 );
74 NodeAssert.deepEqual(79 NodeAssert.deepEqual(
75 limits.windows.map(({ kind, usedPercent, resetsAt: reset }) => ({80 limits.windows.map(({ kind, usedPercent, resetsAt: reset }) => ({
76 kind,81 kind,
...@@ -139,6 +144,7 @@ it.effect("keeps Go entitlement absence distinct from failed or malformed usage...@@ -139,6 +144,7 @@ it.effect("keeps Go entitlement absence distinct from failed or malformed usage
139 Effect.provide(NodeServices.layer),144 Effect.provide(NodeServices.layer),
140 );145 );
141 NodeAssert.equal(limits.unavailable?.reason, reason);146 NodeAssert.equal(limits.unavailable?.reason, reason);
147 NodeAssert.equal(limits.credentialFingerprint, undefined);
142 NodeAssert.deepEqual(limits.windows, []);148 NodeAssert.deepEqual(limits.windows, []);
143 }149 }
144 }),150 }),
apps/server/src/provider/Layers/openCodeUsageLimits.ts+11-1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import * as NodeOS from "node:os";1import * as NodeOS from "node:os";
2import * as NodeCrypto from "node:crypto";
23
3import type { ServerProviderUsageWindow } from "@t3tools/contracts";4import type { ServerProviderUsageWindow } from "@t3tools/contracts";
4import * as DateTime from "effect/DateTime";5import * as DateTime from "effect/DateTime";
...@@ -95,7 +96,16 @@ export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")(...@@ -95,7 +96,16 @@ export const readOpenCodeGoUsageLimits = Effect.fn("readOpenCodeGoUsageLimits")(
95 resetsAt: DateTime.formatIso(body.usage.monthly.resetsAt),96 resetsAt: DateTime.formatIso(body.usage.monthly.resetsAt),
96 },97 },
97 ];98 ];
98 return makeUsageLimits({ checkedAt, windows });99 return {
100 ...makeUsageLimits({ checkedAt, windows }),
101 // Go's usage response has no account ID. An unkeyed hash matches across
102 // environments without a shared secret. It permits offline guesses, but
103 // Go keys are randomly generated.
104 credentialFingerprint: NodeCrypto.createHash("sha256")
105 .update("opencode-go\0")
106 .update(apiKey)
107 .digest("hex"),
108 };
99 }).pipe(109 }).pipe(
100 Effect.timeout("5 seconds"),110 Effect.timeout("5 seconds"),
101 Effect.orElseSucceed(() =>111 Effect.orElseSucceed(() =>
packages/contracts/src/providerUsageLimits.ts+2
...@@ -50,6 +50,8 @@ export type ServerProviderResetCredits = typeof ServerProviderResetCredits.Type;...@@ -50,6 +50,8 @@ export type ServerProviderResetCredits = typeof ServerProviderResetCredits.Type;
50export const ServerProviderUsageLimits = Schema.Struct({50export const ServerProviderUsageLimits = Schema.Struct({
51 checkedAt: IsoDateTime,51 checkedAt: IsoDateTime,
52 windows: ForwardCompatibleArray(ServerProviderUsageWindow),52 windows: ForwardCompatibleArray(ServerProviderUsageWindow),
53 /** Opaque credential identity when the provider does not report an account. */
54 credentialFingerprint: Schema.optional(TrimmedNonEmptyString),
53 resetCredits: Schema.optional(ServerProviderResetCredits),55 resetCredits: Schema.optional(ServerProviderResetCredits),
54 /** Provider-owned usage settings when quota windows are not available to the client. */56 /** Provider-owned usage settings when quota windows are not available to the client. */
55 externalUsage: Schema.optional(57 externalUsage: Schema.optional(
packages/shared/src/usageLimits.test.ts+68
...@@ -207,6 +207,74 @@ describe("pools", () => {...@@ -207,6 +207,74 @@ describe("pools", () => {
207 expect(accounts[0]?.limits.windows[0]?.usedPercent).toBe(55);207 expect(accounts[0]?.limits.windows[0]?.usedPercent).toBe(55);
208 });208 });
209209
210 it("merges OpenCode Go limits from machines with the same API key", () => {
211 const go = provider({
212 driver: ProviderDriverKind.make("opencode"),
213 instanceId: ProviderInstanceId.make("opencode"),
214 auth: { status: "authenticated" },
215 usageLimits: {
216 checkedAt,
217 credentialFingerprint: "shared-go-key",
218 windows: [{ ...window, id: "go_rolling", usedPercent: 3 }],
219 },
220 });
221 const input = new Map([
222 [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { providers: [go] } }],
223 [
224 EnvironmentId.make("env-b"),
225 {
226 entry: { target: { label: "Desktop" } },
227 serverConfig: {
228 providers: [
229 {
230 ...go,
231 usageLimits: {
232 ...go.usageLimits!,
233 checkedAt: "2026-09-03T11:30:00.000Z",
234 windows: [{ ...window, id: "go_rolling", usedPercent: 4 }],
235 },
236 },
237 ],
238 },
239 },
240 ],
241 ]);
242 const accounts = collectLimitAccounts(input);
243 expect(accounts).toHaveLength(1);
244 expect(accounts[0]?.environments).toEqual([
245 { environmentId: "env-a", label: "Laptop" },
246 { environmentId: "env-b", label: "Desktop" },
247 ]);
248 expect(collectLimitPools(accounts, now)[0]?.windows[0]?.members).toHaveLength(1);
249 expect(accounts[0]?.limits.windows[0]?.usedPercent).toBe(4);
250
251 const differentKey = {
252 ...go,
253 usageLimits: { ...go.usageLimits!, credentialFingerprint: "other-go-key" },
254 };
255 input.set(EnvironmentId.make("env-b"), {
256 entry: { target: { label: "Desktop" } },
257 serverConfig: { providers: [differentKey] },
258 });
259 expect(collectLimitAccounts(input)).toHaveLength(2);
260
261 input.set(EnvironmentId.make("env-a"), {
262 ...laptop,
263 serverConfig: {
264 providers: [{ ...go, auth: { status: "authenticated", email: "same@example.com" } }],
265 },
266 });
267 input.set(EnvironmentId.make("env-b"), {
268 entry: { target: { label: "Desktop" } },
269 serverConfig: {
270 providers: [
271 { ...differentKey, auth: { status: "authenticated", email: "SAME@example.com" } },
272 ],
273 },
274 });
275 expect(collectLimitAccounts(input)).toHaveLength(1);
276 });
277
210 it("takes windows from a fresher hub read but credits and redeem from the native instance", () => {278 it("takes windows from a fresher hub read but credits and redeem from the native instance", () => {
211 const native = provider({279 const native = provider({
212 driver: claude,280 driver: claude,
packages/shared/src/usageLimits.ts+43-31
...@@ -136,16 +136,24 @@ export function collectExternalUsageLinks(presentations: LimitPresentations) {...@@ -136,16 +136,24 @@ export function collectExternalUsageLinks(presentations: LimitPresentations) {
136 return [...links.values()];136 return [...links.values()];
137}137}
138138
139function accountKey(driver: ServerProvider["driver"], email: string | undefined): string | null {139/** Prefer the reported email; use an identical credential when no email is available. */
140function accountKey(
141 driver: ServerProvider["driver"],
142 email: string | undefined,
143 limits?: ServerProviderUsageLimits,
144): string | null {
140 const normalizedEmail = email?.trim().toLowerCase();145 const normalizedEmail = email?.trim().toLowerCase();
141 return normalizedEmail ? `${driver}:${normalizedEmail}` : null;146 if (normalizedEmail) return `${driver}:${normalizedEmail}`;
147 return limits?.credentialFingerprint
148 ? `${driver}:credential:${limits.credentialFingerprint}`
149 : null;
142}150}
143151
144/**152/**
145 * One subscription account as the pooled views see it, whichever way it was153 * One subscription account as the pooled views see it, whichever way it was
146 * reported. The same email signed in natively on two environments, or reported154 * reported. Matching emails or credentials across environments name
147 * by a hub as well as natively, is one account: its quota is one bucket, so155 * one account. Its quota is one bucket, so counting it twice would misstate
148 * counting it twice would misstate what is left.156 * what is left.
149 */157 */
150export interface LimitAccount {158export interface LimitAccount {
151 readonly key: string;159 readonly key: string;
...@@ -243,7 +251,7 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl...@@ -243,7 +251,7 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl
243 for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) {251 for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) {
244 if (!provider.usageLimits || limitsNotice(provider.usageLimits) !== null) continue;252 if (!provider.usageLimits || limitsNotice(provider.usageLimits) !== null) continue;
245 merge(253 merge(
246 accountKey(provider.driver, provider.auth.email) ??254 accountKey(provider.driver, provider.auth.email, provider.usageLimits) ??
247 `${environmentId}:${provider.instanceId}`,255 `${environmentId}:${provider.instanceId}`,
248 {256 {
249 key: `${environmentId}:${provider.instanceId}`,257 key: `${environmentId}:${provider.instanceId}`,
...@@ -271,27 +279,31 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl...@@ -271,27 +279,31 @@ export function collectLimitAccounts(presentations: LimitPresentations): readonl
271 : source.label;279 : source.label;
272 for (const account of source.accounts) {280 for (const account of source.accounts) {
273 if (limitsNotice(account.usageLimits) !== null) continue;281 if (limitsNotice(account.usageLimits) !== null) continue;
274 merge(accountKey(account.driver, account.email) ?? `${source.id}:${account.id}`, {282 merge(
275 key: `${source.id}:${account.id}`,283 accountKey(account.driver, account.email, account.usageLimits) ??
276 driver: account.driver,284 `${source.id}:${account.id}`,
277 displayName: account.email ? null : account.id.replace(/\.json$/i, ""),285 {
278 email: account.email,286 key: `${source.id}:${account.id}`,
279 plan: account.plan,287 driver: account.driver,
280 accentColor: undefined,288 displayName: account.email ? null : account.id.replace(/\.json$/i, ""),
281 environments: [],289 email: account.email,
282 sourceLabel,290 plan: account.plan,
283 redeem: account.usageLimits.resetCredits?.nextCreditId291 accentColor: undefined,
284 ? {292 environments: [],
285 environmentId,293 sourceLabel,
286 input: {294 redeem: account.usageLimits.resetCredits?.nextCreditId
287 sourceId: source.id,295 ? {
288 accountId: account.id,296 environmentId,
289 creditId: account.usageLimits.resetCredits.nextCreditId,297 input: {
290 },298 sourceId: source.id,
291 }299 accountId: account.id,
292 : null,300 creditId: account.usageLimits.resetCredits.nextCreditId,
293 limits: account.usageLimits,301 },
294 });302 }
303 : null,
304 limits: account.usageLimits,
305 },
306 );
295 }307 }
296 }308 }
297 }309 }
...@@ -647,7 +659,7 @@ export function collectProviderUsageLimits(...@@ -647,7 +659,7 @@ export function collectProviderUsageLimits(
647 );659 );
648 const nativeAccounts = new Set(660 const nativeAccounts = new Set(
649 native.flatMap((provider) => {661 native.flatMap((provider) => {
650 const key = accountKey(provider.driver, provider.auth.email);662 const key = accountKey(provider.driver, provider.auth.email, provider.usageLimits);
651 return key && provider.usageLimits?.windows.length && !provider.usageLimits.unavailable663 return key && provider.usageLimits?.windows.length && !provider.usageLimits.unavailable
652 ? [key]664 ? [key]
653 : [];665 : [];
...@@ -657,13 +669,13 @@ export function collectProviderUsageLimits(...@@ -657,13 +669,13 @@ export function collectProviderUsageLimits(
657 const notices: string[] = [];669 const notices: string[] = [];
658 for (const provider of native) {670 for (const provider of native) {
659 if (!provider.usageLimits) continue;671 if (!provider.usageLimits) continue;
660 const key = accountKey(provider.driver, provider.auth.email);672 const key = accountKey(provider.driver, provider.auth.email, provider.usageLimits);
661 const hubCredits = sources673 const hubCredits = sources
662 .flatMap((source) => source.accounts.map((account) => ({ source, account })))674 .flatMap((source) => source.accounts.map((account) => ({ source, account })))
663 .filter(675 .filter(
664 ({ account }) =>676 ({ account }) =>
665 key !== null &&677 key !== null &&
666 accountKey(account.driver, account.email) === key &&678 accountKey(account.driver, account.email, account.usageLimits) === key &&
667 account.usageLimits.resetCredits &&679 account.usageLimits.resetCredits &&
668 !limitsNotice(account.usageLimits),680 !limitsNotice(account.usageLimits),
669 )681 )
...@@ -710,7 +722,7 @@ export function collectProviderUsageLimits(...@@ -710,7 +722,7 @@ export function collectProviderUsageLimits(
710 for (const source of sources) {722 for (const source of sources) {
711 const matching = source.accounts.filter((account) => account.driver === selected.driver);723 const matching = source.accounts.filter((account) => account.driver === selected.driver);
712 for (const account of matching) {724 for (const account of matching) {
713 const key = accountKey(account.driver, account.email);725 const key = accountKey(account.driver, account.email, account.usageLimits);
714 if (key && nativeAccounts.has(key)) continue;726 if (key && nativeAccounts.has(key)) continue;
715 accounts.push({727 accounts.push({
716 id: `${source.id}:${account.id}`,728 id: `${source.id}:${account.id}`,