-
Notifications
You must be signed in to change notification settings - Fork 4
Add Cloudflare Durable Object subrouter #50
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| name: Cloudflare Durable Object | ||
|
|
||
| on: | ||
| pull_request: | ||
| paths: | ||
| - ".github/workflows/cloudflare-do.yml" | ||
| - "cloudflare/**" | ||
| push: | ||
| branches: | ||
| - main | ||
| paths: | ||
| - ".github/workflows/cloudflare-do.yml" | ||
| - "cloudflare/**" | ||
| workflow_dispatch: | ||
| inputs: | ||
| deploy_environment: | ||
| description: "Cloudflare environment to deploy" | ||
| required: true | ||
| default: "staging" | ||
| type: choice | ||
| options: | ||
| - staging | ||
| - production | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| concurrency: | ||
| group: cloudflare-do-${{ github.event_name == 'workflow_dispatch' && inputs.deploy_environment || github.ref }} | ||
| cancel-in-progress: false | ||
|
|
||
| jobs: | ||
| verify: | ||
| name: Verify | ||
| runs-on: ubuntu-latest | ||
| steps: | ||
| - uses: actions/checkout@v6 | ||
|
|
||
| - uses: oven-sh/setup-bun@v2 | ||
| with: | ||
| bun-version: 1.3.14 | ||
|
|
||
| - name: Install | ||
| working-directory: cloudflare | ||
| run: bun install --frozen-lockfile | ||
|
|
||
| - name: Typecheck | ||
| working-directory: cloudflare | ||
| run: bun run typecheck | ||
|
|
||
| - name: Test | ||
| working-directory: cloudflare | ||
| run: bun run test | ||
|
|
||
| - name: Validate Worker bundle | ||
| working-directory: cloudflare/packages/worker | ||
| run: bunx wrangler deploy --env staging --dry-run --outdir dist-dry-run | ||
|
|
||
| deploy-staging: | ||
| name: Deploy staging | ||
| needs: verify | ||
| runs-on: ubuntu-latest | ||
| if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch' && inputs.deploy_environment == 'staging' | ||
| environment: | ||
| name: subrouter-staging | ||
| url: https://subrouter-staging.cmux.dev/healthz | ||
| steps: | ||
| - uses: actions/checkout@v6 | ||
|
|
||
| - uses: oven-sh/setup-bun@v2 | ||
| with: | ||
| bun-version: 1.3.14 | ||
|
|
||
| - name: Install | ||
| working-directory: cloudflare | ||
| run: bun install --frozen-lockfile | ||
|
|
||
| - name: Deploy | ||
| working-directory: cloudflare/packages/worker | ||
| env: | ||
| CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | ||
| CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | ||
| run: bunx wrangler deploy --env staging --keep-vars | ||
|
|
||
| - name: Smoke health | ||
| run: curl -fsS https://subrouter-staging.cmux.dev/healthz | ||
|
|
||
| deploy-production: | ||
| name: Deploy production | ||
| needs: verify | ||
| runs-on: ubuntu-latest | ||
| if: github.event_name == 'workflow_dispatch' && inputs.deploy_environment == 'production' | ||
| environment: | ||
| name: subrouter-production | ||
| url: https://subrouter.cmux.dev/healthz | ||
| steps: | ||
| - uses: actions/checkout@v6 | ||
|
|
||
| - uses: oven-sh/setup-bun@v2 | ||
| with: | ||
| bun-version: 1.3.14 | ||
|
|
||
| - name: Install | ||
| working-directory: cloudflare | ||
| run: bun install --frozen-lockfile | ||
|
|
||
| - name: Deploy | ||
| working-directory: cloudflare/packages/worker | ||
| env: | ||
| CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} | ||
| CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} | ||
| run: bunx wrangler deploy --env production --keep-vars | ||
|
|
||
| - name: Smoke health | ||
| run: curl -fsS https://subrouter.cmux.dev/healthz |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| node_modules/ | ||
| packages/*/node_modules/ | ||
| packages/*/.wrangler/ | ||
| packages/*/dist*/ |
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| { | ||
| "name": "subrouter-cloudflare", | ||
| "private": true, | ||
| "type": "module", | ||
| "workspaces": [ | ||
| "packages/*" | ||
| ], | ||
| "scripts": { | ||
| "typecheck": "bun run --filter '*' typecheck", | ||
| "test": "bun test packages/core/test packages/worker/test", | ||
| "deploy:staging": "bun run --filter '@subrouter/cloudflare-worker' deploy:staging", | ||
| "deploy:production": "bun run --filter '@subrouter/cloudflare-worker' deploy:production" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/bun": "^1.1.13", | ||
| "@types/node": "^22.9.0", | ||
| "typescript": "^5.6.3" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| { | ||
| "name": "@subrouter/core", | ||
| "version": "0.0.0", | ||
| "type": "module", | ||
| "main": "./src/index.ts", | ||
| "types": "./src/index.ts", | ||
| "scripts": { | ||
| "typecheck": "tsc --noEmit" | ||
| }, | ||
| "dependencies": { | ||
| "effect": "^3.21.2" | ||
| }, | ||
| "devDependencies": { | ||
| "typescript": "^5.6.3" | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,163 @@ | ||
| 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 | ||
|
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. P3: Prompt for AI agents |
||
| } | ||
| 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 | ||
|
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: The round-robin cursor is shared across all orgs and quota keys, so distribution for one tenant or quota pool is skewed by requests from other tenants/pools. In a multi-tenant reference implementation this can produce confusing test behavior. Consider maintaining a Map<(orgId, resolvedQuotaKey) => number> for per-org-per-pool cursors. Prompt for AI agents |
||
|
|
||
| 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) | ||
|
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: Pinned-account requests can be ignored for existing sticky sessions. Because sticky lookup runs before Prompt for AI agents |
||
| 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) | ||
| } | ||
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: Missing
"private": trueon this internal workspace package. The root workspace is already marked private, but this package lacks its own guard. If someone runsnpm publishorbun publishfrom inside the package directory, it could accidentally publish to the npm registry. Add"private": trueto prevent accidental publication.Prompt for AI agents