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
32 changes: 32 additions & 0 deletions .claude/commands/dr-pr.md
Original file line number Diff line number Diff line change
@@ -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 <branch-name>
```

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 <noreply@anthropic.com>`
- After PR is created, share the URL with the user.
39 changes: 39 additions & 0 deletions .claude/commands/dr-status.md
Original file line number Diff line number Diff line change
@@ -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.
54 changes: 54 additions & 0 deletions .claude/commands/dr-test.md
Original file line number Diff line number Diff line change
@@ -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`).
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,6 @@ docs
.eslintcache
.gocache
/web/node_modules
web/default/node_modules
web/classic/node_modules
!THIRD-PARTY-LICENSES.md
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 28 additions & 6 deletions AIRBOTIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
76 changes: 76 additions & 0 deletions docs/wiki/Architecture-Decisions.md
Original file line number Diff line number Diff line change
@@ -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.
81 changes: 81 additions & 0 deletions docs/wiki/Bug-Log.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading