Skip to main content

เมื่อ shared gateway เจอ client ที่ส่งของไม่ครบ: บทเรียน debug 400 (2013) ข้าม 3 ระบบ

· 11 min read

ตอนสามทุ่มวันจันทร์ 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 parameters field 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 reject parameters: {"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:

  1. mount file เข้า container ผ่าน docker-compose.yaml
  2. register ใน config.yaml → litellm_settings.callbacks
  3. 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:

PayloadMiniMaxvllm
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 ทาง:

OptionEffortOutcome
PR ให้ LiteLLM (เพิ่ม built-in flag)1+ วันไม่แน่นอน — maintainer เข้มงวด
Comment บน qwen #119565 นาทีon-topic, cross-reference ได้
Comment บน LiteLLM #356855 นาทีbridge community patterns
ทำ skill บันทึก procedure10 นาทีreusable สำหรับครั้งหน้า

Decision: ไม่ทำ upstream PR

เหตุผลตรงๆ:

  1. Patch เราทำงานแล้ว — ไม่มี pressure จาก upstream, ไม่มี deadline, ไม่มี user อื่นรอ
  2. ROI ต่ำ — high effort (1+ วัน implement + review + iterate), uncertain outcome (rejection risk สูง)
  3. Over-engineering trap — ตื่นเต้นกับ contribute แต่ลืมคิดว่าเราไม่ได้มีหน้าที่ fix upstream
  4. 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 รอบในคืนเดียว ผมได้ข้อสรุปที่ติดตัวมาจนถึงทุกวันนี้:

  1. ฝั่งที่ผิด ≠ ฝั่งที่ควรแก้ — bug อยู่ที่ harness แต่ fix ที่ gateway ให้ coverage ที่กว้างกว่า
  2. Chunked JS = fragile patch surface — ใช้ callback แทน sed source
  3. Hostname detection ใน upstream = จำกัด scope — proxy users ไม่ได้รับ benefit
  4. ไม่ต้อง 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-havenice-to-have
patch เรา break บ่อยไหม?break ทุก release = ต้อง contributestable 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 ได้ ลบทิ้งได้ ไม่เสียหาย

อ้างอิง​

แชร์บทความ
☕

เนื้อหานี้มีประโยชน์ไหม? ช่วยสนับสนุนค่ากาแฟให้ผู้เขียนสักแก้ว

Buy Me a Coffee
Loading...