Skip to content

Fix/dr9 policy before model mapping - #22

Merged
Chris-Wang merged 6 commits into
mainfrom
fix/dr9-policy-before-model-mapping
Jun 9, 2026
Merged

Fix/dr9 policy before model mapping#22
Chris-Wang merged 6 commits into
mainfrom
fix/dr9-policy-before-model-mapping

Conversation

@pjwan2

@pjwan2 pjwan2 commented Jun 7, 2026

Copy link
Copy Markdown

Summary

Fixes DR-9: kids_mode whitelist was checked after ModelMappedHelper rewrote request.Model to the upstream channel name, causing valid whitelisted models (e.g. gpt-4o-mini) to be incorrectly rejected when the channel remapped them (e.g. to llama-3.1-8b-instant on Groq).

Root Cause

applyAirbotixPolicy* was called after helper.ModelMappedHelper, but ModelMappedHelper overwrites request.Model with the upstream name. The whitelist check then compared against the wrong name and always failed.

Fix

Moved all policy checks before ModelMappedHelper across all 5 relay handlers:

File Function Change
relay/compatible_handler.go TextHelper Policy before ModelMappedHelper
relay/claude_handler.go ClaudeHelper Policy before ModelMappedHelper
relay/responses_handler.go ResponsesHelper Policy before ModelMappedHelper
relay/gemini_handler.go GeminiHelper Use info.OriginModelName (URL path model), policy before mapping
relay/gemini_handler.go GeminiEmbeddingHandler Same fix for embedding path

Gemini note: GeminiChatRequest carries no Model field (model is in the URL path). Fixed by reading info.OriginModelName (set by middleware before any mapping) instead of info.UpstreamModelName (set by ModelMappedHelper).

Also Included

  • All Chinese code comments translated to English
  • Team wiki: docs/wiki/ (Architecture Decisions ADR-001–005, Bug Log, Sprint Progress, Dev Setup)
  • Claude Code skills: .claude/commands/ (dr-status, dr-test, dr-pr)
  • .dockerignore: exclude web/*/node_modules
  • AIRBOTIX.md: Sprint 1 status updated

Testing

Tested on dev stack after rebuilding container with the DR-9 fix.

Setup:

  • Server: http://localhost:3000 (Docker dev compose)
  • Channel: Groq, model list llama-3.1-8b-instant + gpt-4o-mini
  • Model mapping: {"gpt-4o-mini": "llama-3.1-8b-instant"} ← key to reproducing the bug
  • ROOT_KEY: user with kids_mode=false (no policy)
  • KIDS_KEY: user with kids_mode=true, policy_profile=kid-safe

Suite 1 of 2 — Handler Coverage (32 tests)

Tests each of the 5 relay handlers independently.
The ★ regression tests are the ones that directly verify the DR-9 fix.


§1 — Policy Block: kids + llama (not whitelisted) → must return 400

llama-3.1-8b-instant is in the Groq channel but NOT on the kids whitelist.
Policy must reject it with 400 before any upstream call is made, across all 5 handlers.

Test Request Expected Actual
1a POST /v1/chat/completions · kids key · model=llama-3.1-8b-instant 400 400
1b POST /v1/responses · kids key · model=llama-3.1-8b-instant 400 400
1c POST /v1/messages · kids key · model=llama-3.1-8b-instant 400 400
1d POST /v1beta/models/llama-3.1-8b-instant:generateContent · kids key 400 400
1e POST /v1beta/models/llama-3.1-8b-instant:embedContent · kids key 400 400

Body on all 5: {"error":{"message":"model_not_eligible_for_kids_mode: llama-3.1-8b-instant","type":"new_api_error","code":"channel:model_mapped_error"}}


§2 ★ Regression: kids + gpt-4o-mini (whitelisted) that maps to llama → must return 200

This is the core DR-9 regression test.

gpt-4o-mini is on the kids whitelist. But the Groq channel maps it to llama-3.1-8b-instant upstream.

  • Pre-fix behaviour: policy ran AFTER ModelMappedHelper → saw llama → returned 400 ❌
  • Post-fix behaviour: policy runs BEFORE ModelMappedHelper → sees gpt-4o-mini → allows → 200 ✅
Test Request Expected Actual
2a ★ POST /v1/chat/completions · kids key · model=gpt-4o-mini 200 200
2b ★ POST /v1/responses · kids key · model=gpt-4o-mini 200 200
2c ★ POST /v1/messages · kids key · model=gpt-4o-mini 200 200
2d ★ POST /v1beta/models/gpt-4o-mini:generateContent · kids key 200 200
2e ★ POST /v1beta/models/gpt-4o-mini:embedContent · kids key body must NOT contain model_not_eligible_for_kids_mode confirmed absent

Note on 2e: Gemini embedContent uses a different request format than OpenAI embeddings.
The upstream (Groq) returns a format error (400), but NOT from our policy check.
Assertion checks body content, not status code, to distinguish the two 400s.


§3 — Passthrough: root key + llama → must return 200 (no policy applied)

Root user has kids_mode=false. Policy middleware runs but issues a passthrough decision.
Non-whitelisted model must succeed.

Test Request Expected Actual
3a POST /v1/chat/completions · root key · model=llama-3.1-8b-instant 200 200
3b POST /v1/responses · root key · model=llama-3.1-8b-instant 200 200
3c POST /v1/messages · root key · model=llama-3.1-8b-instant 200 200
3d POST /v1beta generateContent · root key · model=llama-3.1-8b-instant 200 200

§4 — Whitelist Boundary: whitelisted models without dev channels → 503 from routing, NOT 400 from policy

These models ARE on the whitelist. No Groq channel configured for them in dev → routing fails with 503.
The key assertion: if policy were incorrectly blocking them, we'd see 400. We must NOT see 400.

Test Request Assertion Actual
4a kids key · gpt-4o NOT 400 503 (routing: no channel)
4b kids key · claude-3-5-haiku NOT 400 503 (routing: no channel)
4c kids key · claude-3-5-sonnet NOT 400 503 (routing: no channel)

§5 — Error Body Validation: 400 must carry the right fields

Policy-blocked responses must have a structured error body that clients can parse.

Test Assertion Result
5a /v1/chat/completions 400 body contains model_not_eligible_for_kids_mode found
5b /v1/chat/completions 400 body contains blocked model name llama-3.1-8b-instant found
5c /v1/responses 400 body contains model_not_eligible_for_kids_mode found
5d /v1/messages 400 body contains model_not_eligible_for_kids_mode found

§6 — Tenant Isolation: same endpoint, different key → different result

Guards against policy state leaking between tenants in the same process.

Test Request Expected Actual
6a /v1/chat/completions · root · llama 200 200
6b /v1/chat/completions · kids · llama (same endpoint as 6a) 400 400
6c /v1/chat/completions · root · gpt-4o-mini 200 200
6d /v1/chat/completions · kids · gpt-4o-mini (same endpoint as 6c) 200 200
6e root request after kids blocked request 200 200
6f kids request after root passthrough request 400 400

§7 — Edge Cases

Test Request Expected Actual
7a kids · missing model field 400 (validation) 400
7b kids · model: "" (empty string) 400 400
7c root · missing messages field 500 (upstream rejects) 500
7d kids · gpt-4o-mini-2024-07-18 (HasPrefix match → whitelisted) NOT 400 503 (routing)
7e kids · claude-3-opus (not whitelisted, no channel) 503 (routing fails before policy) 503

7e: claude-3-opus isn't on the whitelist AND has no channel. Because there's no channel, the request
never reaches policy — routing fails first with 503. This is expected and correct.

Suite 1 total: 32/32 PASS


Suite 2 of 2 — Behavioral / Property Tests (56 tests)

A second independent suite testing DR-9 from the angle of system properties rather than per-handler.


§1 — Streaming: policy must enforce synchronously even with stream: true

A blocked model must return a JSON 400, never start an SSE stream.
An allowed model must return 200 with proper SSE data: lines.

Test
1a kids + llama + stream:true → 400 (policy blocks before stream opens)
1b that 400 body is JSON model_not_eligible_for_kids_mode (not SSE data)
1c kids + gpt-4o-mini + stream:true → 200 (allowed, stream begins)
1d kids stream 200 response body contains SSE data: markers
1e root + llama + stream:true → 200 (passthrough)
1f root stream 200 response body contains SSE data: markers

6/6 PASS


§2 — Complete Whitelist: all 8 EligibleModels must not be policy-blocked

Every model in internal/kids/kids.go EligibleModels tested individually.
Assertion: none return 400 from policy (may return 503 from routing — expected).

Model Result
gpt-4o-mini ✅ not policy-blocked (200 — has channel)
gpt-4o ✅ not policy-blocked (503 — no channel in dev)
gpt-image-2 ✅ not policy-blocked (503 — no channel in dev)
gpt-image-1 ✅ not policy-blocked (503 — no channel in dev)
claude-3-5-haiku ✅ not policy-blocked (503 — no channel in dev)
claude-3-5-sonnet ✅ not policy-blocked (503 — no channel in dev)
flux-schnell ✅ not policy-blocked (503 — no channel in dev)
flux-1.1-pro ✅ not policy-blocked (503 — no channel in dev)

8/8 PASS


§3 — HasPrefix Matching: versioned variants inherit whitelist, boundary models excluded

IsModelEligible uses strings.HasPrefixclaude-3-5-haiku-20241022 inherits from claude-3-5-haiku.

Should be allowed (prefix matches whitelist entry):

Variant Base entry Result
gpt-4o-mini-2024-07-18 gpt-4o-mini ✅ not policy-blocked
gpt-4o-mini-audio-preview gpt-4o-mini ✅ not policy-blocked
gpt-4o-2024-11-20 gpt-4o ✅ not policy-blocked
claude-3-5-haiku-20241022 claude-3-5-haiku ✅ not policy-blocked
claude-3-5-sonnet-20241022 claude-3-5-sonnet ✅ not policy-blocked
flux-1.1-pro-ultra flux-1.1-pro ✅ not policy-blocked

Boundary (similar-looking but NOT a prefix match — must NOT be whitelisted):

Model Why not matched Result
gpt-4-turbo gpt-4 not in whitelist (only gpt-4o) ✅ 503 from routing, body: No available channel
gpt-3.5-turbo not a prefix of any whitelist entry ✅ 503 from routing
claude-3-haiku claude-3-haiku ≠ prefix of claude-3-5-haiku ✅ 503 from routing
claude-3-opus not a prefix of any whitelist entry ✅ 503 from routing

10/10 PASS


§4 — Error Response Schema: 400 body must have correct JSON structure

Test Assertion Result
4a kids + llama → exactly HTTP 400
4b body contains "type":"new_api_error"
4c body contains "code":"channel:model_mapped_error"
4d body contains model name llama-3.1-8b-instant in message
4e body contains model_not_eligible_for_kids_mode in message
4f /v1/messages (Claude shape) 400 body also has model_not_eligible_for_kids_mode

6/6 PASS


§5 — Response Quality: allowed requests return real LLM output

Test Assertion Result
5a kids + gpt-4o-mini → HTTP 200
5b 200 body contains "choices" (OpenAI completion field)
5c 200 body contains "model" field
5d 200 body does NOT contain top-level "error" key
5e kids /v1/messages (Claude) 200 body contains "content" array

5/5 PASS


§6 — Policy Stability: identical input must give identical output (no state accumulation)

Same request sent 3 consecutive times. All results must match.

Run kids + llama kids + gpt-4o-mini root + llama
#1 400 ✅ 200 ✅ 200 ✅
#2 400 ✅ 200 ✅ 200 ✅
#3 400 ✅ 200 ✅ 200 ✅

9/9 PASS


§7 — Interleave Isolation: alternating root/kids requests, no state leakage

Policy decision must depend only on the key in the current request.

Step Key Model Expected Actual
7.1 root llama 200 200
7.2 kids llama 400 400
7.3 root llama 200 (after kids block) 200
7.4 kids gpt-4o-mini 200 (kids CAN use whitelisted) 200
7.5 root llama 200 (after kids allow) 200
7.6 kids llama 400 (still enforced) 400
7.7 kids gpt-4o-mini 200 (whitelist stable) 200
7.8 root llama 200 (root never affected) 200

8/8 PASS


§8 — Auth Boundary: unauthenticated requests rejected before policy runs

Test Result
8a No Authorization header → not 200 ✅ 401
8b Garbage Bearer token → not 200 ✅ 401
8c Empty Bearer value → not 200 ✅ 401
8d Auth-rejected body does NOT mention model_not_eligible_for_kids_mode (policy never ran) ✅ confirmed absent

4/4 PASS


Final Totals

Suite Tests Passed Failed
Suite 1 — handler coverage 32 32 0
Suite 2 — behavioral/property 56 56 0
Total 88 88 0

All 88 tests pass. Safe to merge. ✅

pjwan2 and others added 5 commits June 7, 2026 13:31
Whitelist entries like "gpt-4o-mini" were being blocked for kids keys
because ModelMappedHelper ran first and renamed the model to the upstream
name (e.g. llama-3.1-8b-instant), which is not on the whitelist.

Moving the Airbotix policy check above ModelMappedHelper means the
decision always uses the client-requested model name, so the whitelist
match works regardless of how the channel remaps the model downstream.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ponses/gemini handlers

Same ordering bug as fixed in compatible_handler.go — applyAirbotixPolicy*
was called after ModelMappedHelper, which rewrites request.Model to the
upstream channel name before the whitelist check runs.

Fix: move each applyAirbotixPolicy* call above ModelMappedHelper so the
whitelist always evaluates the client-requested model name.

Note for gemini_handler.go: GeminiChatRequest carries no Model field (Gemini
puts the model in the URL path). Using info.OriginModelName, which is set by
middleware before any mapping, gives the correct pre-mapping name.

Closes DR-9.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Same ordering bug as the chat handlers — checkAirbotixModelWhitelist was
called with info.UpstreamModelName (post-mapping) instead of the original
client-requested model name. Fix moves the check above ModelMappedHelper
and switches to info.OriginModelName, consistent with GeminiHelper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All comments in the four relay handler files are now English-only,
consistent with the rest of the codebase.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
docs/wiki/ — GitHub Wiki source for team reference:
  Home, Sprint-1-Progress, Architecture-Decisions, Bug-Log, Dev-Setup

.claude/commands/ — Claude Code slash commands for dev workflow:
  /dr-status  sprint progress report
  /dr-test    e2e policy verification (DR-9 test suite)
  /dr-pr      PR creation checklist

.gitignore — expose .claude/commands/ for team sharing; ignore seed outputs
.dockerignore — exclude web/*/node_modules (cuts build context ~1.5GB→40MB)
AIRBOTIX.md — update Sprint 1 status + known-bug log

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@pjwan2
pjwan2 requested a review from Chris-Wang June 7, 2026 04:01
relayGeminiRouter (/v1beta) was missing middleware.AirbotixPolicy(),
so ContextKeyPolicyDecision was never set in the gin context for Gemini
requests. applyAirbotixPolicyToGemini reads that key and returns nil
(allow-all) when it is absent — meaning kids_mode enforcement was
silently skipped for all /v1beta routes.

Fix: insert AirbotixPolicy() between TokenAuth and ModelRequestRateLimit
in relayGeminiRouter, matching the chain already present in relayV1Router.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@Chris-Wang Chris-Wang left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM

@Chris-Wang
Chris-Wang merged commit 45f05f2 into main Jun 9, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants