OctoHub 0.8.0: พรอกซีงอก media API แบบรวมศูนย์ขึ้นมา
เมื่อเดือนก่อน คำถามที่ทำให้เราสร้าง OctoHub ขึ้นมาคือ: เอเจนต์ของคุณเรียกโมเดลไปสี่สิบครั้ง — มันส่งอะไรออกไปกันแน่ ราคาเท่าไร และอัปสตรีมไหนที่ตอบจริง ๆ
นี่คือคำถามเดิม แต่ย้ายที่ตั้งของเงินไปแล้ว เอเจนต์ของคุณสร้างภาพขึ้นมาสิบเอ็ดใบระหว่างวนแก้งานออกแบบ ตามด้วยวิดีโอเก้าวินาที แล้วอ่านบทสรุปออกเสียงด้วยเสียงสังเคราะห์ มีผู้ให้บริการเกี่ยวข้องสี่ราย สามในนั้นคิดเงินด้วยหน่วยที่ไม่ใช่โทเคน — วินาทีของ GPU, วินาทีของวิดีโอ, จำนวนตัวอักษร รายหนึ่งในนั้นไม่บอกคุณเลยว่าราคาเท่าไร ทีนี้: คุณจะเก็บเงินลูกค้ารายไหน?
สำหรับ chat completion เราตอบคำถามนั้นไปแล้ว สำหรับอย่างอื่นทั้งหมด คำตอบยังเป็นสเปรดชีตอยู่
OctoHub 0.8.0 ปิดช่องว่างนั้น การสร้างภาพ การสร้างวิดีโอ การสังเคราะห์เสียงพูด และการถอดเสียง ตอนนี้วิ่งผ่านพรอกซีตัวเดียวกัน คีย์ไคลเอนต์ชุดเดียวกัน allow-list ชุดเดียวกัน บันทึกคำขอชุดเดียวกัน และคอลัมน์ต้นทุนคอลัมน์เดียวกันกับ /v1/completions ผู้ให้บริการห้าราย — fal, ElevenLabs, Replicate, Runway และ OpenRouter — อยู่หลังซอง JSON ใบเดียว
การแบ่งงานที่ทำให้มันเล็ก
ทั้งหมดนี้วางอยู่บน octolib 0.36.1 ที่ติดแท็กไว้ในบ่ายวันเดียวกัน ซึ่งเป็นที่อยู่จริง ๆ ของสแต็ก media: request struct แบบมี type สำหรับทั้งสี่งาน, adapter ของผู้ให้บริการ, วงจรชีวิตของ job, ตัวอธิบายความสามารถ และตารางอัตราอ้างอิงไว้ตีราคาสิ่งที่ผู้ให้บริการไม่ยอมตีราคาให้เอง เราเล่าถึงรีลีสนั้นไว้ในบันทึกรอบต้นกันยายน
ฝั่ง OctoHub ตามมาจากกฎข้อเดียว: อะไรที่เกี่ยวกับโมเดลเป็นของ octolib อะไรที่เกี่ยวกับผู้เช่าเป็นของที่นี่ ไวยากรณ์การเราต์, adapter, job handle และการตีราคาเป็นของ octolib ส่วนคีย์, โควตา, การเก็บลงฐานข้อมูล, เมตริก และ API บนสายเป็นของ OctoHub
กฎข้อนั้นคือเหตุผลที่คอนฟิกไม่มีไวยากรณ์ใหม่เพิ่มเข้ามาเลย alias ของ media ก็คือ alias ของโมเดล:
[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 แบบเดียวกัน:
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"}'
ทุกคำตอบ — ภาพ, วิดีโอ, เสียง, ข้อความถอดเสียง, จบแล้วหรือยังรันอยู่ — คือออบเจ็กต์หน้าตาเดียวกัน:
{
"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 ตามผู้ให้บริการ:
"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 เป๊ะ ๆ
# 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 รับมันได้ที่ GitHub — ยินดีรับ issues และ pull requests เอกสาร media ฉบับเต็ม: doc/11-media.md.



