# OctoHub 0.8.0: พรอกซีงอก media API แบบรวมศูนย์ขึ้นมา

> OctoHub 0.8.0 ย้ายภาพ วิดีโอ เสียงพูด และการถอดเสียง มาไว้หลังประตูหน้าแบบ self-hosted บานเดียวกับ chat completion ของคุณ — ซอง JSON ใบเดียวสำหรับสี่งาน ผู้ให้บริการห้าราย job ในคิวที่รอดผ่านการรีสตาร์ตไปได้ และต้นทุนต่อคำขอที่พูดว่า "ตีราคาไม่ได้" แทนที่จะเดาว่าศูนย์ สร้างบน octolib 0.36.1 โอเพนซอร์ส เขียนด้วย Rust ภายใต้ Apache-2.0

# OctoHub 0.8.0: พรอกซีงอก media API แบบรวมศูนย์ขึ้นมา

เมื่อเดือนก่อน คำถามที่ทำให้เราสร้าง [OctoHub](/blog/introducing-octohub-llm-proxy-for-observability) ขึ้นมาคือ: เอเจนต์ของคุณเรียกโมเดลไปสี่สิบครั้ง — มันส่งอะไรออกไปกันแน่ ราคาเท่าไร และอัปสตรีมไหนที่ตอบจริง ๆ

นี่คือคำถามเดิม แต่ย้ายที่ตั้งของเงินไปแล้ว เอเจนต์ของคุณสร้างภาพขึ้นมาสิบเอ็ดใบระหว่างวนแก้งานออกแบบ ตามด้วยวิดีโอเก้าวินาที แล้วอ่านบทสรุปออกเสียงด้วยเสียงสังเคราะห์ มีผู้ให้บริการเกี่ยวข้องสี่ราย สามในนั้นคิดเงินด้วยหน่วยที่ไม่ใช่โทเคน — วินาทีของ GPU, วินาทีของวิดีโอ, จำนวนตัวอักษร รายหนึ่งในนั้นไม่บอกคุณเลยว่าราคาเท่าไร ทีนี้: คุณจะเก็บเงินลูกค้ารายไหน?

สำหรับ chat completion เราตอบคำถามนั้นไปแล้ว สำหรับอย่างอื่นทั้งหมด คำตอบยังเป็นสเปรดชีตอยู่

**OctoHub 0.8.0 ปิดช่องว่างนั้น** การสร้างภาพ การสร้างวิดีโอ การสังเคราะห์เสียงพูด และการถอดเสียง ตอนนี้วิ่งผ่านพรอกซีตัวเดียวกัน คีย์ไคลเอนต์ชุดเดียวกัน allow-list ชุดเดียวกัน บันทึกคำขอชุดเดียวกัน และคอลัมน์ต้นทุนคอลัมน์เดียวกันกับ `/v1/completions` ผู้ให้บริการห้าราย — fal, ElevenLabs, Replicate, Runway และ OpenRouter — อยู่หลังซอง JSON ใบเดียว

---

## การแบ่งงานที่ทำให้มันเล็ก

ทั้งหมดนี้วางอยู่บน [octolib](/blog/octolib-the-engine-behind-our-ai-stack) **0.36.1** ที่ติดแท็กไว้ในบ่ายวันเดียวกัน ซึ่งเป็นที่อยู่จริง ๆ ของสแต็ก media: request struct แบบมี type สำหรับทั้งสี่งาน, adapter ของผู้ให้บริการ, วงจรชีวิตของ job, ตัวอธิบายความสามารถ และตารางอัตราอ้างอิงไว้ตีราคาสิ่งที่ผู้ให้บริการไม่ยอมตีราคาให้เอง เราเล่าถึงรีลีสนั้นไว้ใน[บันทึกรอบต้นกันยายน](/blog/release-round-early-september-2026)

ฝั่ง OctoHub ตามมาจากกฎข้อเดียว: **อะไรที่เกี่ยวกับโมเดลเป็นของ octolib อะไรที่เกี่ยวกับผู้เช่าเป็นของที่นี่** ไวยากรณ์การเราต์, adapter, job handle และการตีราคาเป็นของ octolib ส่วนคีย์, โควตา, การเก็บลงฐานข้อมูล, เมตริก และ API บนสายเป็นของ OctoHub

กฎข้อนั้นคือเหตุผลที่คอนฟิกไม่มีไวยากรณ์ใหม่เพิ่มเข้ามาเลย alias ของ media ก็คือ alias ของโมเดล:

```toml
[media_models]
"flux" = ["fal:fal-ai/flux/dev", "replicate:black-forest-labs/flux-1.1-pro"]
"veo"  = ["openrouter:google/veo-3.1"]
"tts"  = ["elevenlabs:eleven_flash_v2_5"]

[providers.fal]        # concurrency and rate windows, unchanged
concurrency = 8
requests_per_minute = 60
```

ไวยากรณ์ `provider:model` เดิม ความหมายแบบเป็นลิสต์ก็คือกระจายโหลดเหมือนเดิม ตัวจำกัดต่อผู้ให้บริการตัวเดิม งาน media ไม่เคย*ป้อน*ตัวเลขให้ `tokens_per_minute` เพราะผู้ให้บริการ media ไม่รายงานโทเคนกลับมาเลย แต่หน้าต่างจำกัดอัตราผูกอยู่กับชื่อผู้ให้บริการและใช้ร่วมกับ completion ดังนั้นบนผู้ให้บริการที่คุณใช้ทั้งสองอย่าง งบโทเคนที่ทราฟฟิกแชตของคุณใช้ไปแล้วจะปัดคำขอ media ทิ้งได้

คอนฟิกที่ผิดจะล้มตั้งแต่ตอนบูต ไม่ใช่ตอนคำขอแรกที่เสียเงินไปแล้ว: ผู้ให้บริการที่ไม่รู้จัก, `provider:model` ที่รูปแบบผิด, ลิสต์ mirror ที่ว่างเปล่า หรือ alias ที่ชนกับ `[models]` ทั้งหมดนี้ปฏิเสธที่จะสตาร์ต

---

## สี่งาน หนึ่งซอง

```
POST /v1/images/generations     generate | edit | inpaint | variation
POST /v1/videos                 text_to_video | image_to_video | reference_to_video | extend | edit
POST /v1/audio/speech
POST /v1/audio/transcriptions
GET  /v1/media/{id}             fetch or advance a job
POST /v1/media/{id}/cancel
GET  /v1/media/models           capabilities, parameters, reference price
```

พวกมันเป็นเอนด์พอยต์ฝั่งไคลเอนต์ ยืนยันตัวตนแบบเดียวกับ completion เป๊ะ ๆ — คีย์ bearer ตัวเดียวกัน allow-list โมเดลต่อคีย์แบบเดียวกัน การเชื่อมข้อมูลด้วย `X-Request-Id` แบบเดียวกัน:

```bash
curl -sX POST http://127.0.0.1:8080/v1/images/generations \
  -H "Authorization: Bearer <client-key>" \
  -d '{"model":"flux","prompt":"a red panda astronaut","count":2,"size":"1024x1024"}'
```

ทุกคำตอบ — ภาพ, วิดีโอ, เสียง, ข้อความถอดเสียง, จบแล้วหรือยังรันอยู่ — คือออบเจ็กต์หน้าตาเดียวกัน:

```jsonc
{
  "id": "med_9f3c1e0b…", "object": "media", "task": "text_to_image",
  "status": "succeeded", "model": "fal-ai/flux/dev", "provider": "fal",
  "progress": 1.0,
  "artifacts": [ { "kind": "image", "media_type": "image/png",
                   "source": { "type": "url", "value": "https://…" },
                   "size_bytes": 812345, "expires_at": 1767225600 } ],
  "usage": { "cost": 0.08, "cost_source": "provider", "currency": "USD", … },
  "warnings": [], "safety": { "status": "passed", … }, "error": null
}
```

หน้าตาแบบเดียวกันแปลว่ามีโค้ดพาธเดียวสำหรับการเก็บลงฐานข้อมูล มีรูปแบบแถวเดียวสำหรับบันทึก และมีของอยู่อย่างเดียวให้ไคลเอนต์ของคุณ parse การถอดเสียงเป็นงานเดียวที่ payload ไม่ใช่ artifact มันจึงเพิ่มออบเจ็กต์ `result` ที่มี `text`, `language`, `segments` และ `words` เข้ามา

มีสองจุดที่เราจงใจแตกต่างจาก API ของ OpenAI และประกาศไว้ล่วงหน้าดีกว่าปล่อยให้คุณไปเจอเอง: **ทุกอย่างเป็น JSON ไม่มีวันเป็น `multipart/form-data`** — อินพุตแบบไบนารีคือออบเจ็กต์ `{"type":"url"…}` หรือ `{"type":"base64"…}` — และการ edit, inpaint และ variation ของภาพเป็นฟิลด์ `mode` ไม่ใช่พาธแยกกัน `/v1/images/generations` ยืมพาธของ OpenAI มาพร้อมกับวิธีสะกด `model` / `prompt` / `size` แต่ไม่ได้ยืมรูปแบบบนสายมาด้วย — จำนวนภาพคือ `count` ไม่ใช่ `n` และคำตอบที่ได้คือซองด้านบน ไม่ใช่ `{"created", "data"}` ของ OpenAI ดังนั้น SDK ของ OpenAI จะ parse มันไม่ได้ ให้เรียกผ่าน HTTP ธรรมดาหรือ wrapper บาง ๆ เอา

---

## job ที่อยู่ได้นานกว่าคำขอที่เริ่มมันขึ้นมา

นี่คือส่วนที่ต่างจากการพรอกซี chat completion จริง ๆ และเป็นที่ที่การตัดสินใจเชิงออกแบบทั้งหมดอยู่

completion คือคำเรียกครั้งเดียวที่ไม่คืนค่าก็ล้มเหลว **ส่วน job ของ media ผูกมัดเงินไว้ที่อัปสตรีมตั้งแต่วินาทีที่ผู้ให้บริการรับงาน แล้วหลังจากนั้นมันอาจรันต่อไปอีกหลายนาที** วิดีโอไม่ใช่คำขอที่ช้า มันคือการซื้อของแล้วตามด้วยการรอ ทุกอย่างที่เหลือตามมาจากการเอาข้อเท็จจริงข้อนั้นมาคิดอย่างจริงจัง:

**แถวถูกเขียนก่อนการรอ ไม่ใช่หลัง** ทันทีที่ผู้ให้บริการแบบมีคิวรับงาน — fal, Replicate, Runway และวิดีโอของ OpenRouter — OctoHub จะเก็บเรกคอร์ดและ `JobHandle` ที่ไม่มี credential ติดไปด้วยลงฐานข้อมูล แล้วค่อยเริ่มรอ การรีสตาร์ต ไทม์เอาต์ ไคลเอนต์ที่วางสายไปเฉย ๆ: ไม่มีอะไรในนั้นทำให้ job ที่คุณจ่ายเงินไปแล้วกลายเป็นเด็กกำพร้าได้ handle อยู่ในฐานข้อมูลของคุณ และ job ทำต่อจากมันได้เสมอ (ส่วน ElevenLabs กับเอนด์พอยต์แบบซิงโครนัสของ OpenRouter ไม่มีคิวให้ส่ง handle กลับมา — มันทำงานทั้งหมดให้จบภายในคำเรียก submit ครั้งเดียว จึงไม่มีช่วงให้ถูกขัดจังหวะ และไม่มีอะไรให้ทำต่อ)

**`202` ไม่ใช่ความล้มเหลว** ส่ง `wait: false` มา คุณจะได้ id กลับไปทันทีที่ผู้ให้บริการรับงาน ส่ง `wait: true` มาแล้วเกิน `server.upstream_timeout_secs` คุณก็ได้ `202` ตัวเดิมพร้อม `status: "queued"` หรือ `"running"` งานฝั่งรีโมตยังเดินต่อ id ยังมีชีวิต ไม่มีอะไรหายไป พร้อมเมื่อไรค่อย poll `GET /v1/media/{id}`

**การ poll คือสิ่งที่ทำให้ job เดินหน้า — ไม่มี background worker** นี่คือเป้าหมายที่เราจงใจไม่ทำ: worker แปลว่าต้องมี scheduler ต้องมี lease และต้องมีโหมดความล้มเหลวอันที่สองสำหรับ job ที่ไม่มีใครรออยู่ ผลที่ตามมาเราเขียนไว้ในเอกสารตรง ๆ แทนที่จะซ่อนมัน: job ที่คุณไม่เคย poll จะค้างอยู่ที่ `queued` และต้นทุนของมันจะไม่ถูกบันทึกเลย ส่วนการอ่าน job ที่จบไปแล้วนั้นฟรีและไม่มีการคิดเงินซ้ำ — แถวที่อยู่ในสถานะสุดท้ายถูกเสิร์ฟจากฐานข้อมูลโดยไม่มีการเรียกอัปสตรีมเลยแม้แต่ครั้งเดียว

**permit ของผู้ให้บริการครอบแค่ตอน submit** ตัวจำกัดการทำงานพร้อมกันต่อผู้ให้บริการของ OctoHub เฝ้าเฉพาะคำเรียก submit แล้วก็ปล่อย วิดีโอสี่นาทีไม่ได้ตรึงหนึ่งในแปดช่อง fal ของคุณไว้สี่นาที แต่ช่องต่อผู้เช่าถูกถือไว้ตลอดทั้งคำขอ งาน media ของลูกค้ารายหนึ่งจึงกินงบก้อนเดียวกับ completion ของเขาเอง

**failover เกิดขึ้นตอน submit ซึ่งเป็นจุดที่ปลอดภัย** เปิด `server.failover_on_error` ขึ้นมา — ค่าเริ่มต้นคือปิด เหมือนฝั่ง completion — แล้วความผิดพลาดของผู้ให้บริการตอน submit จะตัดตัวเลือกนั้นทิ้งแล้วส่งคำขอต่อให้ mirror ตัวถัดไปใน alias ความผิดพลาดครั้งนั้นยังนับเข้าสถิติความล้มเหลวติดต่อกันของผู้ให้บริการรายนั้นด้วย: ตั้ง `server.provider_error_cooldown_secs` (ค่าเริ่มต้นคือ `0` แปลว่าปิด) แล้วความล้มเหลวฝั่งผู้ให้บริการสามครั้งติดจะส่งมันเข้าช่วงคูลดาวน์ ซึ่งแค่จัดลำดับมันไว้หลังตัวเลือกที่ยังแข็งแรง ไม่ได้บล็อกมัน ถ้าปล่อยทุกอย่างไว้ที่ค่าเริ่มต้น ความผิดพลาดจะเด้งกลับไปหาผู้เรียกตรง ๆ และไม่ว่าจะทางไหน พอ job ถูก*รับ*แล้วก็ไม่มีอะไรให้ fail over อีก — มันถูกจ่ายเงินไปแล้ว

เรกคอร์ดถูกจำกัดขอบเขตไว้ที่คีย์ที่สร้างมันขึ้นมา id ของผู้เช่ารายอื่นจะได้ `404` ไม่ใช่ `403` — คุณไม่มีสิทธิ์แม้แต่จะรู้ว่า id นั้นมีอยู่จริง

---

## ปัญหาเรื่องพารามิเตอร์ กับคำตอบที่ซื่อสัตย์ของมัน

ผู้ให้บริการ media ทุกรายมีความคิดเรื่องหน้าตาของคำขอไม่เหมือนกันเลย fal อยากได้ `num_inference_steps` และ `guidance_scale` บางเอนด์พอยต์เรียกพรอมป์ตว่า `text` ส่วน Runway ขายเครดิตและคิดเป็นชื่อโมเดลของตัวเอง API ที่รวมทุกอย่างไว้ที่เดียวต้องตัดสินใจว่าจะเอายังไงกับเรื่องนี้ และมีคำตอบที่แย่อยู่สองแบบ: เปิดออกมาเฉพาะส่วนที่ทับกัน (ไร้ประโยชน์) หรือประดิษฐ์ชั้นแปลงค่าที่แกล้งทำเป็นว่าทุกอย่างเหมือนกันหมด (โกหก และแพงด้วย)

คำตอบของ OctoHub มีสามส่วน:

**แกนกลางที่พกพาได้ สะกดแบบเดียวกันทุกที่** — `prompt`, `count`, `seed`, `size`, `duration_secs`, `negative_prompt`, `output_format` โดย `size` รับ `"1024x1024"` หรือ `"16:9"` นอกนั้นคือ `400` พกพาได้แปลว่ามีชื่อเรียกเดียว ไม่ได้แปลว่ารองรับได้ทุกที่: Runway ไม่มีอะไรเทียบเท่า `count`, `negative_prompt` หรือ `output_format` และเอนด์พอยต์วิดีโอของ OpenRouter ก็ไม่มีเหมือนกัน ภายใต้นโยบายเข้มงวดที่เป็นค่าเริ่มต้น พารามิเตอร์พวกนั้นจึงเด้งกลับมาเป็น `400` บนผู้ให้บริการเหล่านั้น แทนที่จะถูกเมินเงียบ ๆ — ซึ่งก็คือส่วนที่สามข้างล่างนี้

**ช่องทางหนีที่ส่งอะไรก็ได้ผ่านไปตรง ๆ แบบคำต่อคำ** โดยแยก namespace ตามผู้ให้บริการ:

```jsonc
"provider_options": {
  "fal": { "input": { "num_inference_steps": 28, "guidance_scale": 3.5 },
           "field_map": { "prompt": "text" } }
}
```

`field_map` แมปชื่อแบบพกพาไปยังชื่อที่เอนด์พอยต์นั้นเรียกมันจริง ๆ — `prompt` แบบพกพาจึงยังใช้ได้กับเอนด์พอยต์ที่ตั้งชื่อฟิลด์ว่า `text` เวลา alias หนึ่งพาดข้ามหลายผู้ให้บริการ ให้ส่งทุก namespace มาพร้อมกันได้เลย มีเฉพาะ namespace ของตัวเลือกที่ชนะเท่านั้นที่ถูกส่งต่อ ที่เหลือถูกทิ้ง และนั่นแหละคือสิ่งที่ทำให้ alias แบบหลายผู้ให้บริการใช้งานได้จริง

**นโยบายว่าจะเกิดอะไรขึ้นเมื่อพารามิเตอร์ตัวหนึ่งทำตามไม่ได้** `unsupported_parameters: "error"` (ค่าเริ่มต้น) จะล้ม _ก่อนที่เงินจะถูกใช้ไป_ — ถูกต้องสำหรับโปรดักชัน ส่วน `"warn_and_drop"` จะทิ้งพารามิเตอร์นั้นแล้วคืนคำเตือนกลับมา — มีประโยชน์ตอน alias เดียวแผ่ออกไปหลายผู้ให้บริการที่รองรับไม่เท่ากัน คุณเลือกได้เป็นรายคำขอ เพราะมีแต่คุณที่รู้ว่าคุณหมายถึงแบบไหน

และ `GET /v1/media/models` จะบอกคุณว่าอันไหนเป็นอันไหนก่อนที่คุณจะเสียเงินสักบาท: แฟล็กความสามารถทั้งด้านการทำงานและด้านพารามิเตอร์ของทุกตัวเลือกที่คอนฟิกไว้, ขีดจำกัด, JSON Schema ของ `provider_options` ของ adapter ตัวนั้นเอง และราคาอ้างอิง มีสองจุดที่ยังไม่เนียนใน 0.8.0 ซึ่งขอบอกไว้เลยเพราะยังไงคุณก็ต้องเจอเอง: การสำรวจความสามารถยิงถามทุกตัวเลือกผ่าน adapter ของภาพ ฟิลด์ `tasks` จึงอ่านได้ว่า `["text_to_image"]` เสมอ แม้กับ alias ที่เป็นวิดีโอ ส่วนตัวเลือก `elevenlabs` ไม่มี adapter ของภาพเลย มันจึงกลับมาพร้อมราคาแต่มีตัวอธิบายเป็น `null` ฟิลด์ความสามารถหลายตัวเขียนไว้ตรง ๆ ว่า `unknown` — adapter ไม่มีทางรู้สคีมาของทุกเอนด์พอยต์ และการพูดออกมาแบบนั้นดีกว่าคำตอบผิดที่ฟังดูมั่นใจ นั่นแหละคือเหตุผลที่ช่องทางหนีมีอยู่

---

## ต้นทุนที่ปฏิเสธจะเดา

นี่คือฟีเจอร์ที่รีลีสนี้พูดถึงจริง ๆ และเป็นจุดเดียวที่เราดื้อที่สุด

`usage.cost` คือตัวเลขที่ถูกคิดเงิน ส่วน `usage.cost_source` บอกว่ามันมาจากไหน:

| `cost_source` | ความหมาย                                                             |
| ------------- | -------------------------------------------------------------------- |
| `provider`    | อัปสตรีมคืนตัวเลขดอลลาร์จริงกลับมา OpenRouter และ Replicate ทำแบบนี้ |
| `estimate`    | คำนวณในเครื่องจากตารางอัตราอ้างอิงของ octolib                        |
| `unavailable` | ไม่มีอะไรตีราคามันได้ — `cost` เป็น `null`                           |

**`null` ไม่ใช่ศูนย์** คำขอที่ไม่มีอะไรตีราคาได้จะถูกบันทึกว่าตีราคาไม่ได้ ไม่ใช่ว่าฟรี มันไปโผล่ใน `octohub_media_cost_unknown_total` และพกคำเตือน `cost_unavailable` ติดตัวไป แทนที่จะแอบดึงยอดใช้จ่ายรวมของคุณให้ต่ำลงเงียบ ๆ แล้วทำให้แดชบอร์ดดูดีกว่าความเป็นจริง

ตัวเลขประมาณการมาจากตารางอ้างอิงของ octolib ซึ่งรู้จักหน่วยเพราะผู้ให้บริการก็คิดเป็นหน่วย: ElevenLabs คิดเงินตามจำนวนตัวอักษร Runway ขายวินาทีของวิดีโอที่แปลงมาจากเครดิต ส่วน fal ถอยไปใช้เวลาจริงของ GPU เพราะนั่นคือปริมาณเดียวที่เมตริกคิวของมันรายงานออกมา ตรงไหนที่อัตราจะกลายเป็นการเดา ตรงนั้นก็ไม่มีอัตรา — โมเดลจากชุมชนบน Replicate คิดเงินเป็นวินาทีของ GPU บนคลาส GPU ที่ไม่รู้ว่าตัวไหน มันจึงหาอัตราไม่เจอและอยู่ในสถานะตีราคาไม่ได้ แทนที่จะถูกประทับตราด้วยตัวเลขที่ฟังดูเข้าท่า

ช่องว่างที่เรารู้อยู่ และขอพูดออกมาดัง ๆ: **การถอดเสียงของ ElevenLabs ตีราคาไม่ได้** Scribe คิดเงินตามความยาวเสียงที่ป้อนเข้าไป ซึ่งไม่ถูกรายงานกลับมา และไม่มีอัตราอ้างอิงตัวไหนครอบคลุมมัน การถอดเสียงบนผู้ให้บริการรายอื่นได้ตัวเลขจริง — fal ตกไปใช้อัตราครอบจักรวาลต่อวินาที GPU ของมัน ส่วน Replicate และ OpenRouter ตีราคาจากยอดดอลลาร์เท่าที่อัปสตรีมรายงานกลับมา เมื่อไรที่เราตีราคา Scribe ได้ เราจะทำ จนกว่าจะถึงตอนนั้นมันคือ `unavailable` ไม่ใช่ `0.00`

ฝั่งยอดรวม `GET /v1/admin/usage` ได้ `media_count` และ `total_cost` เพิ่มเข้ามา — ซึ่งตอนนี้บวก completions, embeddings _และ_ media เข้าเป็นตัวเลขเดียวที่คุณเอาไปใส่ใบแจ้งหนี้ได้จริง ส่วน `GET /v1/admin/media` ลิสต์เรกคอร์ดทีละรายการด้วยตัวกรองชุดเดียวกับอีกสองตัว เรียงใหม่สุดขึ้นก่อน รวม job ที่ยังวิ่งอยู่ไว้ด้วยพร้อมสถานะที่ยังไม่สิ้นสุดและ `completed_at` ที่เป็น `null` job _คือ_ เรกคอร์ดของมันเองในสถานะที่ยังไม่จบ ไม่มีคิวแยกให้ต้องไปส่อง

ฝั่ง Prometheus แยกตาม task, model และ provider — บวกป้ายกำกับ `api_key_id` บนตัวนับคำขอเมื่อเปิด `metrics.per_key` ส่วนตัวนับต้นทุนยังไม่มีป้ายกำกับแยกตามคีย์ ยอดใช้จ่ายรายผู้เช่ามาจาก `GET /v1/admin/usage` ซึ่งเป็นตัวเลขที่แม่นยำ ไม่ใช่ค่าที่สุ่มเก็บมา:

```
octohub_media_requests_total{task,model,provider,status}
octohub_media_duration_seconds{task,model,provider}
octohub_media_cost_microusd_total{task,model,provider,source}
octohub_media_cost_unknown_total{task,model,provider}
```

ต้นทุนถูกนับเป็นไมโครดอลลาร์ เพราะตัวนับหน่วยดอลลาร์สำหรับภาพที่ราคา $0.003 ก็คือเครื่องผลิตความคลาดเคลื่อนจากการปัดเศษดี ๆ นี่เอง และเราจงใจไม่ทำ gauge นับ job ที่ค้างอยู่: ตัวที่แม่นจริงต้องไปนับแถวที่ยังไม่สิ้นสุดในฐานข้อมูล ส่วนตัวนับในโปรเซสจะผิดทันทีที่ job ถูก poll โดย replica คนละตัว หรือรอดผ่านการรีสตาร์ตมาได้

---

## สิ่งที่เราตอบว่าไม่

ฟีเจอร์ที่ไม่ได้อยู่ในนี้รับน้ำหนักโครงสร้างพอ ๆ กับฟีเจอร์ที่อยู่:

**OctoHub ไม่กลายเป็น blob store** ไม่มี object store ไม่มี CDN ไม่มีวงจรชีวิตของ artifact ให้ต้องดูแล และมันไม่เคยไปดึง artifact มาให้คุณ ผู้ให้บริการที่ตอบกลับมาเป็น URL ก็ถูกเก็บไว้เป็น URL — แถวนั้นถือแค่ลิงก์กับเมทาดาทา ไม่มีอย่างอื่น และนั่นคือทราฟฟิกส่วนใหญ่ ส่วนผู้ให้บริการที่ตอบกลับมาเป็นตัว payload เองคือข้อยกเว้นที่คุณควรเผื่อขนาดเอาไว้: เสียงพูดของ ElevenLabs เป็นแบบนี้เสมอ ส่วน fal, Replicate หรือ OpenRouter เป็นบางครั้ง ไบต์พวกนั้นถูกเข้ารหัส base64 ลงในคำตอบ และ base64 ชุดเดียวกันก็ถูกเก็บลงคอลัมน์ `result` ของเรกคอร์ดด้วย — เพราะแถวนั้นแหละคือสิ่งที่ `GET /v1/media/{id}` ครั้งถัดไปเล่นซ้ำให้ โดยไม่ต้องแตะอัปสตรีมเลย ไฟล์ MP3 หนึ่งไฟล์ในตาราง `media` กินพื้นที่ราว 4/3 ของขนาดตัวเอง การใช้งาน text-to-speech หนัก ๆ จึงควรวางแผนเผื่อตารางที่เป็นทั้งบัญชีแยกประเภทและคลังสื่อไปพร้อมกัน

**พาธไฟล์ที่ไคลเอนต์ส่งมาถูกปฏิเสธ** `MediaSource` ของ octolib รองรับ `file`, `provider_file` และ `object_storage` แต่ทั้งสามตัวได้ `400` ที่นี่ พาธในคำขอที่ยิงไปหา _เซิร์ฟเวอร์_ คือคำขอให้อ่านระบบไฟล์ของเซิร์ฟเวอร์ — นั่นคือรูโหว่ SSRF/LFI ไม่ใช่ฟีเจอร์ base64 แบบ inline ถูกตรวจขนาดกับ `media.max_source_bytes` (ค่าเริ่มต้น 20 MiB) ก่อนที่อะไรจะไปถึงผู้ให้บริการ และ payload ไม่เคยลงไปอยู่ในฐานข้อมูล: คำขอที่บันทึกไว้เก็บโครงสร้างกับจำนวนไบต์ ไม่ใช่ตัวไบต์

**credential ของอัปสตรีมอยู่บนเซิร์ฟเวอร์เท่านั้น** คีย์ของผู้ให้บริการมาจากสภาพแวดล้อมของเซิร์ฟเวอร์ (`FAL_API_KEY`, `ELEVENLABS_API_KEY` และอื่น ๆ) และไม่มีวันถูกรับมาจากไคลเอนต์ `provider_options.<provider>.cost_estimate` ถูกปฏิเสธทันที — การตีราคาถูกตัดสินฝั่งเซิร์ฟเวอร์ และไคลเอนต์ไม่มีสิทธิ์มาบอกคุณว่ามันติดเงินคุณเท่าไร

สิ่งที่ยังไม่มีใน 0.8.0 เช่นกัน: TTS แบบสตรีม และ `multipart/form-data` ทั้งคู่เพิ่มเข้ามาทีหลังได้ตอนที่มีคนต้องการมันจริง ๆ — แม้ว่าสตรีมมิงจะต้องมีคำตอบเรื่องต้นทุนของตัวเองเสียก่อน เพราะพาธเสียงพูดแบบสตรีมของ octolib ไม่รายงานข้อมูลการใช้งานกลับมาเลยสักอย่าง

---

## อัปเกรด

ตาราง `media` ถูกสร้างตอนสตาร์ตพร้อมกับตารางอื่น ๆ ทั้งบน SQLite, MySQL และ PostgreSQL — ไม่มีขั้นตอน migration คอนฟิกเดิมยังใช้ได้ต่อ: ถ้าคุณไม่ได้เพิ่ม `[media_models]` เข้าไป เอนด์พอยต์ใหม่ก็แค่ไม่มีอะไรให้เราต์ และพรอกซีส่วนที่เหลือก็ทำงานเหมือนตอน 0.7 เป๊ะ ๆ

```bash
# Linux x86_64, static musl build
curl -fsSL https://github.com/Muvon/octohub/releases/download/0.8.0/octohub-0.8.0-x86_64-unknown-linux-musl.tar.gz | tar xz
./octohub
```

ไบนารีถูกปล่อยออกมาสำหรับหก target (Linux musl, macOS, Windows — x86_64 และ ARM64) หรือจะ `cargo build --release` จากซอร์สก็ได้

รีลีสนี้ถูกทำเครื่องหมายว่า breaking ด้วยเหตุผลข้อเดียว: trait `Storage` งอกเมธอดฝั่ง media ขึ้นมา เรื่องนี้มีผลเฉพาะกับคนที่ดูแล storage backend ของตัวเองที่เขียนอิงกับส่วนในของ OctoHub ถ้าคุณรันไบนารีก็ไม่มีอะไรต้องแก้ อีกเรื่องเล็ก ๆ ที่ควรรู้: ตั้งแต่ 0.7.12 OctoHub ส่งต่อเฮดเดอร์ระบุที่มาตลอดเส้นทางจากต้นจนปลาย และตั้งแต่รอบนี้มันแนะนำตัวเองกับอัปสตรีมว่า `Octohub/<version>` แทนค่าเริ่มต้นทั่วไปของ octolib — แดชบอร์ดฝั่งผู้ให้บริการจึงเรียกชื่อพรอกซีที่เป็นคนยิงคำเรียกนั้นได้

---

## ประเด็นของเรื่องนี้

เหตุผลที่ต้องวางพรอกซีไว้หน้าโมเดลของคุณไม่เคยเป็นเรื่องการเราต์ มันคือการที่จุดเดียวซึ่งทุกคำขอวิ่งผ่านนั้น เป็นที่เดียวที่ความจริงทั้งหมดมีอยู่ เหตุผลข้อนั้นไม่ได้อ่อนลงเลยเมื่อคำขอเริ่มคืนพิกเซลและเสียงกลับมาแทนโทเคน — มันแข็งขึ้นด้วยซ้ำ เพราะ media คือที่ที่ต้นทุนต่อคำขอเลิกเป็นเศษปัดทิ้ง แล้วกลายเป็นบิลจริง ๆ

ตั้งแต่ 0.8.0 เป็นต้นไป คำตอบของ "ลูกค้าคนไหนสร้างวิดีโอตัวนี้ บนผู้ให้บริการรายไหน และมันราคาเท่าไร" คือแถวเดียวในฐานข้อมูลของคุณเอง วางอยู่ข้าง ๆ chat completion ในสกุลเงินเดียวกัน พร้อมคอลัมน์ที่ยอมรับตรง ๆ เวลาไม่มีใครรู้

— Don

_OctoHub เป็นโอเพนซอร์สภายใต้ Apache-2.0 พัฒนาโดย [Muvon Un Limited](https://muvon.io) รับมันได้ที่ [GitHub](https://github.com/Muvon/octohub) — ยินดีรับ issues และ pull requests เอกสาร media ฉบับเต็ม: [doc/11-media.md](https://github.com/Muvon/octohub/blob/master/doc/11-media.md)._
