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
162 changes: 162 additions & 0 deletions cloudflare/packages/core/src/account-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { Context, Effect, Layer } from "effect"

/**
* SubrouterActor — multi-tenant rewrite of the Go subrouter, hosted on Rivet
* actors. v1 = functional parity with Go subrouter (sticky session → account
* routing). Aurora-backed account store, not local JSON.
*
* This file is the *interface and sticky-routing core*. The actual Rivet
* actor wrapper lives in packages/actors and forwards into here. The HTTP
* transport (the thing that *replaces* what Go subrouter listens for at
* 0.0.0.0:31415) lives in packages/proxy/src/gateway.ts since the AI
* integrations go through the same proxy as gh/registries/etc.
*
* Why: Go subrouter today requires a long-lived VM (Mac Mini or systemd box).
* Rivet actors let us host it with zero VM ops, multi-tenant from the start.
*/

export interface Account {
readonly id: string
readonly orgId: string
readonly kind: "codex_oauth" | "anthropic_oauth" | "openai_apikey" | "anthropic_apikey"
readonly label: string
readonly enabled: boolean
readonly rateLimitRemaining?: number
readonly modelQuotas?: AccountModelQuotas
readonly lastUsedAt?: number
}

export interface AccountModelQuota {
readonly remainingPercent: number
readonly resetsAt?: number
readonly protectedBelowPercent?: number
}

export type AccountModelQuotas = Readonly<Record<string, AccountModelQuota>>

// Named model-family quota pools. A request whose model name contains one of
// these keywords draws from that pool instead of the account-wide "default":
// Codex Spark ("spark") and Claude Opus/Sonnet weekly limits ("opus"/"sonnet").
// Keep this in sync with what the quota populator writes into Account.modelQuotas:
// a model keyed to a pool no account carries is ineligible everywhere (see
// accountHasQuotaForModel), which is also what isolates providers (a Claude
// "opus" model only matches accounts that carry an "opus" pool). Anthropic's
// opus/sonnet caps are sub-limits of the account-wide window, so the populator
// must set each to min(account-wide remaining, family remaining); the Codex
// Spark pool is independent of the account-wide window.
const MODEL_QUOTA_POOLS = ["spark", "opus", "sonnet"] as const

export const quotaKeyForModel = (model: string | undefined): string => {
const normalized = model?.trim().toLowerCase()
if (!normalized) return "default"
for (const pool of MODEL_QUOTA_POOLS) {
if (normalized.includes(pool)) return pool
}
return "default"
}

export const accountHasQuotaForModel = (
account: Account,
quotaKey: string
): boolean => {
const quota = account.modelQuotas?.[quotaKey]
if (!quota) return quotaKey === "default"
return quota.remainingPercent > 0
}

export interface AccountStore {
readonly list: (orgId: string) => Effect.Effect<ReadonlyArray<Account>>
readonly pick: (input: {
readonly orgId: string
readonly sessionId: string
readonly preferAccountId?: string
readonly model?: string
readonly quotaKey?: string
}) => Effect.Effect<Account | null>
readonly recordUse: (accountId: string) => Effect.Effect<void>
}

export class AccountStoreTag extends Context.Tag("AccountStore")<
AccountStoreTag,
AccountStore
>() {}

/**
* Sticky session table. In production: a Postgres row per (orgId, sessionId).
* Here: in-memory Map. Stickiness ensures cached agent context stays useful
* (matches Go subrouter's `X-Subrouter-Session` semantics).
*/
export const makeInMemoryStickyStore = () => {
const map = new Map<string, string>() // `${orgId}:${sessionId}` -> accountId
return {
get: (orgId: string, sessionId: string, quotaKey = "default"): string | null =>
map.get(`${orgId}:${quotaKey}:${sessionId}`) ?? null,
set: (
orgId: string,
sessionId: string,
accountId: string,
quotaKey = "default"
): void => {
map.set(`${orgId}:${quotaKey}:${sessionId}`, accountId)
},
clear: (): void => {
map.clear()
},
}
}

/**
* Reference in-memory AccountStore impl that round-robins enabled accounts and
* respects sticky sessions. Production swaps in a Postgres-backed implementation
* that reads from `accounts` and `subrouter_session_assignments`.
*/
export const makeInMemoryAccountStoreLayer = (initial: ReadonlyArray<Account>) => {
const accounts = [...initial]
const sticky = makeInMemoryStickyStore()
let cursor = 0

return Layer.succeed(AccountStoreTag, {
list: (orgId) =>
Effect.succeed(accounts.filter((a) => a.orgId === orgId && a.enabled)),

pick: ({ orgId, sessionId, preferAccountId, model, quotaKey }) => {
const resolvedQuotaKey = quotaKey ?? quotaKeyForModel(model)
const isEligible = (account: Account): boolean =>
account.orgId === orgId &&
account.enabled &&
accountHasQuotaForModel(account, resolvedQuotaKey)

// 1. sticky table first
const stickyId = sticky.get(orgId, sessionId, resolvedQuotaKey)
if (stickyId) {
const found = accounts.find((a) => a.id === stickyId && isEligible(a))
if (found) return Effect.succeed(found)
}
// 2. explicit pin
if (preferAccountId) {
const found = accounts.find(
(a) => a.id === preferAccountId && isEligible(a)
)
if (found) {
sticky.set(orgId, sessionId, found.id, resolvedQuotaKey)
return Effect.succeed(found)
}
}
// 3. round-robin over enabled accounts for the org
const eligible = accounts.filter(isEligible)
if (eligible.length === 0) return Effect.succeed(null)
const pick = eligible[cursor % eligible.length]!
cursor += 1
sticky.set(orgId, sessionId, pick.id, resolvedQuotaKey)
return Effect.succeed(pick)
},

recordUse: (accountId) =>
Effect.sync(() => {
const i = accounts.findIndex((a) => a.id === accountId)
if (i >= 0) {
accounts[i] = { ...accounts[i]!, lastUsedAt: Date.now() }
}
}),
} satisfies AccountStore)
}
180 changes: 18 additions & 162 deletions cloudflare/packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,163 +1,19 @@
import { Context, Effect, Layer } from "effect"
export * from "./service.ts"

/**
* SubrouterActor — multi-tenant rewrite of the Go subrouter, hosted on Rivet
* actors. v1 = functional parity with Go subrouter (sticky session → account
* routing). Aurora-backed account store, not local JSON.
*
* This file is the *interface and sticky-routing core*. The actual Rivet
* actor wrapper lives in packages/actors and forwards into here. The HTTP
* transport (the thing that *replaces* what Go subrouter listens for at
* 0.0.0.0:31415) lives in packages/proxy/src/gateway.ts since the AI
* integrations go through the same proxy as gh/registries/etc.
*
* Why: Go subrouter today requires a long-lived VM (Mac Mini or systemd box).
* Rivet actors let us host it with zero VM ops, multi-tenant from the start.
*/

export interface Account {
readonly id: string
readonly orgId: string
readonly kind: "codex_oauth" | "anthropic_oauth" | "openai_apikey" | "anthropic_apikey"
readonly label: string
readonly enabled: boolean
readonly rateLimitRemaining?: number
readonly modelQuotas?: AccountModelQuotas
readonly lastUsedAt?: number
}

export interface AccountModelQuota {
readonly remainingPercent: number
readonly resetsAt?: number
readonly protectedBelowPercent?: number
}

export type AccountModelQuotas = Readonly<Record<string, AccountModelQuota>>

// Named model-family quota pools. A request whose model name contains one of
// these keywords draws from that pool instead of the account-wide "default":
// Codex Spark ("spark") and Claude Opus/Sonnet weekly limits ("opus"/"sonnet").
// Keep this in sync with what the quota populator writes into Account.modelQuotas:
// a model keyed to a pool no account carries is ineligible everywhere (see
// accountHasQuotaForModel), which is also what isolates providers (a Claude
// "opus" model only matches accounts that carry an "opus" pool). Anthropic's
// opus/sonnet caps are sub-limits of the account-wide window, so the populator
// must set each to min(account-wide remaining, family remaining); the Codex
// Spark pool is independent of the account-wide window.
const MODEL_QUOTA_POOLS = ["spark", "opus", "sonnet"] as const

export const quotaKeyForModel = (model: string | undefined): string => {
const normalized = model?.trim().toLowerCase()
if (!normalized) return "default"
for (const pool of MODEL_QUOTA_POOLS) {
if (normalized.includes(pool)) return pool
}
return "default"
}

export const accountHasQuotaForModel = (
account: Account,
quotaKey: string
): boolean => {
const quota = account.modelQuotas?.[quotaKey]
if (!quota) return quotaKey === "default"
return quota.remainingPercent > 0
}

export interface AccountStore {
readonly list: (orgId: string) => Effect.Effect<ReadonlyArray<Account>>
readonly pick: (input: {
readonly orgId: string
readonly sessionId: string
readonly preferAccountId?: string
readonly model?: string
readonly quotaKey?: string
}) => Effect.Effect<Account | null>
readonly recordUse: (accountId: string) => Effect.Effect<void>
}

export class AccountStoreTag extends Context.Tag("AccountStore")<
export {
AccountStoreTag,
AccountStore
>() {}

/**
* Sticky session table. In production: a Postgres row per (orgId, sessionId).
* Here: in-memory Map. Stickiness ensures cached agent context stays useful
* (matches Go subrouter's `X-Subrouter-Session` semantics).
*/
export const makeInMemoryStickyStore = () => {
const map = new Map<string, string>() // `${orgId}:${sessionId}` -> accountId
return {
get: (orgId: string, sessionId: string, quotaKey = "default"): string | null =>
map.get(`${orgId}:${quotaKey}:${sessionId}`) ?? null,
set: (
orgId: string,
sessionId: string,
accountId: string,
quotaKey = "default"
): void => {
map.set(`${orgId}:${quotaKey}:${sessionId}`, accountId)
},
clear: (): void => {
map.clear()
},
}
}

/**
* Reference in-memory AccountStore impl that round-robins enabled accounts and
* respects sticky sessions. Production swaps in a Postgres-backed implementation
* that reads from `accounts` and `subrouter_session_assignments`.
*/
export const makeInMemoryAccountStoreLayer = (initial: ReadonlyArray<Account>) => {
const accounts = [...initial]
const sticky = makeInMemoryStickyStore()
let cursor = 0

return Layer.succeed(AccountStoreTag, {
list: (orgId) =>
Effect.succeed(accounts.filter((a) => a.orgId === orgId && a.enabled)),

pick: ({ orgId, sessionId, preferAccountId, model, quotaKey }) => {
const resolvedQuotaKey = quotaKey ?? quotaKeyForModel(model)
const isEligible = (account: Account): boolean =>
account.orgId === orgId &&
account.enabled &&
accountHasQuotaForModel(account, resolvedQuotaKey)

// 1. sticky table first
const stickyId = sticky.get(orgId, sessionId, resolvedQuotaKey)
if (stickyId) {
const found = accounts.find((a) => a.id === stickyId && isEligible(a))
if (found) return Effect.succeed(found)
}
// 2. explicit pin
if (preferAccountId) {
const found = accounts.find(
(a) => a.id === preferAccountId && isEligible(a)
)
if (found) {
sticky.set(orgId, sessionId, found.id, resolvedQuotaKey)
return Effect.succeed(found)
}
}
// 3. round-robin over enabled accounts for the org
const eligible = accounts.filter(isEligible)
if (eligible.length === 0) return Effect.succeed(null)
const pick = eligible[cursor % eligible.length]!
cursor += 1
sticky.set(orgId, sessionId, pick.id, resolvedQuotaKey)
return Effect.succeed(pick)
},

recordUse: (accountId) =>
Effect.sync(() => {
const i = accounts.findIndex((a) => a.id === accountId)
if (i >= 0) {
accounts[i] = { ...accounts[i]!, lastUsedAt: Date.now() }
}
}),
} satisfies AccountStore)
}
accountHasQuotaForModel,
makeInMemoryAccountStoreLayer,
makeInMemoryStickyStore,
quotaKeyForModel,
} from "./account-store.ts"
export type {
Account,
AccountModelQuota,
AccountModelQuotas,
AccountStore,
} from "./account-store.ts"
export {
NoEligibleAccount,
SubrouterService,
makeSubrouterServiceLayer,
} from "./service.ts"
export type { PickedRoute } from "./service.ts"
2 changes: 1 addition & 1 deletion cloudflare/packages/core/src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { Context, Effect, Layer } from "effect"
import {
AccountStoreTag,
type Account,
} from "./index.ts"
} from "./account-store.ts"

export class NoEligibleAccount extends Error {
readonly _tag = "NoEligibleAccount"
Expand Down
29 changes: 29 additions & 0 deletions cloudflare/packages/core/test/public-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { expect, test } from "bun:test"
import { Effect, Layer } from "effect"
import * as core from "../src/index.ts"
import * as service from "../src/service.ts"

test("the core barrel exposes one coherent service and account-store API", async () => {
expect(core.SubrouterService).toBe(service.SubrouterService)
expect(core.NoEligibleAccount).toBe(service.NoEligibleAccount)
expect(typeof core.makeInMemoryAccountStoreLayer).toBe("function")

const layer = core.makeInMemoryAccountStoreLayer([
{
id: "account-1",
orgId: "org-1",
kind: "codex_oauth",
label: "primary",
enabled: true,
},
])
const program = Effect.gen(function* () {
const subrouter = yield* core.SubrouterService
return yield* subrouter.route({ orgId: "org-1", sessionId: "session-1" })
}).pipe(Effect.provide(core.makeSubrouterServiceLayer().pipe(
Layer.provideMerge(layer)
)))

const result = await Effect.runPromise(program)
expect(result.account.id).toBe("account-1")
})
4 changes: 4 additions & 0 deletions cloudflare/packages/worker/src/core-routing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export {
accountHasQuotaForModel,
quotaKeyForModel,
} from "@subrouter/core"
Loading
Loading