diff --git a/internal/server/captures.go b/internal/server/captures.go index fefab9a2..6dd56ba5 100644 --- a/internal/server/captures.go +++ b/internal/server/captures.go @@ -37,7 +37,8 @@ const ( ) // captureFieldsByPath overrides the default capture mask for routes carrying -// large binary payloads (audio/image) where storing the full body is wasteful. +// large binary payloads (audio/image/video) where storing the full body is +// wasteful. var captureFieldsByPath = map[string]captureFields{ "/v1/audio/speech": captureReqAll | captureRespHeaders, "/v1/audio/voices": captureReqHeaders | captureRespAll, @@ -46,6 +47,9 @@ var captureFieldsByPath = map[string]captureFields{ "/v1/images/edits": captureReqHeaders | captureRespHeaders, "/sdapi/v1/txt2img": captureReqAll | captureRespHeaders, "/sdapi/v1/img2img": captureReqHeaders | captureRespHeaders, + // video: the request multipart can carry a conditioning frame and the + // response body IS the clip — headers only, both directions. + "/v1/videos/sync": captureReqHeaders | captureRespHeaders, } // captureFieldsFor returns the capture mask for a request path. Unlisted routes diff --git a/internal/server/server.go b/internal/server/server.go index 0d27d89d..cf70bb82 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -80,6 +80,15 @@ var modelPostJSONRoutes = []string{ var modelPostFormRoutes = []string{ "/v1/audio/transcriptions", "/v1/images/edits", + + // video generation: ONLY the blocking sync shape (vLLM-Omni + // /v1/videos/sync — the response body is the clip). The async + // OpenAI-style POST /v1/videos is deliberately NOT dispatched: its + // poll/download companions (GET /v1/videos/{id}[/content]) carry no + // model field to route by, so registering creation alone would start + // unretrievable upstream jobs. Extraction is content-type driven, so + // JSON bodies dispatch here too. + "/v1/videos/sync", } // modelGetRoutes are model-dispatched GET endpoints (the model arrives as a diff --git a/internal/server/server_test.go b/internal/server/server_test.go index f16a915f..964fcb81 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -1,9 +1,11 @@ package server import ( + "bytes" "context" "encoding/json" "io" + "mime/multipart" "net/http" "net/http/httptest" "strings" @@ -339,3 +341,56 @@ func TestServer_LogStream_UnknownID_Returns400(t *testing.T) { t.Errorf("status=%d want 400", w.Code) } } + +func TestServer_VideoRoutesDispatch(t *testing.T) { + s := newTestServer( + newStubRouter([]string{"videogen-model"}, "video response"), + newStubRouter(nil, ""), + ) + + // Multipart form on /v1/videos/sync. + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + mw.WriteField("model", "videogen-model") + mw.WriteField("prompt", "a cat") + mw.Close() + req := httptest.NewRequest(http.MethodPost, "/v1/videos/sync", &buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + w := httptest.NewRecorder() + s.ServeHTTP(w, req) + if w.Code != http.StatusOK || w.Body.String() != "video response" { + t.Fatalf("/v1/videos/sync: status=%d body=%q", w.Code, w.Body.String()) + } + + // JSON bodies dispatch too (extraction is content-type driven). + jsonReq := httptest.NewRequest(http.MethodPost, "/v1/videos/sync", + strings.NewReader(`{"model":"videogen-model","prompt":"a cat"}`)) + jsonReq.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + s.ServeHTTP(w, jsonReq) + if w.Code != http.StatusOK || w.Body.String() != "video response" { + t.Fatalf("/v1/videos/sync json: status=%d body=%q", w.Code, w.Body.String()) + } + + // Unknown model on the video route still 404s. + nopeReq := httptest.NewRequest(http.MethodPost, "/v1/videos/sync", + strings.NewReader(`{"model":"nope","prompt":"a cat"}`)) + nopeReq.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + s.ServeHTTP(w, nopeReq) + if w.Code != http.StatusNotFound { + t.Fatalf("/v1/videos/sync unknown model: status=%d want 404", w.Code) + } + + // The async job-creation route is NOT dispatched (no poll routes to + // complete the flow) — a stray OpenAI-videos client gets a clean 404 + // instead of an unretrievable upstream job. + asyncReq := httptest.NewRequest(http.MethodPost, "/v1/videos", + strings.NewReader(`{"model":"videogen-model","prompt":"a cat"}`)) + asyncReq.Header.Set("Content-Type", "application/json") + w = httptest.NewRecorder() + s.ServeHTTP(w, asyncReq) + if w.Code != http.StatusNotFound { + t.Fatalf("POST /v1/videos: status=%d want 404 (async family not routed)", w.Code) + } +}