-
Notifications
You must be signed in to change notification settings - Fork 29
Add subscription gateway account pool core #278
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| # Subscription Gateway | ||
|
|
||
| Subscription Gateway will expose Claude and ChatGPT subscription accounts through an OpenAI-compatible local API. It is private while the account pool, provider adapters, and HTTP contract are being extracted and tested. | ||
|
|
||
| The first package layer contains no provider SDK or Open Session runtime code. It owns account routing, durable cooldowns, and retries that stop once an attempt emits client-visible output. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| { | ||
| "name": "@tellahq/subscription-gateway", | ||
| "version": "0.0.0", | ||
| "private": true, | ||
| "description": "Account routing and OpenAI-compatible access for AI subscription providers", | ||
| "license": "MIT", | ||
| "repository": { | ||
| "type": "git", | ||
| "url": "git+https://github.com/tellahq/opensession.git", | ||
| "directory": "packages/core/subscription-gateway" | ||
| }, | ||
| "type": "module", | ||
| "exports": { | ||
| ".": "./src/index.ts", | ||
| "./pool": "./src/pool/index.ts", | ||
| "./storage": "./src/storage/index.ts" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export * from "./pool"; | ||
| export * from "./storage"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,121 @@ | ||
| import { describe, expect, test } from "bun:test"; | ||
| import { walkAccounts } from "./account-walk"; | ||
| import type { AccountResolution, RoutableAccount } from "./types"; | ||
|
|
||
| const shared = (index: number): RoutableAccount => ({ | ||
| id: `account-${index}`, | ||
| name: `Account ${index}`, | ||
| access: { kind: "shared" }, | ||
| }); | ||
|
|
||
| describe("walkAccounts", () => { | ||
| test("walks every account without a fixed retry limit", async () => { | ||
| const accounts = Array.from({ length: 25 }, (_, index) => shared(index)); | ||
| const attempted: string[] = []; | ||
| const result = await walkAccounts({ | ||
| acquire: (excluded): AccountResolution<RoutableAccount> => { | ||
| const account = accounts.find( | ||
| (candidate) => !excluded.has(candidate.id), | ||
| ); | ||
| return account | ||
| ? { kind: "selected", account, reason: "pool" } | ||
| : { kind: "refused", refusal: { kind: "pool-dry" } }; | ||
| }, | ||
| attempt: ({ account }) => { | ||
| attempted.push(account.id); | ||
| return account.id === "account-24" | ||
| ? { kind: "succeeded", value: "done" } | ||
| : { kind: "retry-account", error: new Error("limited") }; | ||
| }, | ||
| onEvent: () => undefined, | ||
| }); | ||
|
|
||
| expect(result).toMatchObject({ | ||
| kind: "succeeded", | ||
| account: { id: "account-24" }, | ||
| value: "done", | ||
| }); | ||
| expect(attempted).toHaveLength(25); | ||
| }); | ||
|
|
||
| test("retries after replay-safe events", async () => { | ||
| const events: string[] = []; | ||
| const attempts: string[] = []; | ||
| const result = await walkAccounts({ | ||
| acquire: (excluded): AccountResolution<RoutableAccount> => { | ||
| const account = [shared(0), shared(1)].find( | ||
| (candidate) => !excluded.has(candidate.id), | ||
| ); | ||
| return account | ||
| ? { kind: "selected", account, reason: "pool" } | ||
| : { kind: "refused", refusal: { kind: "pool-dry" } }; | ||
| }, | ||
| attempt: ({ account, emit }) => { | ||
| attempts.push(account.id); | ||
| emit({ kind: "replay-safe", value: `usage:${account.id}` }); | ||
| return account.id === "account-0" | ||
| ? { kind: "retry-account", error: new Error("limited") } | ||
| : { kind: "succeeded", value: "done" }; | ||
| }, | ||
| onEvent: (event: string) => events.push(event), | ||
| }); | ||
| expect(result.kind).toBe("succeeded"); | ||
| expect(attempts).toEqual(["account-0", "account-1"]); | ||
| expect(events).toEqual(["usage:account-0", "usage:account-1"]); | ||
| }); | ||
|
|
||
| test("never replays after client-visible output", async () => { | ||
| const attempts: string[] = []; | ||
| const result = await walkAccounts({ | ||
| acquire: (excluded): AccountResolution<RoutableAccount> => { | ||
| const account = [shared(0), shared(1)].find( | ||
| (candidate) => !excluded.has(candidate.id), | ||
| ); | ||
| return account | ||
| ? { kind: "selected", account, reason: "pool" } | ||
| : { kind: "refused", refusal: { kind: "pool-dry" } }; | ||
| }, | ||
| attempt: ({ account, emit }) => { | ||
| attempts.push(account.id); | ||
| emit({ kind: "client-visible", value: "partial answer" }); | ||
| return { kind: "retry-account", error: new Error("limited") }; | ||
| }, | ||
| onEvent: () => undefined, | ||
| }); | ||
| expect(result).toMatchObject({ | ||
| kind: "failed", | ||
| account: { id: "account-0" }, | ||
| replayBlocked: true, | ||
| }); | ||
| expect(attempts).toEqual(["account-0"]); | ||
| }); | ||
|
|
||
| test("a strict pin refuses rather than widening", async () => { | ||
| const pinned = shared(0); | ||
| const fallback = shared(1); | ||
| const result = await walkAccounts({ | ||
| acquire: (excluded): AccountResolution<RoutableAccount> => { | ||
| if (excluded.has(pinned.id)) { | ||
| return { | ||
| kind: "refused", | ||
| refusal: { | ||
| kind: "pin-unusable", | ||
| pinnedId: pinned.id, | ||
| pinName: pinned.name, | ||
| }, | ||
| }; | ||
| } | ||
| return { kind: "selected", account: pinned, reason: "pinned" }; | ||
| }, | ||
| attempt: () => ({ | ||
| kind: "retry-account", | ||
| error: new Error(`do not use ${fallback.id}`), | ||
| }), | ||
| onEvent: () => undefined, | ||
| }); | ||
| expect(result).toMatchObject({ | ||
| kind: "refused", | ||
| refusal: { kind: "pin-unusable", pinnedId: "account-0" }, | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,110 @@ | ||||||||
| import type { | ||||||||
| AccountRefusal, | ||||||||
| AccountResolution, | ||||||||
| RoutableAccount, | ||||||||
| } from "./types"; | ||||||||
|
|
||||||||
| export type AttemptEvent<TEvent> = | ||||||||
| | { readonly kind: "replay-safe"; readonly value: TEvent } | ||||||||
| | { readonly kind: "client-visible"; readonly value: TEvent }; | ||||||||
|
|
||||||||
| export type AttemptOutcome<TResult> = | ||||||||
| | { readonly kind: "succeeded"; readonly value: TResult } | ||||||||
| | { readonly kind: "retry-account"; readonly error: unknown } | ||||||||
| | { readonly kind: "failed"; readonly error: unknown }; | ||||||||
|
|
||||||||
| export interface AccountAttempt<TAccount, TEvent, TResult> { | ||||||||
| readonly account: TAccount; | ||||||||
| readonly signal?: AbortSignal; | ||||||||
| readonly emit: (event: AttemptEvent<TEvent>) => void; | ||||||||
| } | ||||||||
|
|
||||||||
| export type AccountWalkResult<TAccount, TResult> = | ||||||||
| | { | ||||||||
| readonly kind: "succeeded"; | ||||||||
| readonly account: TAccount; | ||||||||
| readonly value: TResult; | ||||||||
| } | ||||||||
| | { readonly kind: "refused"; readonly refusal: AccountRefusal } | ||||||||
| | { | ||||||||
| readonly kind: "failed"; | ||||||||
| readonly account: TAccount; | ||||||||
| readonly error: unknown; | ||||||||
| readonly replayBlocked: boolean; | ||||||||
| } | ||||||||
| | { readonly kind: "aborted" } | ||||||||
| | { readonly kind: "invalid-selection"; readonly accountId: string }; | ||||||||
|
|
||||||||
| export interface WalkAccountsOptions< | ||||||||
| TAccount extends RoutableAccount, | ||||||||
| TEvent, | ||||||||
| TResult, | ||||||||
| > { | ||||||||
| readonly acquire: ( | ||||||||
| excludedIds: ReadonlySet<string>, | ||||||||
| ) => AccountResolution<TAccount> | Promise<AccountResolution<TAccount>>; | ||||||||
| readonly attempt: ( | ||||||||
| input: AccountAttempt<TAccount, TEvent, TResult>, | ||||||||
| ) => AttemptOutcome<TResult> | Promise<AttemptOutcome<TResult>>; | ||||||||
| readonly onEvent: (event: TEvent) => void; | ||||||||
| readonly onRetry?: ( | ||||||||
| account: TAccount, | ||||||||
| error: unknown, | ||||||||
| ) => void | Promise<void>; | ||||||||
| readonly signal?: AbortSignal; | ||||||||
| } | ||||||||
|
|
||||||||
| /** | ||||||||
| * Tries accounts until one succeeds or the router refuses another pick. | ||||||||
| * A retry is allowed only before an attempt emits client-visible output. | ||||||||
| */ | ||||||||
| export async function walkAccounts< | ||||||||
| TAccount extends RoutableAccount, | ||||||||
| TEvent, | ||||||||
| TResult, | ||||||||
| >( | ||||||||
| options: WalkAccountsOptions<TAccount, TEvent, TResult>, | ||||||||
| ): Promise<AccountWalkResult<TAccount, TResult>> { | ||||||||
| const excludedIds = new Set<string>(); | ||||||||
|
|
||||||||
| while (!options.signal?.aborted) { | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 P2 — Cancellation during account acquisition still starts a provider attempt The loop checks the signal before
Suggested change
|
||||||||
| const resolution = await options.acquire(excludedIds); | ||||||||
| if (resolution.kind === "refused") return resolution; | ||||||||
| const { account } = resolution; | ||||||||
| if (excludedIds.has(account.id)) { | ||||||||
| return { kind: "invalid-selection", accountId: account.id }; | ||||||||
| } | ||||||||
|
|
||||||||
| let replayBlocked = false; | ||||||||
| let outcome: AttemptOutcome<TResult>; | ||||||||
| try { | ||||||||
| outcome = await options.attempt({ | ||||||||
| account, | ||||||||
| signal: options.signal, | ||||||||
| emit: (event) => { | ||||||||
| if (event.kind === "client-visible") replayBlocked = true; | ||||||||
| options.onEvent(event.value); | ||||||||
| }, | ||||||||
| }); | ||||||||
| } catch (error) { | ||||||||
| outcome = { kind: "failed", error }; | ||||||||
| } | ||||||||
|
|
||||||||
| if (outcome.kind === "succeeded") { | ||||||||
| return { kind: "succeeded", account, value: outcome.value }; | ||||||||
| } | ||||||||
| if (outcome.kind === "failed" || replayBlocked) { | ||||||||
| return { | ||||||||
| kind: "failed", | ||||||||
| account, | ||||||||
| error: outcome.error, | ||||||||
| replayBlocked, | ||||||||
| }; | ||||||||
| } | ||||||||
|
|
||||||||
| excludedIds.add(account.id); | ||||||||
| await options.onRetry?.(account, outcome.error); | ||||||||
| } | ||||||||
|
|
||||||||
| return { kind: "aborted" }; | ||||||||
| } | ||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| import { afterEach, describe, expect, test } from "bun:test"; | ||
| import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; | ||
| import { tmpdir } from "node:os"; | ||
| import { join } from "node:path"; | ||
| import { JsonCooldownRepository, MemoryCooldownRepository } from "../storage"; | ||
| import { CooldownRegistry } from "./cooldowns"; | ||
|
|
||
| const temporaryDirectories: string[] = []; | ||
|
|
||
| afterEach(async () => { | ||
| await Promise.all( | ||
| temporaryDirectories | ||
| .splice(0) | ||
| .map((path) => rm(path, { recursive: true, force: true })), | ||
| ); | ||
| }); | ||
|
|
||
| describe("CooldownRegistry", () => { | ||
| test("separates model exhaustion from account-wide exhaustion", async () => { | ||
| let now = 1_000; | ||
| const registry = await CooldownRegistry.open( | ||
| new MemoryCooldownRepository(), | ||
| () => now, | ||
| ); | ||
| await registry.markExhausted({ | ||
| accountId: "account-a", | ||
| model: "model-a", | ||
| until: 2_000, | ||
| }); | ||
| expect(registry.isActive("account-a", "model-a")).toBe(true); | ||
| expect(registry.isActive("account-a", "model-b")).toBe(false); | ||
|
|
||
| await registry.markExhausted({ accountId: "account-a", until: 3_000 }); | ||
| expect(registry.isActive("account-a", "model-b")).toBe(true); | ||
| now = 3_001; | ||
| expect(registry.isActive("account-a", "model-a")).toBe(false); | ||
| }); | ||
|
|
||
| test("does not let a wedge shorten exhaustion", async () => { | ||
| const registry = await CooldownRegistry.open( | ||
| new MemoryCooldownRepository(), | ||
| () => 1_000, | ||
| ); | ||
| await registry.markExhausted({ accountId: "account-a", until: 10_000 }); | ||
| expect(await registry.markWedged("account-a", 5_000)).toBeUndefined(); | ||
| expect(registry.isActive("account-a")).toBe(true); | ||
| }); | ||
|
|
||
| test("clears only the wedge represented by its token", async () => { | ||
| const registry = await CooldownRegistry.open( | ||
| new MemoryCooldownRepository(), | ||
| () => 1_000, | ||
| ); | ||
| const token = await registry.markWedged("account-a", 5_000); | ||
| expect(token).toBeDefined(); | ||
| if (!token) return; | ||
| await registry.markExhausted({ accountId: "account-a", until: 4_000 }); | ||
| expect(await registry.clearWedge(token)).toBe(false); | ||
| expect(registry.isActive("account-a")).toBe(true); | ||
| }); | ||
|
|
||
| test("restores a shorter cooldown when a wedge is rolled back", async () => { | ||
| const registry = await CooldownRegistry.open( | ||
| new MemoryCooldownRepository(), | ||
| () => 1_000, | ||
| ); | ||
| await registry.markExhausted({ accountId: "account-a", until: 2_000 }); | ||
| const token = await registry.markWedged("account-a", 5_000); | ||
| expect(token).toBeDefined(); | ||
| if (!token) return; | ||
| expect(await registry.clearWedge(token)).toBe(true); | ||
| expect(registry.activeRecords()).toEqual([ | ||
| { | ||
| scope: "account", | ||
| accountId: "account-a", | ||
| reason: "exhausted", | ||
| until: 2_000, | ||
| }, | ||
| ]); | ||
| }); | ||
|
|
||
| test("hydrates durable cooldowns after restart", async () => { | ||
| const directory = await mkdtemp(join(tmpdir(), "subscription-gateway-")); | ||
| temporaryDirectories.push(directory); | ||
| const path = join(directory, "state", "cooldowns.json"); | ||
| const repository = new JsonCooldownRepository(path); | ||
| const first = await CooldownRegistry.open(repository, () => 1_000); | ||
| await first.markExhausted({ | ||
| accountId: "account-a", | ||
| model: "model-a", | ||
| until: 10_000, | ||
| }); | ||
|
|
||
| const second = await CooldownRegistry.open(repository, () => 2_000); | ||
| expect(second.isActive("account-a", "model-a")).toBe(true); | ||
| expect(JSON.parse(await readFile(path, "utf8"))).toEqual([ | ||
| { | ||
| scope: "model", | ||
| accountId: "account-a", | ||
| model: "model-a", | ||
| reason: "exhausted", | ||
| until: 10_000, | ||
| }, | ||
| ]); | ||
| expect((await stat(path)).mode & 0o777).toBe(0o600); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟠 P2 — The new package tests are excluded from required CI
All three new test files live under
packages/core/subscription-gateway/src, butscripts/test-unit-isolated.sh, used by bothbun run checkand the required CI unit-test step, only searchespackages/core/opensession-server/srcandscripts. The rootbun testscript has the same exclusion, so these routing, retry, and durability regressions can merge with green required checks. Add the subscription-gateway source directory tofind_testsor invoke a package-specific test script from the required check.