| author | |
| committer | |
| log | 1a553d0f5dd2aa39a64895ec770dc51ef1448b1a |
| tree | 630ef60d6e04c7d408965c12d0338c3a673aeccd |
| parent | 451afcb22d93f06cb24f9bc16703404564952553 |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 130 insertions(+), 32 deletions(-)
apps/server/src/provider/Layers/OpenCodeProvider.test.ts+6| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import * as NodeAssert from "node:assert/strict"; | 1 | import * as NodeAssert from "node:assert/strict"; |
| 2 | import * as NodeCrypto from "node:crypto"; | ||
| 2 | 3 | ||
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | import { it } from "@effect/vitest"; | 5 | import { 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 @@ |
| 1 | import * as NodeOS from "node:os"; | 1 | import * as NodeOS from "node:os"; |
| 2 | import * as NodeCrypto from "node:crypto"; | ||
| 2 | 3 | ||
| 3 | import type { ServerProviderUsageWindow } from "@t3tools/contracts"; | 4 | import type { ServerProviderUsageWindow } from "@t3tools/contracts"; |
| 4 | import * as DateTime from "effect/DateTime"; | 5 | import * 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; |
| 50 | export const ServerProviderUsageLimits = Schema.Struct({ | 50 | export 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 | }); |
| 209 | 209 | ||
| 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 | } |
| 138 | 138 | ||
| 139 | function accountKey(driver: ServerProvider["driver"], email: string | undefined): string | null { | 139 | /** Prefer the reported email; use an identical credential when no email is available. */ |
| 140 | function 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 | } |
| 143 | 151 | ||
| 144 | /** | 152 | /** |
| 145 | * One subscription account as the pooled views see it, whichever way it was | 153 | * 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 reported | 154 | * reported. Matching emails or credentials across environments name |
| 147 | * by a hub as well as natively, is one account: its quota is one bucket, so | 155 | * 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 | */ |
| 150 | export interface LimitAccount { | 158 | export 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?.nextCreditId | 291 | 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.unavailable | 663 | 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 = sources | 673 | 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}`, |