เมื่อ shared gateway เจอ client ที่ส่งของไม่ครบ: บทเรียน debug 400 (2013) ข้าม 3 ระบบ
สารบัญ
- TL;DR
- อาการ: 400 ทุก 2-3 วินาที
- Phase 1: ฝั่งไหนผิด?
- Phase 2: ขุด source ของ qwen CLI
- Phase 3: ทำไม MiniMax ถึง strict?
- Phase 4: ทางเลือกในการแก้
- Option A — Patch qwen CLI source
- Option B —
--exclude-toolsflag - Option C —
tools.codeModeOnly: true - Option D — LiteLLM gateway callback ✅
- Implementation: 91 บรรทัด
- Phase 5: ทำไม PR #11842 ไม่ช่วย
- Community context
- ทำไมไม่ PR upstream?
- Lesson ที่ได้
- 1. "ฝั่งที่ผิด" ไม่ใช่ "ฝั่งที่ควรแก้" เสมอ
- 2. "ปลายทาง" ไม่ได้หมายความว่า "ทุกคนจะมาทางนี้"
- 3. Shared infra = ทุก recreate มีค่าใช้จ่าย
- 4. อย่า contribute upstream เพราะ "น่าสนใจ"
- 5. Mock data ที่ "ดูสมจริง" = อันตราย
- Appendix: ไฟล์ที่เกี่ยวข้อง
- สรุป
- นิยามที่ติดตัว: nice-to-have vs need-to-have
- อ้างอิง
ตอนสามทุ่มวันจันทร์ LiteLLM log เริ่มเต็มไปด้วย 400 ทุก 2-3 วินาที — request_id วนลูป, model group M3-CODE-256k, Available Model Group Fallbacks=None. ผมไม่รู้ว่ามันคือ harness ที่เพิ่งอัปเดตเมื่อวาน หรือ gateway ที่ pin version ไว้เมื่อเดือนก่อน
นี่คือ investigation log เต็มของ bug ที่ทำให้ผม recreate container สามรอบในคืนเดียว, post บน GitHub, และค้นพบว่าทางออกที่ดีที่สุดคือ 91 บรรทัดที่ไม่มีใครเขียนมาก่อน
TL;DR
qwen CLI v0.23.x เริ่ม strip parameters field ออกจาก tool declarations (PR #11431) — OpenAI และ vllm ยอมรับ, MiniMax reject ทันทีด้วย 400 "function parameters is empty (2013)". แก้ที่ harness (qwen CLI) ไม่ได้เพราะ chunk file regenerate ทุก update — เลือกแก้ที่ LiteLLM gateway แทน ด้วย CustomLogger callback ที่ fill empty schema ก่อน forward. ไม่ PR upstream เพราะ working solution มีอยู่แล้ว, ROI ต่ำ, และมี feature ที่ดีกว่า (tools.codeModeOnly) กำลังจะมาในอนาคต
อาการ: 400 ทุก 2-3 วินาที
ตอนนั้นผมใช้ qwen CLI ผ่าน LiteLLM proxy ปกติ — เพิ่ง pin LiteLLM image เป็น v1.99.0 stable, เพิ่งอัปเดต qwen CLI เป็น v0.23.4 (ผ่าน npm overlay ไม่ใช่ package.json) ทุกอย่างน่าจะเรียบร้อย
จนกระทั่ง log เริ่มแสดง:
[API Error: 400 litellm.BadRequestError: MinimaxException - {"type":"error","error":{"type":"bad_request_error","message":"invalid params, function parameters is empty (2013)","http_code":"400"},"request_id":"06f85269d7ba61c46627c43142c7c458"}. Received Model Group=M3-CODE-256k
Available Model Group Fallbacks=None]
request_id วนลูป, source IP เดิม, model เดิม, error code เดิม — 2013 คืออะไรไม่มีใน qwen source code, ไม่มีใน LiteLLM source code, ผมก็ไม่เคยเห็น
Phase 1: ฝั่งไหนผิด?
คำถามแรกที่ต้องตอบก่อนแก้: bug อยู่ที่ harness (qwen CLI) หรือ gateway (LiteLLM)?
Test ที่ 1 — wire-level curl ไป LiteLLM:
curl -X POST http://litellm-gateway.lan:4000/v1/chat/completions \
-H "Authorization: Bearer $KEY" \
-d '{
"model": "M3-CODE-256k",
"messages": [{"role":"user","content":"hi"}],
"tools": [{"type":"function","function":{"name":"list_agents"}}]
}'
ผล: 400 (2013) ทันที. tools array ที่ไม่มี parameters field → MiniMax reject
Test ที่ 2 — เพิ่ม parameters:
-d '{
...,
"tools": [{"type":"function","function":{"name":"list_agents","parameters":{"type":"object","properties":{}}}}]
}'
ผล: 200 OK
สรุป Phase 1: LiteLLM pass-through ตรงๆ, MiniMax strict validation — ฝั่งที่ "สร้าง payload" คือ qwen CLI
Phase 2: ขุด source ของ qwen CLI
ผมต้องหาว่า qwen CLI ส่ง tools ออกมายังไง. ปัญหาคือ qwen CLI v0.23.x ใช้ bundled chunks ที่อยู่ใน ~/.local/lib/qwen-code/lib/chunks/:
$ grep -rln 'tools' ~/.local/lib/qwen-code/lib/chunks/ | head
chunk-SROLVJFY.js # ← suspect
chunk-RHTNPDGD.js # contains MiniMaxOpenAICompatibleProvider
เปิดดูที่ line 312:
if (canValidateLocally && declaresEmptyArgumentList && /* ... */) {
parameters = void 0; // ← นี่แหละ
}
parameters = void 0 ใน JavaScript = undefined, และ JSON.stringify จะ drop undefined fields. ผลคือ qwen CLI ไม่ได้ส่ง parameters: {} — ส่ง ไม่มี key นี้เลย
Timeline: PR #11431 (merged 2026-09-09) เพิ่มบรรทัดนี้, ship ใน v0.23.2, fix ปัญหา 2 ตัว:
- llama.cpp ไม่สามารถ compile grammar over empty properties
- LM Studio (strict OpenAI validator) reject
{"type":"object"}ที่ไม่มี properties
แต่ทำลาย use case ที่ 3: MiniMax
Phase 3: ทำไม MiniMax ถึง strict?
ตอนแรกผมคิดว่า MiniMax "ผิด" — แต่พอเข้าใจ OpenAI spec จริงๆ:
The
parametersfield of a function declaration is optional in the OpenAI chat-completions schema
แต่ "optional" ในทาง OpenAI = "ถ้าไม่มี ให้ถือว่า function ไม่มี args" — ไม่ได้หมายความว่า "ต้องส่ง empty object". MiniMax เลือก implement แบบ strict (ต้องมี field), OpenAI + vllm เลือก lenient (ไม่ต้องมีก็ได้)
qwen team เองก็รู้ — triage comment บน issue #11834:
Backends disagree about what they accept, and no single wire shape satisfies all of them: MiniMax rejects a missing
parameters(2013), llama.cpp cannot compile over empty properties, strict OpenAI validators rejectparameters: {"type":"object"}with no properties
นี่ไม่ใช่ bug ของฝ่ายเดียว — เป็น spec ambiguity ที่แต่ละ backend ตีความต่างกัน
Phase 4: ทางเลือกในการแก้
ตอนนี้ผมรู้แล้วว่า bug อยู่ที่ qwen CLI, แต่จะแก้ฝั่งไหนดี?
Option A — Patch qwen CLI source
sed -i 's/parameters = void 0/parameters = {type:"object",properties:{}}/g' \
~/.local/lib/qwen-code/lib/chunks/chunk-SROLVJFY.js
ข้อดี: fix ที่ root cause
ข้อเสีย: chunk file regenerate ทุกครั้งที่ update. ผม patch ไปก็หายเมื่อ qwen /update ทำงาน
Option B — --exclude-tools flag
qwen --exclude-tools list_agents --exclude-tools cron_list
ข้อดี: official workaround, ไม่แตะ source
ข้อเสีย: ต้องเพิ่ม flag ทุก invocation. และ tools.exclude ใน settings.json (legacy) ไม่ remove tool จาก registry — แค่ block execution. ผมลองแล้ว 400 ยังออก
Option C — tools.codeModeOnly: true
feature ใหม่ใน qwen-code (issue #10377, MVP) — เปลี่ยน model-facing tool surface ทั้งหมดให้เหลือ 1 tool (exec) ที่รัน JavaScript ใน QuickJS WASM sandbox
ข้อดี: fix ที่ root cause โดยไม่ส่ง empty schema ข้อเสีย: model ใช้ tool ผ่าน JavaScript — workflow เปลี่ยน, ต้อง verify performance, ยังเป็น MVP
Option D — LiteLLM gateway callback ✅
CustomLogger subclass + async_pre_call_hook ที่ fill empty parameters ก่อน forward ไป MiniMax
ข้อดี:
- fix ครั้งเดียวครอบคลุมทุก client (qwen, Claude Code, custom scripts)
- survive qwen upgrade เพราะ volume mount
- cost ใกล้ 0 (10 bytes per tool)
- ไม่กระทบ vllm routes (vllm tolerate empty schema อยู่แล้ว)
ข้อเสีย:
- เพิ่ม logic ใน gateway (shared infra = ต้อง cautious)
- ถ้า backend ที่ sensitive ต่อ empty schema (llama.cpp) มาใช้ = break
Decision: D — เพราะ gateway = shared infra, fix ที่นี่ครั้งเดียวจบ
Implementation: 91 บรรทัด
โครงสร้าง callback:
class ToolParamFixHandler(CustomLogger):
async def async_pre_call_hook(
self, user_api_key_dict, cache, data, call_type
):
tools = data.get("tools")
if not tools:
return data
for tool in tools:
if tool.get("type") != "function":
continue
fn = tool.get("function")
params = fn.get("parameters")
if not params: # missing OR {} → fill
fn["parameters"] = {
"type": "object",
"properties": {},
"additionalProperties": False,
}
return data
proxy_handler_instance = ToolParamFixHandler()
3 จุดที่ต้อง wire:
- mount file เข้า container ผ่าน
docker-compose.yaml - register ใน
config.yaml→litellm_settings.callbacks - instance ต้องเป็น module-level (
proxy_handler_instance: Final = ...)
Pitfall ที่เจอ: callback path format. แรกผมใส่ /app/callbacks/tool_param_fix.py แต่ LiteLLM get_instance_fn() split string by . — เลยเข้าใจว่า .py เป็น attribute. ต้องเปลี่ยนเป็น /app/callbacks/tool_param_fix.proxy_handler_instance
Verification matrix:
| Payload | MiniMax | vllm |
|---|---|---|
parameters: {} | ❌ 400 | ✅ |
parameters: {type:object, properties:{}} | ✅ | ✅ |
parameters: {type:object, properties:{}, additionalProperties:false} | ✅ | ✅ |
missing parameters field | ❌ 400 → fixed by callback | ✅ |
Phase 5: ทำไม PR #11842 ไม่ช่วย
หลัง deploy ผมไปดู upstream อีกที — พบว่า qwen team merged PR #11842 ใน v0.24.0 (2026-09-16). ผมอัปเดตแล้วลอง — 400 ยังออก
อ่าน code:
const MINIMAX_KNOWN_HOSTS = ['api.minimaxi.com', 'api.minimax.io'];
const MINIMAX_HOST_SUFFIXES = ['.minimaxi.com', '.minimax.io'];
static isMiniMaxProvider(config) {
const hostname = new URL(config.baseUrl).hostname;
if (MINIMAX_KNOWN_HOSTS.includes(hostname)) return true;
return MINIMAX_HOST_SUFFIXES.some(s => hostname.endsWith(s));
}
qwen detect "MiniMax" ด้วย hostname matching — ต้อง baseUrl ลงท้ายด้วย minimaxi.com. แต่ผมตั้ง baseUrl ของ qwen = http://10.0.0.155:4000 (LiteLLM proxy) → hostname = 10.0.0.155 → ไม่ match → opt-out ไม่ trigger → parameters ยังถูก strip
PR #11842 fix สำหรับ direct MiniMax users, ไม่ใช่ proxy users อย่างเรา
Community context
ผมค้นหาว่ามีใครเจอเหมือนกันไหม:
คนที่เจอเหมือนกัน:
- tontinton/maki #940 (closed, fix merged) — MiniMax + Kimi ผ่าน Bifrost gateway. ทีม maki ใช้ pattern เดียวกับผม: "Default empty/missing input schemas to
{type:object, properties:{}}" — approach ที่ apply แล้ว work - earendil-works/pi #8839 — anthropic-messages provider fails
- maximhq/bifrost #6153 — Anthropic-compat providers inherit server tools
คนที่เกี่ยวข้อง:
- LiteLLM #35685 (open) — Vertex/Gemini variant ของ bug เดียวกัน.
_map_function()silently drops tool parameters เมื่อ key เป็นinput_schemaแทนparameters. Bug spans v1.92 - v1.96+ - qwen-code #11834, #11905, #11956 — ต้นเรื่องทั้งหมด
ไม่มี LiteLLM issue สำหรับ 2013 — เราเป็น case แรกที่ report ผ่าน LiteLLM path
ทำไมไม่ PR upstream?
ผมพิจารณา 4 ทาง:
| Option | Effort | Outcome |
|---|---|---|
| PR ให้ LiteLLM (เพิ่ม built-in flag) | 1+ วัน | ไม่แน่นอน — maintainer เข้มงวด |
| Comment บน qwen #11956 | 5 นาที | on-topic, cross-reference ได้ |
| Comment บน LiteLLM #35685 | 5 นาที | bridge community patterns |
| ทำ skill บันทึก procedure | 10 นาที | reusable สำหรับครั้งหน้า |
Decision: ไม่ทำ upstream PR
เหตุผลตรงๆ:
- Patch เราทำงานแล้ว — ไม่มี pressure จาก upstream, ไม่มี deadline, ไม่มี user อื่นรอ
- ROI ต่ำ — high effort (1+ วัน implement + review + iterate), uncertain outcome (rejection risk สูง)
- Over-engineering trap — ตื่นเต้นกับ contribute แต่ลืมคิดว่าเราไม่ได้มีหน้าที่ fix upstream
- Better solution กำลังมา —
tools.codeModeOnly(issue #10377) จะแก้ที่ root cause ใน qwen-code เอง
สิ่งที่ควรทำ: cross-reference ใน issues ที่มีอยู่ (lite effort, high signal)
Lesson ที่ได้
1. "ฝั่งที่ผิด" ไม่ใช่ "ฝั่งที่ควรแก้" เสมอ
qwen CLI สร้าง payload ผิด, แต่การ fix ที่ gateway ให้ coverage ที่กว้างกว่า, survive upgrade, และครอบคลุม clients อื่นๆ ที่อาจเจอ bug เดียวกันในอนาคต
2. "ปลายทาง" ไม่ได้หมายความว่า "ทุกคนจะมาทางนี้"
qwen PR #11842 detect MiniMax ด้วย hostname — ใช้ได้กับ direct calls เท่านั้น. ถ้า architecture ของคุณมี proxy, upstream fix อาจไม่ช่วย
3. Shared infra = ทุก recreate มีค่าใช้จ่าย
ผม recreate LiteLLM container 3 ครั้งในคืนเดียว — แต่ละครั้งคือ downtime สั้นๆ ที่กระทบทุก client รวมถึงตัวผมเอง. ถ้าผม break gateway ขณะ debug, ผมก็คุยกับตัวเองไม่ได้ — เพราะ model ของผมก็รันผ่าน gateway
4. อย่า contribute upstream เพราะ "น่าสนใจ"
working solution มีอยู่ → upstream PR เป็น nice-to-have, ไม่ใช่ need-to-have. ลงทุนเวลากับงานที่กระทบเราโดยตรงก่อน
5. Mock data ที่ "ดูสมจริง" = อันตราย
ระหว่าง debug ผมเกือบเขียน fake benchmark เพื่อ "claim" ว่า LiteLLM patch ไม่กระทบ latency — แต่ไม่ได้วัดจริง. กฎของผมคือถ้ายังไม่ได้ measure, mark ว่า (ยังไม่ได้ทดสอบ) หรือไม่เขียนเลย
Appendix: ไฟล์ที่เกี่ยวข้อง
การแก้อยู่ที่ gateway — รับทุก request ที่ผ่าน, fill empty parameters ถ้าจำเป็น, แล้ว forward. สำหรับ vllm (ที่ tolerate empty schema อยู่แล้ว) overhead = 0
สรุป
หลังจาก recreate container 3 รอบในคืนเดียว ผมได้ข้อสรุปที่ติดตัวมาจนถึงทุกวันนี้:
- ฝั่งที่ผิด ≠ ฝั่งที่ควรแก้ — bug อยู่ที่ harness แต่ fix ที่ gateway ให้ coverage ที่กว้างกว่า
- Chunked JS = fragile patch surface — ใช้ callback แทน sed source
- Hostname detection ใน upstream = จำกัด scope — proxy users ไม่ได้รับ benefit
- ไม่ต้อง PR ทุกเรื่อง — working solution + low ROI = เก็บ energy ไว้ทำอย่างอื่น
Patch ตอนนี้ live ที่ /home/apps/litellm-gateway/callbacks/tool_param_fix.py — ทุก client ที่ผ่าน gateway ได้ benefit, qwen ไม่ต้องแตะ, MiniMax ก็ไม่ complain แล้ว
นิยามที่ติดตัว: nice-to-have vs need-to-have
ระหว่าง debug ผมเกือบ contribute upstream เพราะ "รู้สึกว่าควร" — แต่พอตั้งคำถามจริงๆ กลับพบว่าเป็น nice-to-have, ไม่ใช่ need-to-have
ต่างกันตรงนี้:
- Need-to-have — ไม่ทำ = ระบบเราไม่ทำงาน, มี user อื่นรอ, มี SLA/deadline
- Nice-to-have — ทำ = community ได้, ไม่ทำ = ไม่มีใครเดือดร้อน
Filter ง่ายๆ ก่อนตัดสินใจ contribute upstream:
| คำถาม | need-to-have | nice-to-have |
|---|---|---|
| patch เรา break บ่อยไหม? | break ทุก release = ต้อง contribute | stable 6 เดือน+ = ไม่จำเป็น |
| มี SLA จาก upstream ไหม? | มี = contribute | ไม่มี = skip |
| ROI ของ effort? | > 1 วัน implement คุ้ม return | < return = skip |
| "contribute" feel obligation หรือ choice? | obligation = ทำ | choice = reframe ก่อน |
คำเดียวที่ติดลม — "nice-to-have, not need-to-have" — กลายเป็น permission ให้เราหยุดโดยไม่รู้สึกผิด และเป็น filter ให้เราเลือกงานที่กระทบเราตรงๆ ก่อน
ถ้า upstream fix ออกมาเมื่อไหร่ — patch เราก็ redundant ได้ ลบทิ้งได้ ไม่เสียหาย
อ้างอิง
- qwen-code #11834 — Original bug report
- qwen-code PR #11842 — Per-provider fix (hostname-based)
- qwen-code #11956 — Variant:
nullparameters - qwen-code #10377 — codeModeOnly feature
- LiteLLM #35685 — Similar pattern (Vertex/Gemini silent drop)
- tontinton/maki #940 — Same problem, fix ที่ apply แล้ว
- Qwen3.8-Flash-Next blog — Architecture preview of Qwen4
- LiteLLM CustomLogger docs — async_pre_call_hook reference
เนื้อหานี้มีประโยชน์ไหม? ช่วยสนับสนุนค่ากาแฟให้ผู้เขียนสักแก้ว
Buy Me a Coffee