Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"packages/clients/website",
"packages/core/opensession-server",
"packages/core/protocol",
"packages/core/subscription-gateway",
"packages/integrations/apple-mobile"
],
"scripts": {
Expand Down
5 changes: 5 additions & 0 deletions packages/core/subscription-gateway/README.md
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.
18 changes: 18 additions & 0 deletions packages/core/subscription-gateway/package.json
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"
}
}
2 changes: 2 additions & 0 deletions packages/core/subscription-gateway/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./pool";
export * from "./storage";
121 changes: 121 additions & 0 deletions packages/core/subscription-gateway/src/pool/account-walk.test.ts
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", () => {

Copy link
Copy Markdown
Contributor

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, but scripts/test-unit-isolated.sh, used by both bun run check and the required CI unit-test step, only searches packages/core/opensession-server/src and scripts. The root bun test script has the same exclusion, so these routing, retry, and durability regressions can merge with green required checks. Add the subscription-gateway source directory to find_tests or invoke a package-specific test script from the required check.

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" },
});
});
});
110 changes: 110 additions & 0 deletions packages/core/subscription-gateway/src/pool/account-walk.ts
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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 await options.acquire, but not after it. Since acquire is explicitly allowed to be asynchronous, a request aborted while account discovery is pending still invokes attempt once acquisition resolves, potentially starting a paid provider request after the client disconnected. Recheck the signal immediately after acquisition and return aborted before examining or attempting the selected account.

Suggested change
while (!options.signal?.aborted) {
const resolution = await options.acquire(excludedIds);
if (options.signal?.aborted) return { kind: "aborted" };

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" };
}
107 changes: 107 additions & 0 deletions packages/core/subscription-gateway/src/pool/cooldowns.test.ts
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);
});
});
Loading
Loading