From 0f1e66ea0f523f31f90ce35b4615452e48703707 Mon Sep 17 00:00:00 2001 From: Mohammadali Karimi Date: Mon, 12 Oct 2026 19:09:12 +0400 Subject: [PATCH 1/5] Share one key guard across all requests and rate-limit sign-in The guard that refuses API calls while CPA has no keys cached CPA's answer per Worker isolate, so after the last key was removed other isolates kept forwarding for up to 15 seconds, which CPA lets through. Requests now run in the single Durable Object in front of the container, and no cached answer is trusted for a window after any dashboard write. Sign-in attempts are limited to 5 a minute per client IP. --- src/cpa.ts | 20 ++++++++++++++++++++ src/env.ts | 1 + src/gateway.ts | 12 +++++++++++- src/index.ts | 16 +--------------- src/key-guard.ts | 6 +++++- test/gateway.test.ts | 39 +++++++++++++++++++++++++++++++++++++-- wrangler.jsonc | 3 ++- 7 files changed, 77 insertions(+), 20 deletions(-) diff --git a/src/cpa.ts b/src/cpa.ts index 9df9304..309a16f 100644 --- a/src/cpa.ts +++ b/src/cpa.ts @@ -1,13 +1,20 @@ import { Container } from "@cloudflare/containers"; import type { Env } from "./env.ts"; +import { handle } from "./gateway.ts"; +import { KeyGuard } from "./key-guard.ts"; import { handleS3 } from "./s3.ts"; export const STATE_HOST = "r2.internal"; export const STATE_BUCKET = "state"; +const nowSeconds = () => Math.floor(Date.now() / 1000); + +// Every request runs here, in the one Durable Object in front of the container, so a single +// KeyGuard sees every dashboard change no matter which Worker isolate took the request. export class Cpa extends Container { override defaultPort = 8317; override sleepAfter = "30m"; + private readonly keyGuard = new KeyGuard(nowSeconds); constructor(ctx: ConstructorParameters[0], env: Env) { super(ctx, env); @@ -19,6 +26,19 @@ export class Cpa extends Container { CPA_MANAGEMENT_KEY: env.MANAGEMENT_KEY, }; } + + override fetch(request: Request): Promise { + return handle( + request, + { + dashboardPassword: this.env.DASHBOARD_PASSWORD, + nowSeconds, + keyGuard: this.keyGuard, + loginLimiter: this.env.LOGIN_LIMIT, + }, + (forwarded) => this.containerFetch(forwarded), + ); + } } Cpa.outboundByHost = { diff --git a/src/env.ts b/src/env.ts index 834a3b2..09efea2 100644 --- a/src/env.ts +++ b/src/env.ts @@ -3,6 +3,7 @@ import type { Cpa } from "./cpa.ts"; export interface Env { readonly CPA: DurableObjectNamespace; readonly STATE: R2Bucket; + readonly LOGIN_LIMIT: RateLimit; readonly MANAGEMENT_KEY: string; readonly DASHBOARD_PASSWORD: string; } diff --git a/src/gateway.ts b/src/gateway.ts index b24023e..4c54acb 100644 --- a/src/gateway.ts +++ b/src/gateway.ts @@ -5,16 +5,22 @@ import { loginPage, messagePage } from "./login-page.ts"; export type Forward = (request: Request) => Promise; +export interface LoginLimiter { + limit(options: { key: string }): Promise<{ success: boolean }>; +} + export interface GatewayConfig { readonly dashboardPassword: string; readonly nowSeconds: () => number; readonly keyGuard: KeyGuard; + readonly loginLimiter: LoginLimiter; } const MAX_LOGIN_BODY = 4096; export const MIN_PASSWORD_LENGTH = 16; const NO_CLIENT_KEYS = "No API key is set up yet: add one under API Keys in the dashboard."; const CPA_UNAVAILABLE = "The gateway is starting or unavailable. Try again shortly."; +const TOO_MANY_ATTEMPTS = "Too many sign-in attempts. Wait a minute and try again."; const PASSWORD_NOT_SET = `Sign-in is off: set the DASHBOARD_PASSWORD secret to at least ${MIN_PASSWORD_LENGTH} characters.`; function redirect(location: string, headers: HeadersInit = {}): Response { @@ -59,8 +65,10 @@ async function api(request: Request, url: URL, config: GatewayConfig, forward: F } async function dashboard(request: Request, config: GatewayConfig, forward: Forward): Promise { + const writes = request.method !== "GET" && request.method !== "HEAD"; + if (writes) config.keyGuard.forget(); const response = await forward(withoutCookies(request)); - if (request.method !== "GET" && request.method !== "HEAD") config.keyGuard.forget(); + if (writes) config.keyGuard.forget(); return response; } @@ -89,6 +97,8 @@ async function login(request: Request, config: GatewayConfig): Promise if (config.dashboardPassword.length < MIN_PASSWORD_LENGTH) return html(loginPage(PASSWORD_NOT_SET), 503); const length = Number(request.headers.get("content-length") ?? "0"); if (!Number.isFinite(length) || length > MAX_LOGIN_BODY) return html(loginPage("Request too large."), 413); + const client = request.headers.get("cf-connecting-ip") ?? "unknown"; + if (!(await config.loginLimiter.limit({ key: client })).success) return html(loginPage(TOO_MANY_ATTEMPTS), 429); const form = await request.formData().catch(() => null); const password = form?.get("password"); if (typeof password !== "string" || !(await passwordMatches(password, config.dashboardPassword))) { diff --git a/src/index.ts b/src/index.ts index a425eae..1093f45 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,25 +1,11 @@ import { getContainer } from "@cloudflare/containers"; import type { Env } from "./env.ts"; -import { handle } from "./gateway.ts"; -import { KeyGuard } from "./key-guard.ts"; export { ContainerProxy } from "@cloudflare/containers"; export { Cpa } from "./cpa.ts"; -const nowSeconds = () => Math.floor(Date.now() / 1000); -const keyGuard = new KeyGuard(nowSeconds); - export default { fetch(request, env) { - const cpa = getContainer(env.CPA); - return handle( - request, - { - dashboardPassword: env.DASHBOARD_PASSWORD, - nowSeconds, - keyGuard, - }, - (forwarded) => cpa.fetch(forwarded), - ); + return getContainer(env.CPA).fetch(request); }, } satisfies ExportedHandler; diff --git a/src/key-guard.ts b/src/key-guard.ts index 7a9d2ca..1aa909d 100644 --- a/src/key-guard.ts +++ b/src/key-guard.ts @@ -9,6 +9,7 @@ function probeKey(): string { export class KeyGuard { private requiredSince: number | null = null; + private uncachedUntil = 0; constructor(private readonly nowSeconds: () => number) {} @@ -21,13 +22,16 @@ export class KeyGuard { await response?.body?.cancel(); if (response === null) return "unavailable"; if (response.status === 401 || response.status === 403) { - this.requiredSince = now; + if (now >= this.uncachedUntil) this.requiredSince = now; return "required"; } return response.ok ? "open" : "unavailable"; } + // Called around every dashboard write: CPA may apply a key change after its answer, so no + // "required" answer is trusted again until the window has passed. forget(): void { this.requiredSince = null; + this.uncachedUntil = this.nowSeconds() + REQUIRED_SECONDS; } } diff --git a/test/gateway.test.ts b/test/gateway.test.ts index b884d2a..72f12bc 100644 --- a/test/gateway.test.ts +++ b/test/gateway.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { handle, type Forward, type GatewayConfig } from "../src/gateway.ts"; +import { handle, type Forward, type GatewayConfig, type LoginLimiter } from "../src/gateway.ts"; import { KeyGuard } from "../src/key-guard.ts"; import { classify } from "../src/routes.ts"; import { SESSION_COOKIE, issueSession, passwordMatches, readCookie, sessionIsValid } from "../src/session.ts"; @@ -8,8 +8,10 @@ const PASSWORD = "correct-horse-battery-staple-0123456789"; const NOW = 1_800_000_000; const CLIENT_KEY = "client-key-0123456789abcdef0123456789abcdef"; +const unlimited: LoginLimiter = { limit: async () => ({ success: true }) }; + function freshConfig(nowSeconds: () => number = () => NOW): GatewayConfig { - return { dashboardPassword: PASSWORD, nowSeconds, keyGuard: new KeyGuard(nowSeconds) }; + return { dashboardPassword: PASSWORD, nowSeconds, keyGuard: new KeyGuard(nowSeconds), loginLimiter: unlimited }; } const config = freshConfig(); @@ -179,6 +181,25 @@ describe("handle", () => { expect(probes).toHaveLength(3); }); + it("trusts no cached answer for a while after a dashboard change, in case CPA applies it late", async () => { + const keys = [CLIENT_KEY]; + const { forward, probes } = fakeCpa(keys); + let now = NOW; + const guarded = freshConfig(() => now); + const call = () => handle(at("/v1/models", { headers: { "x-api-key": CLIENT_KEY } }), guarded, forward); + const save = at("/v8/management/config/access/api-keys", { method: "PUT", headers: { cookie: await sessionCookie() }, body: "[]" }); + await handle(save, guarded, forward); + expect((await call()).status).toBe(200); + keys.length = 0; + expect((await call()).status).toBe(401); + expect(probes).toHaveLength(2); + keys.push(CLIENT_KEY); + now += 15; + await call(); + await call(); + expect(probes).toHaveLength(3); + }); + it("fails closed when CPA cannot be asked", async () => { const down: Forward = async () => new Response("starting", { status: 502 }); const response = await handle(at("/v1/models", { headers: { "x-api-key": CLIENT_KEY } }), freshConfig(), down); @@ -279,6 +300,20 @@ describe("handle", () => { expect(await response.text()).toContain("Wrong password."); }); + it("limits sign-in attempts per client address", async () => { + const { forward } = recorder(); + const seen: string[] = []; + const limited = { ...config, loginLimiter: { limit: async ({ key }: { key: string }) => ({ success: seen.push(key) <= 1 }) } }; + const attempt = () => + handle(at("/login", { method: "POST", headers: { "cf-connecting-ip": "203.0.113.7" }, body: new URLSearchParams({ password: PASSWORD }) }), limited, forward); + expect((await attempt()).status).toBe(303); + const blocked = await attempt(); + expect(blocked.status).toBe(429); + expect(blocked.headers.get("set-cookie")).toBeNull(); + expect(await blocked.text()).toContain("Too many sign-in attempts"); + expect(seen).toEqual(["203.0.113.7", "203.0.113.7"]); + }); + it("turns sign-in off while the dashboard password is missing or short", async () => { const { forward } = recorder(); const weak = { ...config, dashboardPassword: "short" }; diff --git a/wrangler.jsonc b/wrangler.jsonc index a738a45..887d04d 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -18,5 +18,6 @@ "bindings": [{ "name": "CPA", "class_name": "Cpa" }] }, "migrations": [{ "tag": "v1", "new_sqlite_classes": ["Cpa"] }], - "r2_buckets": [{ "binding": "STATE", "bucket_name": "llm-gateway" }] + "r2_buckets": [{ "binding": "STATE", "bucket_name": "llm-gateway" }], + "ratelimits": [{ "name": "LOGIN_LIMIT", "namespace_id": "1001", "simple": { "limit": 5, "period": 60 } }] } From 4b06349d9314ed118c3d0b4ed711a2a368a7d3ad Mon Sep 17 00:00:00 2001 From: Mohammadali Karimi Date: Mon, 12 Oct 2026 19:09:12 +0400 Subject: [PATCH 2/5] Rewrite the README for new users and add SECURITY.md Short, keyword-led README with the one-click deploy up front; the security model and a private reporting channel move to SECURITY.md. Adds repository metadata to package.json. --- README.md | 112 +++++++++++++++++++++++++--------------------- SECURITY.md | 23 ++++++++++ package-lock.json | 1 + package.json | 25 ++++++++++- 4 files changed, 110 insertions(+), 51 deletions(-) create mode 100644 SECURITY.md diff --git a/README.md b/README.md index f17964b..ad82136 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,100 @@ -# llm-gateway +# LLM Gateway: CLIProxyAPI on Cloudflare in one click -Your own subscription logins (Claude, Codex, Gemini and the others [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) supports) served as an API from your Cloudflare account. One click deploys it; then any app uses your subscriptions with a base URL and a key, from anywhere. No machine of yours has to stay on. +**Turn your Claude, ChatGPT Codex and Gemini subscriptions into your own private API, hosted on Cloudflare.** One click deploys [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) (CPA) to Cloudflare Workers and Containers. Point Claude Code, the Anthropic or OpenAI SDKs, or any app at your gateway URL and use your subscription from anywhere, with no server or home machine left running. [![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/PlaygrndLabs/llm-gateway) +[![ci](https://github.com/PlaygrndLabs/llm-gateway/actions/workflows/ci.yml/badge.svg)](https://github.com/PlaygrndLabs/llm-gateway/actions/workflows/ci.yml) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -``` -app ──► Worker llm-gateway..workers.dev ──► Container: CPA v8.0.24 ──► Claude / Codex / Gemini - │ /v1/* → key from the dashboard, then CPA │ - │ /login → dashboard behind a password └─ http://r2.internal ─► Worker S3 shim ─► R2 bucket - └ everything else → 404 -``` - -- **The real CPA**, built from a pinned upstream commit, with its matching dashboard (`management.html` v1.26.1, checksum-pinned). Not a port: every provider and API CPA supports works the same way. -- **State in R2 with no keys to create.** CPA saves its config and logins through its S3 object store. Inside the container that store points at `http://r2.internal`, which the Worker intercepts and serves from an R2 binding (`src/s3.ts`). Restarts, sleeps and redeploys keep every login. -- **Locked down by default.** API keys exist only once you make them in the dashboard; until then every API call is refused. CPA checks every `/v1/*` and `/v1beta/*` call against its key list, but on its own it lets every call through when that list is empty. So before forwarding, the Worker asks CPA with a random key: if CPA accepts it, no keys are set up and the Worker refuses the call; if CPA can't be reached, the Worker refuses too (`src/key-guard.ts`). The dashboard opens only after a password sign-in (an HMAC-signed, HttpOnly, SameSite=Strict cookie) and still asks for CPA's management key. CPA's own unauthenticated pages, such as its OAuth callbacks, return 404. -- **Cheap when idle.** One `basic` instance (1/4 vCPU, 1 GiB) that sleeps after 30 minutes without requests and wakes on the next one. +## Why -## Deploy +- **One click, no server.** Cloudflare builds and runs everything. Nothing to keep online. +- **The real CPA.** Built from a pinned upstream release with its official dashboard, so every provider and API CPA supports just works: Anthropic Messages, OpenAI-compatible and Gemini. +- **Private by default.** The dashboard sits behind a password, and every API call needs a key you create. Until then, all calls are refused. +- **Logins that stick.** Config and OAuth logins live in your own R2 bucket and survive restarts and redeploys. +- **Costs little when idle.** The container sleeps after 30 minutes and wakes on the next request. -You need a Cloudflare account on the Workers Paid plan ($5 a month; Containers require it). +## Quick start -1. Press **Deploy to Cloudflare** above. Cloudflare copies this repository to your GitHub account, creates the R2 bucket and builds the container. -2. It asks for two secrets. Generate each with `openssl rand -hex 32` (any value of at least 32 characters of `A-Z a-z 0-9 _ -` works): - - `DASHBOARD_PASSWORD`: your password for the dashboard sign-in. - - `MANAGEMENT_KEY`: CPA's management key, which the dashboard asks for after sign-in. -3. When the deploy finishes, open `https://llm-gateway..workers.dev`, sign in, and make an API key for each app under API Keys. +You need a Cloudflare account on the [Workers Paid plan](https://developers.cloudflare.com/workers/platform/pricing/) ($5/month; Containers require it). -`MANAGEMENT_KEY` seeds CPA's config on its first start only. From then on the config in R2 is the source of truth: rotate it, and add or remove app keys, in the dashboard. `DASHBOARD_PASSWORD` stays a Worker secret and can be changed any time in the Cloudflare dashboard or with `wrangler secret put DASHBOARD_PASSWORD`. +1. Press **Deploy to Cloudflare** above. +2. Set the two secrets it asks for. Generate each with `openssl rand -hex 32`: + - `DASHBOARD_PASSWORD`: your dashboard sign-in password. + - `MANAGEMENT_KEY`: CPA's management key, asked for after sign-in. +3. Open `https://llm-gateway..workers.dev`, sign in, and create an API key under **API Keys**. -## Log in to a subscription +## Connect a subscription -1. Open your gateway, sign in with the dashboard password, then enter the management key. -2. Under OAuth login, choose the provider (for Claude: Anthropic) and open the link it gives you. -3. Sign in with that provider. The browser then lands on a `localhost` address that does not load; that is expected, since each provider only returns to the address registered for its login. Copy the whole address from the address bar (it carries `code` and `state`) and paste it into the dashboard's Callback URL field. Or, in the same browser where you are signed in to the gateway, replace `http://localhost:` with your gateway address and open it: the gateway hands the callback to CPA. +1. In the dashboard, open **OAuth login** and pick a provider (for Claude: Anthropic). +2. Sign in with the provider. You land on a `localhost` page that doesn't load; that's expected. +3. Copy that address into the dashboard's **Callback URL** field. Or replace `http://localhost:` with your gateway URL and open it in the same browser. -CPA saves the login to R2 and refreshes its tokens itself. +CPA stores the login and refreshes its tokens on its own. -## Use it from an app +## Use it -The Anthropic Messages API, with streaming and tools, is what the Anthropic SDKs, the Claude Agent SDK and Claude Code read: +Claude Code, the Claude Agent SDK and the Anthropic SDKs: -``` -ANTHROPIC_BASE_URL=https://llm-gateway..workers.dev -ANTHROPIC_API_KEY= +```bash +export ANTHROPIC_BASE_URL=https://llm-gateway..workers.dev +export ANTHROPIC_API_KEY= ``` -Send the key as `x-api-key` or `Authorization: Bearer` (Gemini clients may use `x-goog-api-key` or `?key=`). OpenAI-compatible clients use `https://llm-gateway..workers.dev/v1` with the same key; Gemini clients use `/v1beta`. +OpenAI-compatible clients use `/v1`, and Gemini clients use `/v1beta`, with the same key. -``` -curl https://llm-gateway..workers.dev/v1/messages \ +```bash +curl https://llm-gateway..workers.dev/v1/messages \ -H "x-api-key: $ANTHROPIC_API_KEY" -H "anthropic-version: 2023-06-01" -H "content-type: application/json" \ -d '{"model":"claude-sonnet-5-5","max_tokens":64,"messages":[{"role":"user","content":"Say hi"}]}' ``` -`GET /v1/models` lists the models your logged-in subscriptions offer. Give every app its own key in the dashboard, so removing one cuts off only that app. A key works as soon as you save it. +`GET /v1/models` lists what your subscriptions offer. Give each app its own key, so you can revoke one without touching the rest. -## Deploy with wrangler instead +## How it works ``` -npm ci -npx wrangler deploy # needs Docker running to build the image -sh scripts/set-secrets.sh # generates the two secrets, stores them, prints them once +app ──► Worker ──► Durable Object ──► Container: CPA ──► Claude / Codex / Gemini + /v1/* key check │ + /login dashboard sign-in └─► http://r2.internal ──► R2 bucket ``` -To deploy from GitHub Actions, add the repository secrets `CLOUDFLARE_API_TOKEN` (Workers Scripts, Containers and Workers R2 Storage: Edit) and `CLOUDFLARE_ACCOUNT_ID`; the `deploy` workflow then runs after every green `ci` run on `main`. Without them it skips. +A Worker fronts a single CPA container. It refuses API calls until a key exists, puts the dashboard behind a password, and serves CPA's S3 storage from an R2 binding, so you don't have to create storage keys. See [SECURITY.md](SECURITY.md) for the full model. -## Develop +
+Deploy with wrangler or GitHub Actions +```bash +npm ci +npx wrangler deploy # needs Docker to build the image +sh scripts/set-secrets.sh # generates both secrets, stores them, prints them once ``` + +To deploy from GitHub Actions, add the repository secrets `CLOUDFLARE_API_TOKEN` (Workers Scripts, Containers, Workers R2 Storage: Edit) and `CLOUDFLARE_ACCOUNT_ID`. Every green `ci` run on `main` then deploys. + +`MANAGEMENT_KEY` seeds CPA's config on first start only; after that, change it in the dashboard. `DASHBOARD_PASSWORD` can be changed any time with `wrangler secret put DASHBOARD_PASSWORD`. +
+ +
+Develop + +```bash npm ci -npm run verify # typecheck, lint, unit tests -sh test/container-smoke.sh # needs Docker: builds the image and runs CPA against the S3 shim +npm run verify # typecheck, lint, unit tests +sh test/container-smoke.sh # needs Docker: runs CPA against the R2 shim ``` -`npm run types` regenerates `worker-configuration.d.ts` after a `wrangler.jsonc` change. To move to a newer CPA, change `CPA_COMMIT` in `container/Dockerfile` together with a dashboard release that speaks the same management API (`PANEL_VERSION` and `PANEL_SHA256`). +To upgrade CPA, change `CPA_COMMIT` in `container/Dockerfile` together with a matching dashboard release (`PANEL_VERSION`, `PANEL_SHA256`). +
-## Limits +## Good to know -- Requests to providers leave from Cloudflare's network, not your home connection. Check that your provider's terms allow how you use your subscription. -- The first request after the container sleeps waits a few seconds while it starts. -- One instance serves everything; CPA handles concurrent requests itself. +- Requests reach providers from Cloudflare's network. **Check that your provider's terms allow how you use your subscription.** +- The first request after the container sleeps takes a few seconds while it starts. +- [Containers pricing](https://developers.cloudflare.com/containers/pricing/). More platforms may follow. -Containers pricing: [developers.cloudflare.com/containers/pricing](https://developers.cloudflare.com/containers/pricing/). +Built on [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI). Not affiliated with Anthropic, OpenAI, Google or Cloudflare. ## License -MIT +[MIT](LICENSE) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..3a09698 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,23 @@ +# Security + +## Reporting a vulnerability + +Please report privately through [GitHub's private vulnerability reporting](https://github.com/PlaygrndLabs/llm-gateway/security/advisories/new). Don't open a public issue. We aim to reply within a week. + +Bugs in CPA itself belong upstream at [router-for-me/CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI). + +## Security model + +Everything a deploy holds belongs to its owner: your Cloudflare account, your R2 bucket and your secrets. This repository has no keys, telemetry or shared backend. + +- **API calls need a key.** On its own, CPA lets every `/v1/*` and `/v1beta/*` call through while its key list is empty. The gateway closes that gap: before forwarding, it asks CPA with a random key (`src/key-guard.ts`). If CPA accepts it, no keys are set up, so the gateway refuses the call. If CPA can't be reached, the gateway refuses too. The check runs in the single Durable Object in front of the container, so every request sees the same answer, and a dashboard change takes effect at once. +- **The dashboard needs two secrets.** First the `DASHBOARD_PASSWORD` sign-in, which sets an HMAC-signed `__Host-` cookie (HttpOnly, Secure, SameSite=Strict) that lasts 12 hours. Then CPA's own management key. Sign-in attempts are limited to 5 a minute per IP. Changing the password signs out every session. +- **Only known paths reach CPA.** The API, the dashboard, `/healthz` and the OAuth callback (for a signed-in user only) are forwarded. Everything else, and any path with encoded separators, returns 404. Browser cookies are stripped before forwarding. +- **State stays in your R2 bucket.** CPA's S3 client talks to `http://r2.internal`, which only the container can reach and which the Worker serves from an R2 binding. No R2 API tokens exist. +- **A pinned supply chain.** CPA is built from a pinned commit, and the dashboard HTML is checksum-verified (`container/Dockerfile`). + +## Your part + +- Use long random secrets: `openssl rand -hex 32`. +- Give each app its own API key, and delete a key the moment it may have leaked. +- Never commit `.dev.vars`. It is in `.gitignore`. diff --git a/package-lock.json b/package-lock.json index 5f4c4b1..cc9d087 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,6 +5,7 @@ "packages": { "": { "name": "llm-gateway", + "license": "MIT", "dependencies": { "@cloudflare/containers": "0.3.7" }, diff --git a/package.json b/package.json index 49b4f9a..50589e1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,29 @@ { "name": "llm-gateway", - "description": "One-shot CLIProxyAPI on Cloudflare: your subscription logins as an API from a Worker and a Container, state in R2.", + "description": "Deploy CLIProxyAPI (CPA) to Cloudflare in one click: your Claude, Codex and Gemini subscriptions as a private API, on Workers and Containers with state in R2.", + "license": "MIT", + "homepage": "https://github.com/PlaygrndLabs/llm-gateway#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/PlaygrndLabs/llm-gateway.git" + }, + "bugs": { + "url": "https://github.com/PlaygrndLabs/llm-gateway/issues" + }, + "keywords": [ + "cliproxyapi", + "cpa", + "cloudflare-workers", + "cloudflare-containers", + "claude-code", + "llm-gateway", + "ai-gateway", + "openai-compatible", + "anthropic", + "codex", + "gemini", + "self-hosted" + ], "private": true, "type": "module", "scripts": { From bfccd839ca7b6c1f3f18c2c921d152d715ef26d0 Mon Sep 17 00:00:00 2001 From: Mohammadali Karimi Date: Mon, 12 Oct 2026 19:42:32 +0400 Subject: [PATCH 3/5] Rename the project to sub2api The Worker, R2 bucket, package, login page and docs now use sub2api, and the README introduces it as a Cloudflare-based LLM gateway and proxy server that deploys CPA. --- .github/workflows/deploy.yml | 4 ++-- README.md | 16 ++++++++-------- SECURITY.md | 2 +- package-lock.json | 4 ++-- package.json | 11 ++++++----- src/login-page.ts | 4 ++-- src/session.ts | 2 +- test/container-smoke.sh | 4 ++-- wrangler.jsonc | 4 ++-- 9 files changed, 26 insertions(+), 25 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 877f760..eb5828e 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -45,8 +45,8 @@ jobs: - name: Create the R2 bucket if it is missing if: steps.creds.outputs.ready == 'true' run: | - if ! npx wrangler r2 bucket info llm-gateway >/dev/null 2>&1; then - npx wrangler r2 bucket create llm-gateway + if ! npx wrangler r2 bucket info sub2api >/dev/null 2>&1; then + npx wrangler r2 bucket create sub2api fi - if: steps.creds.outputs.ready == 'true' run: npx wrangler deploy diff --git a/README.md b/README.md index ad82136..07c8cac 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ -# LLM Gateway: CLIProxyAPI on Cloudflare in one click +# sub2api: a Cloudflare-based LLM gateway and proxy server -**Turn your Claude, ChatGPT Codex and Gemini subscriptions into your own private API, hosted on Cloudflare.** One click deploys [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) (CPA) to Cloudflare Workers and Containers. Point Claude Code, the Anthropic or OpenAI SDKs, or any app at your gateway URL and use your subscription from anywhere, with no server or home machine left running. +**Literally the [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) (CPA) project, deployed as a Cloudflare Worker in one click.** Turn your Claude, ChatGPT Codex and Gemini subscriptions into your own private API. Point Claude Code, the Anthropic or OpenAI SDKs, or any app at your gateway URL and use your subscription from anywhere, with no server or home machine left running. -[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/PlaygrndLabs/llm-gateway) -[![ci](https://github.com/PlaygrndLabs/llm-gateway/actions/workflows/ci.yml/badge.svg)](https://github.com/PlaygrndLabs/llm-gateway/actions/workflows/ci.yml) +[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/PlaygrndLabs/sub2api) +[![ci](https://github.com/PlaygrndLabs/sub2api/actions/workflows/ci.yml/badge.svg)](https://github.com/PlaygrndLabs/sub2api/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) ## Why @@ -22,7 +22,7 @@ You need a Cloudflare account on the [Workers Paid plan](https://developers.clou 2. Set the two secrets it asks for. Generate each with `openssl rand -hex 32`: - `DASHBOARD_PASSWORD`: your dashboard sign-in password. - `MANAGEMENT_KEY`: CPA's management key, asked for after sign-in. -3. Open `https://llm-gateway..workers.dev`, sign in, and create an API key under **API Keys**. +3. Open `https://sub2api..workers.dev`, sign in, and create an API key under **API Keys**. ## Connect a subscription @@ -37,14 +37,14 @@ CPA stores the login and refreshes its tokens on its own. Claude Code, the Claude Agent SDK and the Anthropic SDKs: ```bash -export ANTHROPIC_BASE_URL=https://llm-gateway..workers.dev +export ANTHROPIC_BASE_URL=https://sub2api..workers.dev export ANTHROPIC_API_KEY= ``` OpenAI-compatible clients use `/v1`, and Gemini clients use `/v1beta`, with the same key. ```bash -curl https://llm-gateway..workers.dev/v1/messages \ +curl https://sub2api..workers.dev/v1/messages \ -H "x-api-key: $ANTHROPIC_API_KEY" -H "anthropic-version: 2023-06-01" -H "content-type: application/json" \ -d '{"model":"claude-sonnet-5-5","max_tokens":64,"messages":[{"role":"user","content":"Say hi"}]}' ``` @@ -93,7 +93,7 @@ To upgrade CPA, change `CPA_COMMIT` in `container/Dockerfile` together with a ma - The first request after the container sleeps takes a few seconds while it starts. - [Containers pricing](https://developers.cloudflare.com/containers/pricing/). More platforms may follow. -Built on [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI). Not affiliated with Anthropic, OpenAI, Google or Cloudflare. +Built on [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI). Not affiliated with [Wei-Shaw/sub2api](https://github.com/Wei-Shaw/sub2api), Anthropic, OpenAI, Google or Cloudflare. ## License diff --git a/SECURITY.md b/SECURITY.md index 3a09698..38ef5b2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Reporting a vulnerability -Please report privately through [GitHub's private vulnerability reporting](https://github.com/PlaygrndLabs/llm-gateway/security/advisories/new). Don't open a public issue. We aim to reply within a week. +Please report privately through [GitHub's private vulnerability reporting](https://github.com/PlaygrndLabs/sub2api/security/advisories/new). Don't open a public issue. We aim to reply within a week. Bugs in CPA itself belong upstream at [router-for-me/CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI). diff --git a/package-lock.json b/package-lock.json index cc9d087..81b0cea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,10 +1,10 @@ { - "name": "llm-gateway", + "name": "sub2api", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "llm-gateway", + "name": "sub2api", "license": "MIT", "dependencies": { "@cloudflare/containers": "0.3.7" diff --git a/package.json b/package.json index 50589e1..aa4d118 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,14 @@ { - "name": "llm-gateway", - "description": "Deploy CLIProxyAPI (CPA) to Cloudflare in one click: your Claude, Codex and Gemini subscriptions as a private API, on Workers and Containers with state in R2.", + "name": "sub2api", + "description": "A Cloudflare-based LLM gateway and proxy server: the CLIProxyAPI (CPA) project deployed as a Cloudflare Worker in one click.", "license": "MIT", - "homepage": "https://github.com/PlaygrndLabs/llm-gateway#readme", + "homepage": "https://github.com/PlaygrndLabs/sub2api#readme", "repository": { "type": "git", - "url": "git+https://github.com/PlaygrndLabs/llm-gateway.git" + "url": "git+https://github.com/PlaygrndLabs/sub2api.git" }, "bugs": { - "url": "https://github.com/PlaygrndLabs/llm-gateway/issues" + "url": "https://github.com/PlaygrndLabs/sub2api/issues" }, "keywords": [ "cliproxyapi", @@ -17,6 +17,7 @@ "cloudflare-containers", "claude-code", "llm-gateway", + "llm-proxy", "ai-gateway", "openai-compatible", "anthropic", diff --git a/src/login-page.ts b/src/login-page.ts index 4700203..eec85b4 100644 --- a/src/login-page.ts +++ b/src/login-page.ts @@ -5,7 +5,7 @@ function escapeHtml(text: string): string { export function loginPage(error: string | null): string { const message = error === null ? "" : `

${escapeHtml(error)}

`; return page(`
-

LLM Gateway

+

sub2api

Sign in to open the CPA dashboard.

${message} @@ -27,7 +27,7 @@ function page(content: string): string { -LLM Gateway +sub2api