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.