diff --git a/.claude/commands/dr-pr.md b/.claude/commands/dr-pr.md new file mode 100644 index 000000000000..a2a17067a328 --- /dev/null +++ b/.claude/commands/dr-pr.md @@ -0,0 +1,32 @@ +Help create a clean PR for the current branch. Follow this checklist before opening the PR. + +## Pre-PR checklist + +1. **Run tests** — confirm all Go tests pass: + ``` + docker run --rm -v "$(pwd):/app" -w /app golang:1.25-alpine sh -c 'go test ./relay/ ./internal/... 2>&1 | grep -E "^(ok|FAIL)"' + ``` + All lines must say `ok`. If any say `FAIL`, stop and fix before proceeding. + +2. **Check diff** — run `git diff main...HEAD` and summarise what changed. Flag any: + - Accidental debug prints or TODOs left in + - Files that shouldn't be in this PR (seed scripts, .env, temp files) + - Missing test for the change + +3. **Check ordering bug** — if the PR touches `relay/*_handler.go`, verify that for each handler that calls `applyAirbotixPolicy*`, the call comes **BEFORE** `helper.ModelMappedHelper`. This ordering is critical for kids_mode whitelist correctness. + +4. **Push branch** if not already pushed: + ``` + git push -u origin + ``` + +5. **Create the PR via GitHub MCP** — use the create_pull_request tool with: + - title: `type(scope): short description` (e.g. `fix(relay): apply policy before model mapping`) + - base: `main` + - body sections: Problem, Fix, Verification table (test cases + results) + - draft: false + +## Reminders +- One concern per PR. Don't bundle unrelated fixes. +- Co-author line in description: `Co-Authored-By: Claude Sonnet 4.6 ` +- After PR is created, share the URL with the user. diff --git a/.claude/commands/dr-status.md b/.claude/commands/dr-status.md new file mode 100644 index 000000000000..0526eaaa1779 --- /dev/null +++ b/.claude/commands/dr-status.md @@ -0,0 +1,39 @@ +Report the current DeepRouter project status. Do the following steps in order: + +1. Run `git log --oneline -8` to show recent commits. +2. Run `git branch` to list local branches and note any active feature/fix branches. +3. Run `git status --short` to show any unstaged or uncommitted changes. +4. Read `AIRBOTIX.md` (the "What we customise" table) to get the Airbotix-specific package status. +5. Read `PLAN.md` if it exists, and note the current phase. + +Then produce a concise report in this format: + +--- +## DeepRouter Status — [today's date] + +### Recent commits (last 8) +[list] + +### Active branches +[list any non-main branches] + +### Uncommitted changes +[list or "none"] + +### Sprint 1 ticket status (from memory + code) +| Ticket | Title | Status | +|--------|-------|--------| +| DR-6 | internal/billing webhook dispatcher | ✅ Done | +| DR-7 | internal/kids hard constraints | ✅ Done | +| DR-8 | internal/policy decision engine | ✅ Done | +| DR-9 | e2e: same endpoint, different key → different policy | 🟡 PR open, fix incomplete (claude/gemini/responses handlers still have ordering bug) | +| DR-13 | Quota check RPM/TPM + staging deploy | ⏳ Not started | + +### Open PRs / branches +[describe any open branches/PRs] + +### What needs doing next +[top 1-2 items] +--- + +Be specific and honest. Do not mark anything Done if it has known gaps. diff --git a/.claude/commands/dr-test.md b/.claude/commands/dr-test.md new file mode 100644 index 000000000000..2cd6a7624c19 --- /dev/null +++ b/.claude/commands/dr-test.md @@ -0,0 +1,54 @@ +Run the standard DeepRouter policy e2e verification (DR-9 test suite). + +The local dev stack runs at http://localhost:3000. + +## What you need first + +Ask the user for two API tokens if not already provided: +- ROOT_KEY: a token belonging to a user with `kids_mode=false`, `policy_profile=passthrough` +- KIDS_KEY: a token belonging to a user with `kids_mode=true`, `policy_profile=kid-safe` + +The Groq channel must have `model_mapping`: `gpt-4o-mini` → `llama-3.1-8b-instant`. + +## Run 3 test cases + +For each test, run the curl command and record the HTTP status + first few words of the response content. + +**TEST 1 — root key, non-whitelisted model (should PASS)** +``` +curl -s -w "\nHTTP %{http_code}" http://localhost:3000/v1/chat/completions \ + -H "Authorization: Bearer $ROOT_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"llama-3.1-8b-instant","messages":[{"role":"user","content":"Say hello in 5 words"}],"max_tokens":20}' +``` +Expected: HTTP 200, content with words. + +**TEST 2 — kids key, non-whitelisted model (should BLOCK)** +``` +curl -s -w "\nHTTP %{http_code}" http://localhost:3000/v1/chat/completions \ + -H "Authorization: Bearer $KIDS_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"llama-3.1-8b-instant","messages":[{"role":"user","content":"Say hello in 5 words"}],"max_tokens":20}' +``` +Expected: HTTP 400, error mentioning `model_not_eligible_for_kids_mode`. + +**TEST 3 — kids key, whitelisted model that maps to non-whitelisted upstream (should PASS)** +``` +curl -s -w "\nHTTP %{http_code}" http://localhost:3000/v1/chat/completions \ + -H "Authorization: Bearer $KIDS_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"Say hello in 5 words"}],"max_tokens":20}' +``` +Expected: HTTP 200, content with words (channel remaps gpt-4o-mini → llama-3.1-8b-instant internally, but whitelist check sees the original name). + +## Report + +After running all 3 tests, report a table: + +| Test | Key | Model sent | Expected | Result | Pass? | +|------|-----|------------|----------|--------|-------| +| 1 | root | llama-3.1-8b-instant | 200 | ... | ✅/❌ | +| 2 | kids | llama-3.1-8b-instant | 400 | ... | ✅/❌ | +| 3 | kids | gpt-4o-mini | 200 | ... | ✅/❌ | + +If any test fails, diagnose why (check container logs: `docker logs new-api-dev --tail 30`). diff --git a/.dockerignore b/.dockerignore index 2cf7cad463f1..0e8f04f6928f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,4 +8,6 @@ docs .eslintcache .gocache /web/node_modules +web/default/node_modules +web/classic/node_modules !THIRD-PARTY-LICENSES.md diff --git a/.gitignore b/.gitignore index 7234e229c514..1e436bf0fdae 100644 --- a/.gitignore +++ b/.gitignore @@ -18,12 +18,14 @@ new-api /__debug_bin* .DS_Store tiktoken_cache +bin/seed-output-*.txt .eslintcache .gocache .gomodcache/ .cache plans .claude +!.claude/commands/ .cursor electron/node_modules diff --git a/AIRBOTIX.md b/AIRBOTIX.md index a01acbece1b3..59c6de538805 100644 --- a/AIRBOTIX.md +++ b/AIRBOTIX.md @@ -14,20 +14,42 @@ DeepRouter is an independent product (not part of Airbotix). See [`docs/PRD.md`] The model-selection sidecar lives in a **separate repo** (`../smart-router/`, Apache 2.0) precisely to keep routing intelligence outside AGPL's viral scope. See `../CLAUDE.md` for the process-boundary rules. -## What we customise (status as of 2026-05-23) +## What we customise (status as of 2026-06-07, Sprint 1) We minimise core changes to keep upstream cherry-picking sustainable. All Airbotix-specific code lives in dedicated locations: | Path | Purpose | Status | |---|---|---| -| `internal/policy/` | Decision engine — `DecisionFor(kidsMode, profile) → Decision` (6 boolean flags) | ✅ Implemented (78 LOC + tests) — wired via `relay/airbotix_policy.go` | -| `internal/kids/` | Hard constraints: model whitelist, metadata strip, OpenAI ZDR, child-safe system prompt | ✅ Implemented (112 LOC + tests) — wired via `relay/airbotix_policy.go` | -| `internal/smart_router_client/` | HTTP client for the smart-router sidecar, with circuit breaker and graceful degradation | ✅ Implemented (190 LOC + tests) — wired via `middleware/smart_router.go` | -| `internal/billing/` | HMAC-signed per-request billing webhook dispatcher with retry policy | ✅ Implemented (119 LOC + tests) — **NOT yet wired into relay path (Phase 2 in PLAN.md)** | -| `relay/airbotix_policy.go` + test | Stitches policy + kids enforcement into OpenAI / Claude / Gemini / Responses request shapes | ✅ Wired | +| `internal/policy/` | Decision engine — `DecisionFor(kidsMode, profile) → Decision` (6 boolean flags) | ✅ Done — wired via `relay/airbotix_policy.go` | +| `internal/kids/` | Hard constraints: model whitelist, metadata strip, OpenAI ZDR, child-safe system prompt | ✅ Done — wired via `relay/airbotix_policy.go` | +| `internal/smart_router_client/` | HTTP client for the smart-router sidecar, with circuit breaker and graceful degradation | ✅ Done — wired via `middleware/smart_router.go` | +| `internal/billing/` | HMAC-signed per-request billing webhook dispatcher with retry policy | ✅ Code + tests done — **NOT yet wired into relay path (Phase 2 in PLAN.md)** | +| `relay/airbotix_policy.go` + test | Stitches policy + kids enforcement into OpenAI / Claude / Gemini / Responses request shapes | ✅ Wired, 20+ unit tests | +| `relay/compatible_handler.go` | **Bug fix (2026-06-07)**: policy check moved BEFORE `ModelMappedHelper` so kids whitelist uses client-requested model name, not channel-remapped name. | ✅ Fixed (PR open) — ⚠️ same fix still needed in claude/responses/gemini handlers | | `middleware/smart_router.go` | Detects `deeprouter-auto` virtual model, calls smart_router_client, rewrites model name | ✅ Wired | | `model/user.go` | Extended with 5 columns: `kids_mode`, `policy_profile`, `billing_webhook_url`, `custom_pricing_id`, `webhook_secret` | ✅ Migration applies on boot | | `web/default/` | Admin UI — needs fields added for the 4 new User columns (Phase 1 work) | 🟡 Backend ready, UI pending | +| `.dockerignore` | Added `web/default/node_modules` + `web/classic/node_modules` to cut build context from ~1.5 GB to ~40 MB | ✅ Fixed — PR pending | + +## Sprint 1 ticket status (5 Jun – 19 Jun 2026) + +| Ticket | Title | Status | Notes | +|--------|-------|--------|-------| +| DR-6 | `internal/billing` webhook dispatcher | ✅ Done | Code + tests. Not wired (Phase 2). | +| DR-7 | `internal/kids` hard constraints | ✅ Done | Whitelist, ZDR, metadata strip, child-safe prompt. | +| DR-8 | `internal/policy` decision engine | ✅ Done | `DecisionFor()` pure function + tests. | +| DR-9 | e2e: same endpoint, different key → different policy | 🟡 PR open | chat completions path fixed + verified. claude/responses/gemini handlers still have ordering bug. | +| DR-13 | Quota check RPM/TPM + staging deploy | ⏳ Not started | Next. | + +## Known bugs / open items + +### Policy ordering bug in non-chat handlers (HIGH) +`applyAirbotixPolicy*` is called AFTER `helper.ModelMappedHelper` in three handlers: +- `relay/claude_handler.go` (line 39 → line 45) +- `relay/responses_handler.go` (line 63 → line 69) +- `relay/gemini_handler.go` (line 69 → line 77) + +Effect: kids key + whitelisted model gets blocked if the channel remaps it to a non-whitelisted upstream name. Identical root cause as the bug fixed in `compatible_handler.go` (DR-9). Fix tracked in the same PR before merge. **Database changes**: extend NewAPI's existing `users` table with 5 columns. No new tables, no schema rewrite. diff --git a/docs/wiki/Architecture-Decisions.md b/docs/wiki/Architecture-Decisions.md new file mode 100644 index 000000000000..ddb58af91aba --- /dev/null +++ b/docs/wiki/Architecture-Decisions.md @@ -0,0 +1,76 @@ +# Architecture Decisions + +Key decisions made for DeepRouter. Each entry has: what was decided, why, and what it rules out. + +--- + +## ADR-001 — Fork QuantumNous/new-api rather than build from scratch + +**Date:** 2026-05 +**Status:** Active + +**Decision:** Base DeepRouter on `QuantumNous/new-api` (AGPL v3, 32K stars). + +**Why:** Upstream already handles 37 upstream providers, retry logic, billing, admin UI, and multi-tenant token management. Building equivalent from scratch would take months. + +**Trade-off:** Bound to AGPL v3 viral license. Mitigated by keeping Airbotix-specific logic in `internal/` subpackages (clean rebase zone) and the model-selection intelligence in a separate Apache 2.0 repo (`../smart-router/`). + +**Rules out:** Clean-room proprietary gateway. + +--- + +## ADR-002 — Airbotix-specific code lives exclusively in `internal/` + +**Date:** 2026-05 +**Status:** Active + +**Decision:** All fork-specific packages go under `internal/` (policy, kids, billing, smart_router_client). The one exception is `relay/airbotix_policy.go` which is deliberately named to make rebase conflicts obvious. + +**Why:** Upstream `controller/`, `model/`, `service/` are actively maintained. Minimising edits there keeps `git cherry-pick` from upstream feasible. + +**Rules out:** Spreading business logic across upstream files. + +--- + +## ADR-003 — smart-router in a separate repo (Apache 2.0) + +**Date:** 2026-05 +**Status:** Active + +**Decision:** Intelligent model selection (`deeprouter-auto`) lives in `deeprouter-ai/smart-router`, not in this repo. + +**Why:** Model-selection intelligence is proprietary competitive advantage. AGPL's viral clause would force open-sourcing if it lived here. Apache 2.0 on the sidecar keeps it closed while the gateway stays open-source. + +**Rules out:** Bundling routing logic into the gateway binary. + +--- + +## ADR-004 — Policy check must run BEFORE channel model_mapping + +**Date:** 2026-06-07 +**Status:** Active — partial implementation (only `compatible_handler.go` fixed so far) + +**Decision:** In every relay handler, `applyAirbotixPolicy*` must be called before `helper.ModelMappedHelper`. + +**Why:** `ModelMappedHelper` rewrites `request.Model` to the upstream model name (e.g. `gpt-4o-mini` → `llama-3.1-8b-instant` on a Groq channel). If the whitelist check runs after this rewrite, it evaluates the upstream name — which may not be on the whitelist — and blocks a legitimately-allowed request. + +**Correct order:** +``` +1. applyAirbotixPolicy(decision, channelType, request) ← uses client name +2. helper.ModelMappedHelper(c, info, request) ← rewrites to upstream name +``` + +**Affected handlers:** `compatible_handler.go` ✅, `claude_handler.go` ⚠️ pending, `responses_handler.go` ⚠️ pending, `gemini_handler.go` ⚠️ pending. + +--- + +## ADR-005 — `internal/billing/` not wired yet (Phase 2) + +**Date:** 2026-05 +**Status:** Deferred + +**Decision:** The HMAC billing webhook dispatcher is implemented and tested but intentionally not called from the relay path in V0. + +**Why:** V0 goal is relay + kids_mode correctness. Billing introduces a network call on every request; we want relay to be stable first. The wiring point is `service/text_quota.go` where quota is settled post-completion. + +**Rules out:** Live billing in V0 / Sprint 1. diff --git a/docs/wiki/Bug-Log.md b/docs/wiki/Bug-Log.md new file mode 100644 index 000000000000..e0a727629867 --- /dev/null +++ b/docs/wiki/Bug-Log.md @@ -0,0 +1,81 @@ +# Bug Log + +Notable bugs found, root-caused, and fixed. Useful for onboarding and preventing regressions. + +--- + +## BUG-001 — Policy whitelist checks upstream model name instead of client-requested name + +**Date found:** 2026-06-07 +**Severity:** High — blocks legitimate kids key requests +**Ticket:** DR-9 +**PR:** fix/policy-before-model-mapping + +### Symptom +A kids key sending `gpt-4o-mini` (on the `EligibleModels` whitelist) received a 400 error: +``` +model_not_eligible_for_kids_mode: llama-3.1-8b-instant +``` + +### Root cause +In `relay/compatible_handler.go`, `helper.ModelMappedHelper` ran **before** `applyAirbotixPolicy`. `ModelMappedHelper` rewrites `request.Model` to the channel's upstream model name (the Groq channel mapped `gpt-4o-mini` → `llama-3.1-8b-instant`). The whitelist check then saw `llama-3.1-8b-instant`, which is not on the whitelist, and rejected the request. + +### Fix +Moved the policy check block above `ModelMappedHelper` so the whitelist always evaluates the client-requested model name. + +```go +// CORRECT order in compatible_handler.go: +if d, ok := common.GetContextKey(c, constant.ContextKeyPolicyDecision); ok { + // ... whitelist check uses request.Model = "gpt-4o-mini" ✅ +} +err = helper.ModelMappedHelper(c, info, request) +// request.Model is now "llama-3.1-8b-instant" — but we already approved it +``` + +### Still open +Same bug exists in `claude_handler.go` (line 39→45), `responses_handler.go` (line 63→69), `gemini_handler.go` (line 69→77). Fix pending. + +### How to test +Run `/dr-test` — Test 3 (kids key + gpt-4o-mini) validates this fix. + +--- + +## BUG-002 — Docker build context ~1.5 GB due to missing .dockerignore entries + +**Date found:** 2026-06-06 +**Severity:** Low (dev experience only) +**Status:** Fixed — unstaged, PR pending + +### Symptom +`docker compose -f docker-compose.dev.yml up --build` took 8+ minutes, transferring over 1.5 GB of context to Docker daemon. + +### Root cause +`.dockerignore` was missing: +``` +web/default/node_modules +web/classic/node_modules +``` +Both frontend directories' `node_modules` were being sent in full. + +### Fix +Added both entries to `.dockerignore`. Build context now ~40 MB. + +--- + +## BUG-003 — Token routing fails if token `group` field is empty + +**Date found:** 2026-06-06 +**Severity:** Medium — requests 404 at channel selection +**Status:** Fixed via DB update + +### Symptom +Relay returned channel-not-found error even though the channel existed and the model was in the `abilities` table. + +### Root cause +`tokens.group` was empty string `""`. The channel routing query matches `abilities.group = tokens.group`, so an empty token group finds no abilities. + +### Fix +```sql +UPDATE tokens SET "group" = 'default' WHERE user_id = 2; +``` +Ensure all tokens are assigned a group that matches an entry in the `abilities` table. diff --git a/docs/wiki/Dev-Setup.md b/docs/wiki/Dev-Setup.md new file mode 100644 index 000000000000..84151228485d --- /dev/null +++ b/docs/wiki/Dev-Setup.md @@ -0,0 +1,109 @@ +# Dev Setup + +Get the local dev stack running in under 10 minutes. + +## Prerequisites + +- Docker Desktop (Windows/Mac) or Docker Engine (Linux) +- Git +- `gh` CLI (optional, for PR creation) + +## 1. Clone + +```bash +git clone https://github.com/deeprouter-ai/deeprouter.git +cd deeprouter +``` + +## 2. Start the dev stack + +```bash +docker compose -f docker-compose.dev.yml up -d --build +``` + +This builds Go from source. First build takes ~3 min (downloads Go modules). Subsequent rebuilds take ~40 sec. + +Backend: http://localhost:3000 +Admin UI (backend-served): http://localhost:3000 + +## 3. First-time setup + +The DB is empty on fresh volume. Initialize root account: + +```bash +curl -X POST http://localhost:3000/api/setup \ + -H "Content-Type: application/json" \ + -d '{"username":"root","password":"12345678","confirmPassword":"12345678"}' +``` + +Then log in at http://localhost:3000 with `root` / `12345678`. + +## 4. Seed test data (Groq channel + kids tenant) + +You need a [Groq API key](https://console.groq.com) (free tier works). + +Run the seed script from inside an Alpine container (requires `jq`): + +```bash +docker run --rm -it \ + --network host \ + -v "$(pwd)/bin:/scripts" \ + alpine sh -c "apk add -q curl jq && sh /scripts/seed-dev.sh" +``` + +The seed script creates: +- Groq channel with `gpt-4o-mini` → `llama-3.1-8b-instant` model mapping +- Two API tokens: `root-key` (passthrough) and `kids-key` (kids_mode=true) + +## 5. Verify e2e policy + +Run `/dr-test` in Claude Code, or manually: + +```bash +# Should pass (200) +curl http://localhost:3000/v1/chat/completions \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"model":"llama-3.1-8b-instant","messages":[{"role":"user","content":"hi"}],"max_tokens":10}' + +# Should be blocked (400) +curl http://localhost:3000/v1/chat/completions \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"model":"llama-3.1-8b-instant","messages":[{"role":"user","content":"hi"}],"max_tokens":10}' + +# Should pass — whitelist match on gpt-4o-mini (200) +curl http://localhost:3000/v1/chat/completions \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"model":"gpt-4o-mini","messages":[{"role":"user","content":"hi"}],"max_tokens":10}' +``` + +## 6. Rebuild after a Go change + +```bash +docker compose -f docker-compose.dev.yml up -d --build new-api +``` + +## 7. View logs + +```bash +docker logs new-api-dev --tail 50 -f +``` + +## 8. Reset everything + +```bash +docker compose -f docker-compose.dev.yml down -v +``` +Wipes Postgres and Redis volumes. Next start is a fresh DB. + +## Claude Code skills + +If you're using Claude Code (the AI CLI), these slash commands are available: + +| Command | What it does | +|---------|-------------| +| `/dr-status` | Current sprint/PR/git status report | +| `/dr-test` | Runs the 3-case policy e2e test | +| `/dr-pr` | PR creation checklist | diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md new file mode 100644 index 000000000000..62db9caff4d7 --- /dev/null +++ b/docs/wiki/Home.md @@ -0,0 +1,35 @@ +# DeepRouter Wiki + +OpenAI-compatible multi-tenant LLM gateway. Fork of [QuantumNous/new-api](https://github.com/QuantumNous/new-api) (AGPL v3). + +## Quick links + +| | | +|---|---| +| **Repo** | [deeprouter-ai/deeprouter](https://github.com/deeprouter-ai/deeprouter) | +| **Smart Router** | [deeprouter-ai/smart-router](https://github.com/deeprouter-ai/smart-router) | +| **Sprint board** | Linear — Sprint 1 (5 Jun – 19 Jun 2026) | +| **Local dev** | `docker compose -f docker-compose.dev.yml up -d --build new-api` → http://localhost:3000 | + +## What DeepRouter adds on top of upstream + +| Package | What it does | +|---------|-------------| +| `internal/policy/` | Per-tenant policy decision engine. `DecisionFor(kidsMode, profile) → Decision` | +| `internal/kids/` | Kids mode hard constraints: model whitelist, metadata strip, OpenAI ZDR, child-safe system prompt | +| `internal/billing/` | HMAC-signed per-request billing webhook dispatcher (Phase 2, not yet wired) | +| `internal/smart_router_client/` | HTTP client for the smart-router sidecar | +| `relay/airbotix_policy.go` | Stitches policy enforcement into every relay handler | + +## Team + +| Handle | Role | +|--------|------| +| PW (pjwan2) | CTO / lead engineer | + +## Pages + +- [Sprint 1 Progress](Sprint-1-Progress) +- [Architecture Decisions](Architecture-Decisions) +- [Bug Log](Bug-Log) +- [Dev Setup](Dev-Setup) diff --git a/docs/wiki/Sprint-1-Progress.md b/docs/wiki/Sprint-1-Progress.md new file mode 100644 index 000000000000..404e241d3d3b --- /dev/null +++ b/docs/wiki/Sprint-1-Progress.md @@ -0,0 +1,39 @@ +# Sprint 1 Progress + +**Dates:** 5 Jun – 19 Jun 2026 +**Goal:** e2e relay working with `kids_mode` enforcement, staging deployed + +## Ticket status + +| Ticket | Title | Status | PR | Notes | +|--------|-------|--------|----|-------| +| DR-6 | `internal/billing` webhook dispatcher | ✅ Done | merged in main | Code + tests. Not wired into relay (Phase 2). | +| DR-7 | `internal/kids` hard constraints | ✅ Done | merged in main | Whitelist, ZDR, metadata strip, child-safe prompt. | +| DR-8 | `internal/policy` decision engine | ✅ Done | merged in main | `DecisionFor()` pure function + profile tests. | +| DR-9 | e2e: same endpoint, different key → different policy | 🟡 In Review | [fix/policy-before-model-mapping](https://github.com/deeprouter-ai/deeprouter/pull/new/fix/policy-before-model-mapping) | chat path fixed + verified. claude/responses/gemini handlers need same fix before merge. | +| DR-13 | Quota check RPM/TPM + staging deploy | ⏳ Not started | — | Next ticket. | + +## What was verified (DR-9 e2e) + +Three test cases against local dev stack (Groq channel, `gpt-4o-mini` → `llama-3.1-8b-instant` mapping): + +| # | Key type | Model sent | Expected | Result | +|---|----------|------------|----------|--------| +| 1 | Root (passthrough) | `llama-3.1-8b-instant` | 200 ✅ | ✅ | +| 2 | Kids key | `llama-3.1-8b-instant` (not whitelisted) | 400 ❌ blocked | ✅ | +| 3 | Kids key | `gpt-4o-mini` (whitelisted, maps to llama) | 200 ✅ | ✅ | + +## Bug found and fixed during Sprint 1 + +**Policy ordering bug** (`relay/compatible_handler.go`) + +- **Symptom:** Kids key requesting `gpt-4o-mini` was blocked even though it's on the whitelist. +- **Root cause:** `ModelMappedHelper` ran first, renaming `gpt-4o-mini` → `llama-3.1-8b-instant`. The whitelist check then saw the upstream name (not whitelisted) and blocked the request. +- **Fix:** Moved `applyAirbotixPolicy` call to before `ModelMappedHelper` so whitelist always evaluates the client-requested model name. +- **Same bug still exists in:** `claude_handler.go`, `responses_handler.go`, `gemini_handler.go` — fix pending. + +## PRs merged this sprint + +| PR | Title | Date | +|----|-------|------| +| [#21](https://github.com/deeprouter-ai/deeprouter/pull/21) | fix(default): render header wordmark as text so brand name never drops | 2026-06-07 | diff --git a/relay/claude_handler.go b/relay/claude_handler.go index 7915efb8b8c1..c4b78da1aa07 100644 --- a/relay/claude_handler.go +++ b/relay/claude_handler.go @@ -36,16 +36,18 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ return types.NewError(fmt.Errorf("failed to copy request to ClaudeRequest: %w", err), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) } + // Airbotix / DeepRouter policy: checked against the client-requested model + // name BEFORE channel model_mapping so that a kids_mode whitelist entry is + // honoured even when the channel remaps it to a different upstream name. + if rejErr := applyAirbotixPolicyToClaude(c, request); rejErr != nil { + return rejErr + } + err = helper.ModelMappedHelper(c, info, request) if err != nil { return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) } - // Airbotix / DeepRouter policy on the Anthropic-native shape. - if rejErr := applyAirbotixPolicyToClaude(c, request); rejErr != nil { - return rejErr - } - adaptor := GetAdaptor(info.ApiType) if adaptor == nil { return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) @@ -87,17 +89,17 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ request.TopP = nil request.TopK = nil } else { - // 因为BudgetTokens 必须大于1024 + // BudgetTokens must be at least 1024. if request.MaxTokens == nil || *request.MaxTokens < 1280 { request.MaxTokens = common.GetPointer[uint](1280) } - // BudgetTokens 为 max_tokens 的 80% + // Set BudgetTokens to a configured percentage of max_tokens. request.Thinking = &dto.Thinking{ Type: "enabled", BudgetTokens: common.GetPointer[int](int(float64(*request.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage)), } - // TODO: 临时处理 + // TODO: temporary workaround // https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking request.Temperature = common.GetPointer[float64](1.0) } @@ -200,7 +202,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream") if httpResp.StatusCode != http.StatusOK { newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false) - // reset status code 重置状态码 + // reset status code service.ResetStatusCode(newAPIError, statusCodeMappingStr) return newAPIError } @@ -209,7 +211,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ usage, newAPIError := adaptor.DoResponse(c, httpResp, info) //log.Printf("usage: %v", usage) if newAPIError != nil { - // reset status code 重置状态码 + // reset status code service.ResetStatusCode(newAPIError, statusCodeMappingStr) return newAPIError } diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index 4684f8c7e3a7..d198b461a981 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -41,22 +41,34 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types c.Set("chat_completion_web_search_context_size", request.WebSearchOptions.SearchContextSize) } + // Airbotix / DeepRouter policy: checked against the client-requested model + // name BEFORE channel model_mapping so that a kids_mode whitelist entry like + // "gpt-4o-mini" is honoured even when the channel remaps it to a different + // upstream model name (e.g. llama-3.1-8b-instant on Groq). + if d, ok := common.GetContextKey(c, constant.ContextKeyPolicyDecision); ok { + if decision, castOk := d.(policy.Decision); castOk { + if reject := applyAirbotixPolicy(decision, info.ChannelType, request); reject != "" { + return types.NewErrorWithStatusCode(fmt.Errorf("%s", reject), types.ErrorCodeChannelModelMappedError, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) + } + } + } + err = helper.ModelMappedHelper(c, info, request) if err != nil { return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) } includeUsage := true - // 判断用户是否需要返回使用情况 + // Determine whether the client requested usage stats in the response. if request.StreamOptions != nil { includeUsage = request.StreamOptions.IncludeUsage } - // 如果不支持StreamOptions,将StreamOptions设置为nil + // Clear StreamOptions when the channel doesn't support it or streaming is off. if !info.SupportStreamOptions || !lo.FromPtrOr(request.Stream, false) { request.StreamOptions = nil } else { - // 如果支持StreamOptions,且请求中没有设置StreamOptions,根据配置文件设置StreamOptions + // Channel supports StreamOptions and stream is on: apply ForceStreamOption config if set. if constant.ForceStreamOption { request.StreamOptions = &dto.StreamOptions{ IncludeUsage: true, @@ -66,17 +78,6 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types info.ShouldIncludeUsage = includeUsage - // Airbotix / DeepRouter policy: applied on the typed request BEFORE provider - // conversion so kid-safe constraints (model whitelist, system prompt, strip - // identifying metadata, OpenAI ZDR) propagate through any downstream adapter. - if d, ok := common.GetContextKey(c, constant.ContextKeyPolicyDecision); ok { - if decision, castOk := d.(policy.Decision); castOk { - if reject := applyAirbotixPolicy(decision, info.ChannelType, request); reject != "" { - return types.NewErrorWithStatusCode(fmt.Errorf("%s", reject), types.ErrorCodeChannelModelMappedError, http.StatusBadRequest, types.ErrOptionWithSkipRetry()) - } - } - } - adaptor := GetAdaptor(info.ApiType) if adaptor == nil { return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) @@ -126,7 +127,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types relaycommon.AppendRequestConversionFromRequest(info, convertedRequest) if info.ChannelSetting.SystemPrompt != "" { - // 如果有系统提示,则将其添加到请求中 + // Inject channel-level system prompt if configured. request, ok := convertedRequest.(*dto.GeneralOpenAIRequest) if ok { containSystemPrompt := false @@ -137,7 +138,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types } } if !containSystemPrompt { - // 如果没有系统提示,则添加系统提示 + // No system message yet: prepend one. systemMessage := dto.Message{ Role: request.GetSystemRoleName(), Content: info.ChannelSetting.SystemPrompt, @@ -145,7 +146,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types request.Messages = append([]dto.Message{systemMessage}, request.Messages...) } else if info.ChannelSetting.SystemPromptOverride { common.SetContextKey(c, constant.ContextKeySystemPromptOverride, true) - // 如果有系统提示,且允许覆盖,则拼接到前面 + // System prompt override enabled: prepend channel prompt ahead of the existing one. for i, message := range request.Messages { if message.Role == request.GetSystemRoleName() { if message.IsStringContent() { @@ -204,7 +205,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream") if httpResp.StatusCode != http.StatusOK { newApiErr := service.RelayErrorHandler(c.Request.Context(), httpResp, false) - // reset status code 重置状态码 + // reset status code service.ResetStatusCode(newApiErr, statusCodeMappingStr) return newApiErr } @@ -212,7 +213,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types usage, newApiErr := adaptor.DoResponse(c, httpResp, info) if newApiErr != nil { - // reset status code 重置状态码 + // reset status code service.ResetStatusCode(newApiErr, statusCodeMappingStr) return newApiErr } diff --git a/relay/gemini_handler.go b/relay/gemini_handler.go index 89ea19391802..b50dd40cf929 100644 --- a/relay/gemini_handler.go +++ b/relay/gemini_handler.go @@ -25,7 +25,7 @@ func isNoThinkingRequest(req *dto.GeminiChatRequest) bool { if req.GenerationConfig.ThinkingConfig != nil && req.GenerationConfig.ThinkingConfig.ThinkingBudget != nil { configBudget := req.GenerationConfig.ThinkingConfig.ThinkingBudget if configBudget != nil && *configBudget == 0 { - // 如果思考预算为 0,则认为是非思考请求 + // A thinking budget of 0 signals a non-thinking request. return true } } @@ -33,16 +33,16 @@ func isNoThinkingRequest(req *dto.GeminiChatRequest) bool { } func trimModelThinking(modelName string) string { - // 去除模型名称中的 -nothinking 后缀 + // Strip -nothinking suffix from model name. if strings.HasSuffix(modelName, "-nothinking") { return strings.TrimSuffix(modelName, "-nothinking") } - // 去除模型名称中的 -thinking 后缀 + // Strip -thinking suffix from model name. if strings.HasSuffix(modelName, "-thinking") { return strings.TrimSuffix(modelName, "-thinking") } - // 去除模型名称中的 -thinking-number + // Strip -thinking- variant. if strings.Contains(modelName, "-thinking-") { parts := strings.Split(modelName, "-thinking-") if len(parts) > 1 { @@ -65,19 +65,20 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ return types.NewError(fmt.Errorf("failed to copy request to GeminiChatRequest: %w", err), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) } - // model mapped 模型映射 + // Airbotix / DeepRouter policy: checked against the client-requested model + // name BEFORE channel model_mapping. Gemini puts the model in the URL path + // (not the request struct), so we read it from info.OriginModelName which + // is set by middleware before any mapping occurs. + if rejErr := applyAirbotixPolicyToGemini(c, info.OriginModelName, request); rejErr != nil { + return rejErr + } + + // channel model mapping err = helper.ModelMappedHelper(c, info, request) if err != nil { return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) } - // Airbotix / DeepRouter policy: model whitelist + replace SystemInstructions - // on kids_mode. Gemini has no User/Store equivalents. Model name lives on - // info (Gemini puts it in the URL path), not on the request struct. - if rejErr := applyAirbotixPolicyToGemini(c, info.UpstreamModelName, request); rejErr != nil { - return rejErr - } - if model_setting.GetGeminiSettings().ThinkingAdapterEnabled { if isNoThinkingRequest(request) { // check is thinking @@ -151,7 +152,6 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ } requestBody = common.ReaderOnly(storage) } else { - // 使用 ConvertGeminiRequest 转换请求格式 convertedRequest, err := adaptor.ConvertGeminiRequest(c, info, request) if err != nil { return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry()) @@ -189,7 +189,7 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ info.IsStream = info.IsStream || strings.HasPrefix(httpResp.Header.Get("Content-Type"), "text/event-stream") if httpResp.StatusCode != http.StatusOK { newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false) - // reset status code 重置状态码 + // reset status code service.ResetStatusCode(newAPIError, statusCodeMappingStr) return newAPIError } @@ -243,17 +243,18 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo) (newAPI } } + // Airbotix / DeepRouter policy: whitelist checked against the client-requested + // model name BEFORE channel model_mapping. Embedding payloads carry no + // user/system to mutate so only the whitelist guard is needed here. + if rejErr := checkAirbotixModelWhitelist(c, info.OriginModelName); rejErr != nil { + return rejErr + } + err = helper.ModelMappedHelper(c, info, req) if err != nil { return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) } - // Airbotix / DeepRouter policy: model whitelist on the upstream-resolved - // Gemini model. Embedding payloads carry no user/system to mutate. - if rejErr := checkAirbotixModelWhitelist(c, info.UpstreamModelName); rejErr != nil { - return rejErr - } - req.SetModelName("models/" + info.UpstreamModelName) adaptor := GetAdaptor(info.ApiType) diff --git a/relay/responses_handler.go b/relay/responses_handler.go index a3ec3d808c56..88fd2f1ed5c8 100644 --- a/relay/responses_handler.go +++ b/relay/responses_handler.go @@ -60,16 +60,18 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * return types.NewError(fmt.Errorf("failed to copy request to GeneralOpenAIRequest: %w", err), types.ErrorCodeInvalidRequest, types.ErrOptionWithSkipRetry()) } + // Airbotix / DeepRouter policy: checked against the client-requested model + // name BEFORE channel model_mapping so that a kids_mode whitelist entry is + // honoured even when the channel remaps it to a different upstream name. + if rejErr := applyAirbotixPolicyToResponses(c, info.ChannelType, request); rejErr != nil { + return rejErr + } + err = helper.ModelMappedHelper(c, info, request) if err != nil { return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry()) } - // Airbotix / DeepRouter policy on the /v1/responses shape. - if rejErr := applyAirbotixPolicyToResponses(c, info.ChannelType, request); rejErr != nil { - return rejErr - } - adaptor := GetAdaptor(info.ApiType) if adaptor == nil { return types.NewError(fmt.Errorf("invalid api type: %d", info.ApiType), types.ErrorCodeInvalidApiType, types.ErrOptionWithSkipRetry()) @@ -126,7 +128,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * if httpResp.StatusCode != http.StatusOK { newAPIError = service.RelayErrorHandler(c.Request.Context(), httpResp, false) - // reset status code 重置状态码 + // reset status code service.ResetStatusCode(newAPIError, statusCodeMappingStr) return newAPIError } @@ -134,7 +136,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError * usage, newAPIError := adaptor.DoResponse(c, httpResp, info) if newAPIError != nil { - // reset status code 重置状态码 + // reset status code service.ResetStatusCode(newAPIError, statusCodeMappingStr) return newAPIError } diff --git a/router/relay-router.go b/router/relay-router.go index 96365dbccdab..30c362a30afc 100644 --- a/router/relay-router.go +++ b/router/relay-router.go @@ -191,6 +191,7 @@ func SetRelayRouter(router *gin.Engine) { relayGeminiRouter.Use(middleware.RouteTag("relay")) relayGeminiRouter.Use(middleware.SystemPerformanceCheck()) relayGeminiRouter.Use(middleware.TokenAuth()) + relayGeminiRouter.Use(middleware.AirbotixPolicy()) relayGeminiRouter.Use(middleware.ModelRequestRateLimit()) relayGeminiRouter.Use(middleware.Distribute()) {