fix: address review — fail loud on non-video bodies, dedupe form plumbing, doc parity
CI / Tidy (pull_request) Successful in 9m28s
CI / Build & Test (pull_request) Successful in 10m0s

- videoMIME no longer hard-falls-back to video/mp4: a 2xx body that is
  neither declared nor sniffable as video (JSON job envelope, HTML error
  page) is now an APIError instead of a 'successful' corrupt clip.
- Resolution rides the wire as width/height AND the OpenAI-style size
  string, so either upstream convention honors an explicit request.
- writeFormFields + mimeFromContentType shared helpers replace the
  copied multipart loop (audio.go/video.go) and Content-Type branch.
- ADR-0019 indexed in docs/adr/README.md; README gains the videogen
  section + support-matrix mention (docs-parity rule).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXQxVhXBw8PwFAtsVrXSmj
This commit is contained in:
2026-07-12 09:53:34 -04:00
parent 4629c6af0f
commit 89210346b8
6 changed files with 114 additions and 38 deletions
+20 -1
View File
@@ -266,6 +266,24 @@ tr, err := tm.Transcribe(ctx, audio.TranscriptionRequest{
voices, err := ls.ListVoices(ctx, "kokoro") // []string of voice ids
```
## Video: text-to-video + image-to-video
Video generation lives in the `videogen` package (ADR-0019), mirroring
imagegen/audio: one small `Model` contract, zero values mean backend
defaults, bytes in/out. Text-to-video and image-to-video are one surface —
a nil `InitImage` is a pure text prompt; setting it conditions generation
on that frame (hybrid checkpoints like Wan 2.2 TI2V serve both). First
backend: llama-swap (blocking `/v1/videos/sync`, vLLM-Omni style — the
response body is the encoded clip, so `Result` carries a single `Video`).
Generation runs for minutes; bound the call with a context deadline.
```go
vm, _ := ls.VideoModel("videogen-wan22-5b")
res, err := vm.Generate(ctx, videogen.Request{Prompt: "a cat surfing"},
videogen.WithSize("1280x704"), videogen.WithNumFrames(81))
// res.Video.Data ([]byte) + res.Video.MIME ("video/mp4")
```
## Tool calls
```go
@@ -398,7 +416,8 @@ response as a single delta plus final event.
capabilities are present at the client level; whether a given call succeeds
depends on the llama.cpp model llama-swap loads. llama-swap also provides
**image generation + editing** (`imagegen`), **speech synthesis +
transcription** (`audio`) — separate axes, not shown above — plus a `Health`
transcription** (`audio`), **video generation** (`videogen`) — separate
axes, not shown above — plus a `Health`
probe and management methods on `*llamaswap.Provider`.
Notes: Ollama has no native tool_choice — `"none"` drops the tools;
+1
View File
@@ -22,3 +22,4 @@ One decision per file, append-only; supersede rather than rewrite.
| [0016](0016-imagegen-interface.md) | imagegen — a canonical text-to-image interface | Accepted |
| [0017](0017-audio-interfaces.md) | audio — canonical speech synthesis + transcription interfaces | Accepted |
| [0018](0018-imagegen-editor.md) | imagegen.Editor — image-to-image as a separate optional interface | Accepted |
| [0019](0019-videogen-interface.md) | videogen — canonical video-generation surface | Accepted |
+4 -15
View File
@@ -77,7 +77,7 @@ func (m *speechModel) Speak(ctx context.Context, req audio.SpeechRequest, opts .
// is a concrete audio type, else a mapping from the requested format, else
// audio/mpeg (the OpenAI endpoint's default container is mp3).
func speechMIME(contentType, format string) string {
if mt, _, err := mime.ParseMediaType(contentType); err == nil && strings.HasPrefix(mt, "audio/") {
if mt := mimeFromContentType(contentType, "audio/"); mt != "" {
return mt
}
switch strings.ToLower(strings.TrimSpace(format)) {
@@ -128,24 +128,13 @@ func (m *transcriptionModel) Transcribe(ctx context.Context, req audio.Transcrip
if _, err := fw.Write(req.Audio); err != nil {
return nil, fmt.Errorf("llama-swap: build transcription form: %w", err)
}
// Required fields always go on the wire (an empty model id should fail
// loudly upstream, not silently vanish); optional ones only when set.
fields := []struct {
key, value string
required bool
}{
if err := writeFormFields(w, "build transcription form", []formField{
{"model", m.id, true},
{"response_format", "json", true},
{"language", req.Language, false},
{"prompt", req.Prompt, false},
}
for _, f := range fields {
if !f.required && f.value == "" {
continue
}
if err := w.WriteField(f.key, f.value); err != nil {
return nil, fmt.Errorf("llama-swap: build transcription form: %w", err)
}
}); err != nil {
return nil, err
}
if err := w.Close(); err != nil {
return nil, fmt.Errorf("llama-swap: build transcription form: %w", err)
+33
View File
@@ -30,6 +30,8 @@ import (
"encoding/json"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"strings"
@@ -280,3 +282,34 @@ func (p *Provider) apiError(resp *http.Response, model string) error {
e.Message = strings.TrimSpace(string(body))
return e
}
// formField is one multipart form entry for the media endpoints. Required
// fields always go on the wire (an empty model id should fail loudly
// upstream, not silently vanish); optional ones only when set.
type formField struct {
key, value string
required bool
}
// writeFormFields appends fields to a multipart writer, skipping unset
// optional entries. wrap labels errors ("build transcription form", ...).
func writeFormFields(w *multipart.Writer, wrap string, fields []formField) error {
for _, f := range fields {
if !f.required && f.value == "" {
continue
}
if err := w.WriteField(f.key, f.value); err != nil {
return fmt.Errorf("llama-swap: %s: %w", wrap, err)
}
}
return nil
}
// mimeFromContentType returns the parsed media type when it matches prefix
// ("audio/", "video/"), else "".
func mimeFromContentType(contentType, prefix string) string {
if mt, _, err := mime.ParseMediaType(contentType); err == nil && strings.HasPrefix(mt, prefix) {
return mt
}
return ""
}
+26 -19
View File
@@ -64,30 +64,24 @@ func (m *videoModel) Generate(ctx context.Context, req videogen.Request, opts ..
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
// Required fields always go on the wire (an empty model id should fail
// loudly upstream, not silently vanish); optional ones only when set.
fields := []struct {
key, value string
required bool
}{
if err := writeFormFields(w, "build video form", []formField{
{"model", m.id, true},
{"prompt", req.Prompt, true},
{"negative_prompt", req.NegativePrompt, false},
// Resolution rides the wire twice: width/height (vLLM-Omni's
// names) AND the equivalent OpenAI-style size string, since
// upstreams silently ignore fields they don't understand and the
// values can never disagree.
{"width", formatInt(width), false},
{"height", formatInt(height), false},
{"size", strings.TrimSpace(req.Size), false},
{"num_frames", formatNonZero(req.NumFrames), false},
{"fps", formatNonZero(req.FPS), false},
{"num_inference_steps", formatInt(req.Steps), false},
{"guidance_scale", formatFloat(req.GuidanceScale), false},
{"seed", formatInt64(req.Seed), false},
}
for _, f := range fields {
if !f.required && f.value == "" {
continue
}
if err := w.WriteField(f.key, f.value); err != nil {
return nil, fmt.Errorf("llama-swap: build video form: %w", err)
}
}); err != nil {
return nil, err
}
if req.InitImage != nil {
fw, err := w.CreateFormFile("input_reference", initImageFilename(req.InitImage.MIME))
@@ -109,20 +103,33 @@ func (m *videoModel) Generate(ctx context.Context, req videogen.Request, opts ..
if len(videoBytes) == 0 {
return nil, &llm.APIError{Provider: m.p.name, Model: m.id, Message: "video response contained no video"}
}
return &videogen.Result{Video: videogen.Video{Data: videoBytes, MIME: videoMIME(contentType, videoBytes)}}, nil
mimeType := videoMIME(contentType, videoBytes)
if mimeType == "" {
// A 2xx body that is neither declared nor sniffable as video is a
// misconfigured upstream (a JSON job envelope, an HTML error page
// behind a proxy) — fail loud rather than hand back garbage as a
// playable clip.
return nil, &llm.APIError{
Provider: m.p.name,
Model: m.id,
Message: fmt.Sprintf("video response is not a video (content-type %q)", contentType),
}
}
return &videogen.Result{Video: videogen.Video{Data: videoBytes, MIME: mimeType}}, nil
}
// videoMIME resolves the result MIME type: the response Content-Type when it
// is a concrete video type, else content sniffing, else video/mp4 (the
// endpoint's default container).
// is a concrete video type, else content sniffing (mp4/webm magic bytes),
// else "" — the caller treats undetectable as an upstream error, unlike the
// audio path where the request's format param implies the container.
func videoMIME(contentType string, data []byte) string {
if mt, _, err := mime.ParseMediaType(contentType); err == nil && strings.HasPrefix(mt, "video/") {
if mt := mimeFromContentType(contentType, "video/"); mt != "" {
return mt
}
if mt := http.DetectContentType(data); strings.HasPrefix(mt, "video/") {
return mt
}
return "video/mp4"
return ""
}
// initImageFilename picks the multipart filename hint for the conditioning
+30 -3
View File
@@ -179,6 +179,9 @@ func TestVideoModelRequiresBaseURL(t *testing.T) {
}
func TestVideoMIME(t *testing.T) {
// A minimal ISO-BMFF prefix http.DetectContentType sniffs as video/mp4
// (the "mp4" brand prefix must appear inside the declared ftyp box).
mp4Magic := append([]byte{0, 0, 0, 20}, []byte("ftypmp42\x00\x00\x00\x00mp42")...)
cases := []struct {
contentType string
data []byte
@@ -186,12 +189,36 @@ func TestVideoMIME(t *testing.T) {
}{
{"video/webm", []byte("x"), "video/webm"},
{"video/mp4; charset=binary", []byte("x"), "video/mp4"},
{"application/octet-stream", []byte("x"), "video/mp4"},
{"", []byte("x"), "video/mp4"},
{"application/octet-stream", mp4Magic, "video/mp4"},
// Neither declared nor sniffable as video → "" (Generate errors).
{"application/octet-stream", []byte(`{"id":"job-1"}`), ""},
{"", []byte("x"), ""},
}
for _, tc := range cases {
if got := videoMIME(tc.contentType, tc.data); got != tc.want {
t.Errorf("videoMIME(%q) = %q, want %q", tc.contentType, got, tc.want)
t.Errorf("videoMIME(%q, %q) = %q, want %q", tc.contentType, tc.data, got, tc.want)
}
}
}
func TestVideoGenerateNonVideoBodyErrors(t *testing.T) {
// A stock async /v1/videos handler mounted at the sync path (or an HTML
// error page behind a proxy) answers 200 with a non-video body — that
// must be an error, never a "successful" garbage clip.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"job-1","status":"queued"}`))
}))
defer srv.Close()
p := New(WithBaseURL(srv.URL), WithHTTPClient(srv.Client()))
vm, _ := p.VideoModel("videogen-wan")
_, err := vm.Generate(context.Background(), videogen.Request{Prompt: "x"})
var apiErr *llm.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("err = %v, want *llm.APIError for non-video 2xx body", err)
}
if !strings.Contains(apiErr.Message, "not a video") {
t.Errorf("message = %q, want mention of non-video body", apiErr.Message)
}
}