Skip to content
Merged
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
115 changes: 115 additions & 0 deletions .github/workflows/cloudflare-do.yml
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
4 changes: 4 additions & 0 deletions cloudflare/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules/
packages/*/node_modules/
packages/*/.wrangler/
packages/*/dist*/
239 changes: 239 additions & 0 deletions cloudflare/bun.lock

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions cloudflare/package.json
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"
}
}
16 changes: 16 additions & 0 deletions cloudflare/packages/core/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"name": "@subrouter/core",
"version": "0.0.0",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Missing "private": true on this internal workspace package. The root workspace is already marked private, but this package lacks its own guard. If someone runs npm publish or bun publish from inside the package directory, it could accidentally publish to the npm registry. Add "private": true to prevent accidental publication.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cloudflare/packages/core/package.json, line 3:

<comment>Missing `"private": true` on this internal workspace package. The root workspace is already marked private, but this package lacks its own guard. If someone runs `npm publish` or `bun publish` from inside the package directory, it could accidentally publish to the npm registry. Add `"private": true` to prevent accidental publication.</comment>

<file context>
@@ -0,0 +1,16 @@
+{
+  "name": "@subrouter/core",
+  "version": "0.0.0",
+  "type": "module",
+  "main": "./src/index.ts",
</file context>

"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"effect": "^3.21.2"
},
"devDependencies": {
"typescript": "^5.6.3"
}
}
163 changes: 163 additions & 0 deletions cloudflare/packages/core/src/index.ts
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: quotaKeyForModel uses String.includes() to match pool keywords, so model names containing "spark", "opus", or "sonnet" as substrings would silently route to the wrong quota pool. Consider word-boundary matching or an explicit allowlist if model names evolve beyond current conventions.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cloudflare/packages/core/src/index.ts, line 54:

<comment>`quotaKeyForModel` uses `String.includes()` to match pool keywords, so model names containing "spark", "opus", or "sonnet" as substrings would silently route to the wrong quota pool. Consider word-boundary matching or an explicit allowlist if model names evolve beyond current conventions.</comment>

<file context>
@@ -0,0 +1,163 @@
+  const normalized = model?.trim().toLowerCase()
+  if (!normalized) return "default"
+  for (const pool of MODEL_QUOTA_POOLS) {
+    if (normalized.includes(pool)) return pool
+  }
+  return "default"
</file context>

}
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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
Check if this issue is valid — if so, understand the root cause and fix it. At cloudflare/packages/core/src/index.ts, line 117:

<comment>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.</comment>

<file context>
@@ -0,0 +1,163 @@
+export const makeInMemoryAccountStoreLayer = (initial: ReadonlyArray<Account>) => {
+  const accounts = [...initial]
+  const sticky = makeInMemoryStickyStore()
+  let cursor = 0
+
+  return Layer.succeed(AccountStoreTag, {
</file context>


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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 preferAccountId, a request that tries to force a specific account may still route to the old sticky account. Reordering this flow so explicit pins are evaluated before sticky lookup would align behavior with the documented X-Subrouter-Account-ID force semantics.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At cloudflare/packages/core/src/index.ts, line 131:

<comment>Pinned-account requests can be ignored for existing sticky sessions. Because sticky lookup runs before `preferAccountId`, a request that tries to force a specific account may still route to the old sticky account. Reordering this flow so explicit pins are evaluated before sticky lookup would align behavior with the documented `X-Subrouter-Account-ID` force semantics.</comment>

<file context>
@@ -0,0 +1,163 @@
+        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))
</file context>

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)
}
Loading