mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 14:15:00 +00:00
read_video called with a url parameter always failed under an agent budget:
tool:read_video: cannot verify streamed native media against the agent
context budget (no in-memory payload to count); refusing to send
read_audio and read_document appeared to work but only for very small files.
Both symptoms share one root cause.
5ca433b8 introduced the complete-input invariant and, to keep out-of-band
media honest, appended the standard-base64 encoding of the payload as a
synthetic guard-only message. That message is counted as text. Measured against
the bundled BudgetCounter, base64 costs 0.956 tokens per raw byte, so an 11.6 MB
video counted 11,161,058 tokens and exceeded a 200k, a 1M and a 2M window alike.
The practical ceiling was roughly 200 KB at a 200k window while videoMaxBytes is
100 MB. The read_video URL transport streams to the File API without buffering,
so it had no bytes to count at all and was routed to a helper that refused to
send whenever an agent budget was present.
Media is now priced by what a provider actually bills for it. Every number below
is either a published per-unit rate or a published provider limit; none is
derived from a byte size, because a static-image video compresses arbitrarily
small and no bitrate floor exists. An earlier revision of this branch tried one
and a 120-second, 20,627-byte clip priced at 526 tokens against a true 31,560.
internal/mediabudget prices one payload:
- Video and audio: ffprobe measures duration. 263 tokens/second for video,
32 for audio, both published for static processing at 1 FPS.
- PDF: pdfinfo counts pages at 258 tokens/page. When the page count cannot be
read, the charge is the proven ceiling of 1000 pages, which is the most a
provider will accept and therefore the most it can bill.
- Video and audio that cannot be measured are refused. Upstream already
refused unverifiable native media for every URL on the Gemini streamed path;
this narrows that refusal from every URL to only what genuinely cannot be
measured, rather than removing it. PDF differs because its page count is
cheaply measurable and its ceiling is small enough to stay usable.
A remote video is measured without downloading it: two ranged GETs, 512 KB from
the head and 512 KB from the tail, written into a sparse temp file sized to the
declared total and handed to ffprobe. The tail matters because every container
that puts its index at the end keeps it there: head-only probing under-reports
mpeg by 98% and ogg by 79%, and a plain prefix makes ffprobe under-report a
30-second WAV as 0.74 seconds because it clamps to the bytes it can see. Sizing
the temp file to the real total fixes that. Verified end to end on an 11,673,105
byte MP4 served by nginx: 1 MB of ranged reads yielded duration 40.000000,
identical to ffprobe reading the whole URL, for a charge of 10,520 tokens. Those
requests reuse the existing SSRF-safe path, security.WithPinnedIP plus
security.NewSafeClient(0), and no URL is ever handed to an external binary.
Beyond the reported bug, two pre-existing gaps let large media reach a provider
almost unpriced. ExecuteWithChain treated every callProvider error as a provider
failure and advanced to the next entry, and the non-Gemini branches of read_video
and read_document reserved without pricing their payload at all. Measured under a
20,000-token window before this change, a 40 MB video and a 1000-page PDF each
reached a provider charged about 1,600 tokens. Budget refusals are now terminal
in the chain and every media branch prices its payload, so both reach no provider
at all. Genuine provider failures still fail over.
Known limits, stated rather than discovered:
- The /Type /Page scan that guards against a forged /Count is a floor, not a
bound. Pages inside a compressed object stream are invisible to it, and a
9,484-byte PDF built that way is charged 258 tokens for 1000 pages. A real
pdfinfo reads such files correctly; the scan only ever raises a probed count.
- A video URL whose origin does not serve byte ranges is now refused on the
non-Gemini path too, and the refusal is terminal. Upstream forwarded such URLs
unpriced. A HEAD giving only a size is not enough to price one.
- read_video and read_audio require ffprobe. Docker images install it by default
except the base variant; bare binaries and the desktop build do not ship it.
- A hostile origin can craft a container ffprobe reads as about one second.
Reservation.Reconcile overwrites the estimate with the provider's reported
usage, so this weakens the gate rather than defeating it.
Byte ceilings videoMaxBytes, audioMaxBytes and documentMaxBytes are unchanged.
No new module dependency; ffprobe and pdfinfo are optional runtime probes.
576 lines
20 KiB
Go
576 lines
20 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/mediabudget"
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
"github.com/nextlevelbuilder/goclaw/internal/security"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
type mockCredentialProvider struct {
|
|
apiKey string
|
|
apiBase string
|
|
}
|
|
|
|
func (m *mockCredentialProvider) APIKey() string { return m.apiKey }
|
|
func (m *mockCredentialProvider) APIBase() string { return m.apiBase }
|
|
|
|
// parseTestByteRange resolves a "bytes=A-B" or suffix "bytes=-N" spec against
|
|
// a known total, mirroring what a real range-capable file server would do.
|
|
func parseTestByteRange(header string, total int64) (start, end int64, ok bool) {
|
|
const prefix = "bytes="
|
|
if !strings.HasPrefix(header, prefix) {
|
|
return 0, 0, false
|
|
}
|
|
spec := strings.TrimPrefix(header, prefix)
|
|
if strings.HasPrefix(spec, "-") {
|
|
n, err := strconv.ParseInt(spec[1:], 10, 64)
|
|
if err != nil {
|
|
return 0, 0, false
|
|
}
|
|
if n > total {
|
|
n = total
|
|
}
|
|
return total - n, total - 1, true
|
|
}
|
|
parts := strings.SplitN(spec, "-", 2)
|
|
if len(parts) != 2 {
|
|
return 0, 0, false
|
|
}
|
|
s, err1 := strconv.ParseInt(parts[0], 10, 64)
|
|
e, err2 := strconv.ParseInt(parts[1], 10, 64)
|
|
if err1 != nil || err2 != nil {
|
|
return 0, 0, false
|
|
}
|
|
if e >= total {
|
|
e = total - 1
|
|
}
|
|
return s, e, true
|
|
}
|
|
|
|
func TestReadVideo_BothMediaIdAndUrl_Error(t *testing.T) {
|
|
tool := NewReadVideoTool(nil, nil)
|
|
|
|
res := tool.Execute(context.Background(), map[string]any{
|
|
"prompt": "describe this video",
|
|
"media_id": "video-123",
|
|
"url": "https://example.com/video.mp4",
|
|
})
|
|
|
|
if !res.IsError {
|
|
t.Fatalf("expected error when both media_id and url are provided")
|
|
}
|
|
|
|
if !strings.Contains(res.ForLLM, "Both 'media_id' and 'url' parameters cannot be specified") {
|
|
t.Errorf("unexpected error message: %s", res.ForLLM)
|
|
}
|
|
}
|
|
|
|
func TestReadVideo_PrivateURL_Error(t *testing.T) {
|
|
tool := NewReadVideoTool(nil, nil)
|
|
|
|
res := tool.Execute(context.Background(), map[string]any{
|
|
"prompt": "describe this video",
|
|
"url": "http://127.0.0.1/video.mp4",
|
|
})
|
|
|
|
if !res.IsError {
|
|
t.Fatalf("expected error for private video URL")
|
|
}
|
|
if !strings.Contains(res.ForLLM, "Invalid video URL") {
|
|
t.Errorf("unexpected error message: %s", res.ForLLM)
|
|
}
|
|
}
|
|
|
|
// TestReadVideo_GeminiURL_UnmeasurableOriginNeverGetsAStreamingGET locks the
|
|
// front gate: every request to this server fails, so both range probes and the
|
|
// HEAD learn nothing. Nothing later could price the payload either, so the
|
|
// streaming GET to a caller-supplied URL must never be issued.
|
|
func TestReadVideo_GeminiURL_UnmeasurableOriginNeverGetsAStreamingGET(t *testing.T) {
|
|
security.SetAllowLoopbackForTest(true)
|
|
defer security.SetAllowLoopbackForTest(false)
|
|
|
|
hits, streamHits := 0, 0
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
hits++
|
|
if r.Method == http.MethodGet && r.Header.Get("Range") == "" {
|
|
streamHits++
|
|
}
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
tool := NewReadVideoTool(nil, nil)
|
|
cp := &mockCredentialProvider{apiKey: "test-key"}
|
|
|
|
ctx := store.WithAgentContextWindow(context.Background(), 200_000)
|
|
ctx = store.WithAgentMaxTokens(ctx, 32_000)
|
|
|
|
params := map[string]any{
|
|
"prompt": "describe this video",
|
|
"url": ts.URL,
|
|
"_provider_type": "gemini",
|
|
}
|
|
|
|
_, _, err := tool.callProvider(ctx, cp, "gemini", "gemini-2.5-flash", params)
|
|
if !errors.Is(err, mediabudget.ErrNoProbeableBytes) {
|
|
t.Fatalf("expected the refusal to name the unprobeable source, got %T: %v", err, err)
|
|
}
|
|
if !isBudgetRefusal(err) {
|
|
t.Fatalf("the refusal must be terminal for the fallback chain, got %T: %v", err, err)
|
|
}
|
|
if streamHits != 0 {
|
|
t.Fatalf("the streaming GET reached the origin %d time(s), want 0", streamHits)
|
|
}
|
|
if hits != 3 {
|
|
t.Fatalf("the URL was contacted %d time(s), want 3 (head probe, tail probe, HEAD)", hits)
|
|
}
|
|
}
|
|
|
|
// TestReadVideo_GeminiURL_RejectsMissingContentLength covers the Content-Length
|
|
// check in read_video_resolve.go, which was dead code while the fail-closed
|
|
// reservation refused every streamed URL before transport.
|
|
func TestReadVideo_GeminiURL_RejectsMissingContentLength(t *testing.T) {
|
|
security.SetAllowLoopbackForTest(true)
|
|
defer security.SetAllowLoopbackForTest(false)
|
|
|
|
stubMediaProbes(t, 20, 1)
|
|
const total = int64(215_000)
|
|
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("Range") == "" {
|
|
// Flushing before the body makes Go answer chunked, which is what
|
|
// an origin with no declared length looks like.
|
|
w.WriteHeader(http.StatusOK)
|
|
w.(http.Flusher).Flush()
|
|
w.Write(make([]byte, 16))
|
|
return
|
|
}
|
|
start, end, ok := parseTestByteRange(r.Header.Get("Range"), total)
|
|
if !ok {
|
|
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, total))
|
|
w.WriteHeader(http.StatusPartialContent)
|
|
w.Write(make([]byte, end-start+1))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
tool := NewReadVideoTool(nil, nil)
|
|
cp := &mockCredentialProvider{apiKey: "test-key"}
|
|
ctx := store.WithAgentContextWindow(context.Background(), 200_000)
|
|
ctx = store.WithAgentMaxTokens(ctx, 32_000)
|
|
|
|
_, _, err := tool.callProvider(ctx, cp, "gemini", "gemini-2.5-flash", map[string]any{
|
|
"prompt": "describe this video", "url": ts.URL, "_provider_type": "gemini",
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "does not support static streaming") {
|
|
t.Fatalf("expected the Content-Length check to reject, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestReadVideo_GeminiURL_RejectsOversizedStream covers the 2 GB stream ceiling.
|
|
// The clip measures 20 s, so the budget lets it through and this ceiling is
|
|
// what rejects the 3 GB the origin declares.
|
|
func TestReadVideo_GeminiURL_RejectsOversizedStream(t *testing.T) {
|
|
security.SetAllowLoopbackForTest(true)
|
|
defer security.SetAllowLoopbackForTest(false)
|
|
stubMediaProbes(t, 20, 1)
|
|
|
|
const total = int64(3_221_225_472) // 3 GB, over the 2 GB ceiling
|
|
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("Range") == "" {
|
|
w.Header().Set("Content-Length", strconv.FormatInt(total, 10))
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
start, end, ok := parseTestByteRange(r.Header.Get("Range"), total)
|
|
if !ok {
|
|
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, total))
|
|
w.WriteHeader(http.StatusPartialContent)
|
|
w.Write(make([]byte, 1024))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
tool := NewReadVideoTool(nil, nil)
|
|
cp := &mockCredentialProvider{apiKey: "test-key"}
|
|
ctx := store.WithAgentContextWindow(context.Background(), 200_000)
|
|
ctx = store.WithAgentMaxTokens(ctx, 32_000)
|
|
|
|
_, _, err := tool.callProvider(ctx, cp, "gemini", "gemini-2.5-flash", map[string]any{
|
|
"prompt": "describe this video", "url": ts.URL, "_provider_type": "gemini",
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "exceeds the maximum limit of 2 GB") {
|
|
t.Fatalf("expected the 2 GB ceiling to reject, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestReadVideo_ProbeVideoURL_RequestsHeadAndTailRanges proves the probe
|
|
// issues both windows against a range-capable server and uses a suffix spec
|
|
// for the tail, matching what the mediabudget estimator needs.
|
|
func TestReadVideo_ProbeVideoURL_RequestsHeadAndTailRanges(t *testing.T) {
|
|
security.SetAllowLoopbackForTest(true)
|
|
defer security.SetAllowLoopbackForTest(false)
|
|
|
|
const total = int64(2_000_000)
|
|
|
|
var ranges []string
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
rangeHeader := r.Header.Get("Range")
|
|
ranges = append(ranges, rangeHeader)
|
|
|
|
start, end, ok := parseTestByteRange(rangeHeader, total)
|
|
if !ok {
|
|
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, total))
|
|
w.WriteHeader(http.StatusPartialContent)
|
|
w.Write(make([]byte, end-start+1))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
_, pinnedIP, err := security.Validate(ts.URL)
|
|
if err != nil {
|
|
t.Fatalf("validate test server URL: %v", err)
|
|
}
|
|
|
|
payload, ok := probeVideoURL(context.Background(), pinnedIP, ts.URL, "video/mp4")
|
|
if !ok {
|
|
t.Fatal("expected probing to succeed against a range-capable server")
|
|
}
|
|
if payload.Size != total {
|
|
t.Fatalf("payload size = %d, want %d", payload.Size, total)
|
|
}
|
|
if len(ranges) != 2 {
|
|
t.Fatalf("got %d range requests, want 2: %v", len(ranges), ranges)
|
|
}
|
|
if ranges[0] != "bytes=0-524287" {
|
|
t.Errorf("head range = %q, want %q", ranges[0], "bytes=0-524287")
|
|
}
|
|
if ranges[1] != "bytes=-524288" {
|
|
t.Errorf("tail range = %q, want %q", ranges[1], "bytes=-524288")
|
|
}
|
|
}
|
|
|
|
// TestReadVideo_ProbeVideoRange_BoundsReadOnUnsupportedRanges covers a server
|
|
// that answers 200 to a ranged request instead of 206: the probe must cap its
|
|
// read at the head window and close immediately rather than pull the whole
|
|
// declared body, even though the requested range (here, a suffix spec far
|
|
// bigger than the head window) would otherwise permit reading more.
|
|
func TestReadVideo_ProbeVideoRange_BoundsReadOnUnsupportedRanges(t *testing.T) {
|
|
security.SetAllowLoopbackForTest(true)
|
|
defer security.SetAllowLoopbackForTest(false)
|
|
|
|
const declared = int64(8 * mediabudget.HeadBytes)
|
|
big := make([]byte, declared)
|
|
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Length", strconv.FormatInt(declared, 10))
|
|
w.WriteHeader(http.StatusOK) // ignores Range: no range support
|
|
w.Write(big)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
_, pinnedIP, err := security.Validate(ts.URL)
|
|
if err != nil {
|
|
t.Fatalf("validate test server URL: %v", err)
|
|
}
|
|
|
|
rangeHeader := fmt.Sprintf("bytes=-%d", mediabudget.TailBytes)
|
|
data, total, isHead := probeVideoRange(context.Background(), pinnedIP, ts.URL, rangeHeader, declared)
|
|
if total != declared {
|
|
t.Fatalf("total = %d, want %d", total, declared)
|
|
}
|
|
if len(data) != mediabudget.HeadBytes {
|
|
t.Fatalf("read %d bytes, want exactly the head window (%d)", len(data), mediabudget.HeadBytes)
|
|
}
|
|
if !isHead {
|
|
t.Fatal("a 200 to a suffix range returned the start of the file and must be reported as head bytes")
|
|
}
|
|
}
|
|
|
|
// TestReadVideo_GeminiURL_RejectsHugeMediaBeforeStreaming covers the front
|
|
// gate: a video the probe measures as an hour long, too much for the agent's
|
|
// window, is refused by the reservation itself, before the real streaming GET
|
|
// (the request with no Range header) is ever issued.
|
|
func TestReadVideo_GeminiURL_RejectsHugeMediaBeforeStreaming(t *testing.T) {
|
|
security.SetAllowLoopbackForTest(true)
|
|
defer security.SetAllowLoopbackForTest(false)
|
|
stubMediaProbes(t, 3600, 1)
|
|
|
|
const total = int64(3_221_225_472) // 3 GB
|
|
|
|
streamHits := 0
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
rangeHeader := r.Header.Get("Range")
|
|
if rangeHeader == "" {
|
|
streamHits++
|
|
w.Header().Set("Content-Length", strconv.FormatInt(total, 10))
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
start, end, ok := parseTestByteRange(rangeHeader, total)
|
|
if !ok {
|
|
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, total))
|
|
w.WriteHeader(http.StatusPartialContent)
|
|
w.Write(make([]byte, end-start+1))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
tool := NewReadVideoTool(nil, nil)
|
|
cp := &mockCredentialProvider{apiKey: "test-key"}
|
|
ctx := store.WithAgentContextWindow(context.Background(), 200_000)
|
|
ctx = store.WithAgentMaxTokens(ctx, 32_000)
|
|
|
|
_, _, err := tool.callProvider(ctx, cp, "gemini", "gemini-2.5-flash", map[string]any{
|
|
"prompt": "describe this video", "url": ts.URL, "_provider_type": "gemini",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected the reservation to reject a video this large under a small window")
|
|
}
|
|
if !strings.Contains(err.Error(), "context window exceeded") {
|
|
t.Fatalf("expected a context window rejection, got: %v", err)
|
|
}
|
|
if streamHits != 0 {
|
|
t.Fatalf("the streaming GET reached the server %d time(s), want 0", streamHits)
|
|
}
|
|
}
|
|
|
|
// stubGeminiVideoStream replaces the upload so a gate-passing URL can be
|
|
// followed all the way to transport without contacting a provider.
|
|
func stubGeminiVideoStream(tool *ReadVideoTool, record func(contentLength int64, mime string)) {
|
|
tool.setStreamToGeminiForTest(func(_ context.Context, _, _, _ string, body io.Reader, contentLength int64, mime string, _ time.Duration) (*providers.ChatResponse, error) {
|
|
io.Copy(io.Discard, body)
|
|
record(contentLength, mime)
|
|
return &providers.ChatResponse{Content: "a short clip"}, nil
|
|
})
|
|
}
|
|
|
|
// TestReadVideo_GeminiURL_MeasuredPayloadReachesTransportInBudget pins the
|
|
// rule that a measurement, once taken, is the charge: a 20-second clip costs
|
|
// 5,260 tokens and must reach transport under a 200k window, with the re-gate
|
|
// after the streaming response forbidden to re-derive anything from its
|
|
// Content-Length and overrule that.
|
|
func TestReadVideo_GeminiURL_MeasuredPayloadReachesTransportInBudget(t *testing.T) {
|
|
security.SetAllowLoopbackForTest(true)
|
|
defer security.SetAllowLoopbackForTest(false)
|
|
stubMediaProbes(t, 20, 1)
|
|
|
|
const total = int64(215_000)
|
|
body := make([]byte, total)
|
|
|
|
var ranges []string
|
|
streamHits := 0
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
rangeHeader := r.Header.Get("Range")
|
|
if rangeHeader == "" {
|
|
streamHits++
|
|
w.Header().Set("Content-Type", "video/mp4")
|
|
w.Header().Set("Content-Length", strconv.FormatInt(total, 10))
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write(body)
|
|
return
|
|
}
|
|
ranges = append(ranges, rangeHeader)
|
|
start, end, ok := parseTestByteRange(rangeHeader, total)
|
|
if !ok {
|
|
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, total))
|
|
w.WriteHeader(http.StatusPartialContent)
|
|
w.Write(body[start : end+1])
|
|
}))
|
|
defer ts.Close()
|
|
|
|
tool := NewReadVideoTool(nil, nil)
|
|
var uploadedLength int64
|
|
var uploadedMIME string
|
|
stubGeminiVideoStream(tool, func(contentLength int64, mime string) {
|
|
uploadedLength, uploadedMIME = contentLength, mime
|
|
})
|
|
|
|
cp := &mockCredentialProvider{apiKey: "test-key"}
|
|
ctx := store.WithAgentContextWindow(context.Background(), 200_000)
|
|
ctx = store.WithAgentMaxTokens(ctx, 32_000)
|
|
|
|
out, _, err := tool.callProvider(ctx, cp, "gemini", "gemini-2.5-flash", map[string]any{
|
|
"prompt": "describe this video", "url": ts.URL, "_provider_type": "gemini",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("a measured 20s clip must reach transport under a 200k window, got: %v", err)
|
|
}
|
|
if string(out) != "a short clip" {
|
|
t.Fatalf("provider output = %q, want the stubbed upload's answer", out)
|
|
}
|
|
if len(ranges) != 2 || ranges[0] != "bytes=0-524287" || ranges[1] != "bytes=-524288" {
|
|
t.Fatalf("unexpected range requests, want head then tail: %v", ranges)
|
|
}
|
|
if streamHits != 1 {
|
|
t.Fatalf("streaming GET reached %d time(s), want 1", streamHits)
|
|
}
|
|
if uploadedLength != total || uploadedMIME != "video/mp4" {
|
|
t.Fatalf("upload got %d bytes of %q, want %d of video/mp4", uploadedLength, uploadedMIME, total)
|
|
}
|
|
}
|
|
|
|
// TestReadVideo_GeminiURL_RangeRefusingOriginBlockedBeforeStreaming is the
|
|
// regression test for the cap bypass. A WAF or CDN that answers 403 to any
|
|
// ranged read leaves both probes empty-handed, so nothing about this 1.9 GB
|
|
// payload can be measured and the streaming GET must never be issued at all.
|
|
func TestReadVideo_GeminiURL_RangeRefusingOriginBlockedBeforeStreaming(t *testing.T) {
|
|
security.SetAllowLoopbackForTest(true)
|
|
defer security.SetAllowLoopbackForTest(false)
|
|
|
|
const total = int64(1_900_000_000)
|
|
|
|
headHits, streamHits := 0, 0
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("Range") != "" {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
return
|
|
}
|
|
if r.Method == http.MethodHead {
|
|
headHits++
|
|
w.Header().Set("Content-Length", strconv.FormatInt(total, 10))
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
streamHits++
|
|
w.Header().Set("Content-Length", strconv.FormatInt(total, 10))
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer ts.Close()
|
|
|
|
tool := NewReadVideoTool(nil, nil)
|
|
cp := &mockCredentialProvider{apiKey: "test-key"}
|
|
ctx := store.WithAgentContextWindow(context.Background(), 200_000)
|
|
ctx = store.WithAgentMaxTokens(ctx, 32_000)
|
|
|
|
_, _, err := tool.callProvider(ctx, cp, "gemini", "gemini-2.5-flash", map[string]any{
|
|
"prompt": "describe this video", "url": ts.URL, "_provider_type": "gemini",
|
|
})
|
|
if !errors.Is(err, mediabudget.ErrDurationUnmeasurable) {
|
|
t.Fatalf("expected ErrDurationUnmeasurable for an unmeasurable 1.9 GB payload, got %T: %v", err, err)
|
|
}
|
|
if headHits != 1 {
|
|
t.Fatalf("HEAD reached the origin %d time(s), want 1", headHits)
|
|
}
|
|
if streamHits != 0 {
|
|
t.Fatalf("the streaming request reached the origin %d time(s), want 0", streamHits)
|
|
}
|
|
}
|
|
|
|
// TestReadVideo_GeminiURL_RefusesBeforeFetchingAnUnmeasurableOrigin covers an
|
|
// origin that reveals nothing before the stream: no ranged read, no HEAD. A
|
|
// payload with neither size nor bytes can never be priced, so the refusal
|
|
// belongs at the front gate, before a GET is made to an attacker-chosen URL.
|
|
func TestReadVideo_GeminiURL_RefusesBeforeFetchingAnUnmeasurableOrigin(t *testing.T) {
|
|
security.SetAllowLoopbackForTest(true)
|
|
defer security.SetAllowLoopbackForTest(false)
|
|
|
|
const total = int64(1_900_000_000)
|
|
|
|
streamHits := 0
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("Range") != "" || r.Method == http.MethodHead {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
return
|
|
}
|
|
streamHits++
|
|
w.Header().Set("Content-Length", strconv.FormatInt(total, 10))
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write(make([]byte, 4096))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
tool := NewReadVideoTool(nil, nil)
|
|
uploads := 0
|
|
stubGeminiVideoStream(tool, func(int64, string) { uploads++ })
|
|
|
|
cp := &mockCredentialProvider{apiKey: "test-key"}
|
|
ctx := store.WithAgentContextWindow(context.Background(), 200_000)
|
|
ctx = store.WithAgentMaxTokens(ctx, 32_000)
|
|
|
|
_, _, err := tool.callProvider(ctx, cp, "gemini", "gemini-2.5-flash", map[string]any{
|
|
"prompt": "describe this video", "url": ts.URL, "_provider_type": "gemini",
|
|
})
|
|
if !errors.Is(err, mediabudget.ErrNoProbeableBytes) {
|
|
t.Fatalf("expected the front gate to refuse an unpriceable payload, got %T: %v", err, err)
|
|
}
|
|
if streamHits != 0 {
|
|
t.Fatalf("the origin was fetched %d time(s) for a payload nothing could price, want 0", streamHits)
|
|
}
|
|
if uploads != 0 {
|
|
t.Fatalf("the body was forwarded to the upload %d time(s), want 0", uploads)
|
|
}
|
|
}
|
|
|
|
// TestReadVideo_GeminiURL_StreamingGetSurfacesOriginStatus covers the
|
|
// streaming GET's non-2xx branch (read_video_resolve.go:274-280): an origin
|
|
// that answers both ranged probes with real, measurable bytes is priced and
|
|
// let through the front gate, and when the streaming GET itself then fails,
|
|
// the error must name the origin's status code.
|
|
func TestReadVideo_GeminiURL_StreamingGetSurfacesOriginStatus(t *testing.T) {
|
|
security.SetAllowLoopbackForTest(true)
|
|
defer security.SetAllowLoopbackForTest(false)
|
|
stubMediaProbes(t, 20, 1)
|
|
|
|
const total = int64(215_000)
|
|
body := make([]byte, total)
|
|
|
|
streamHits := 0
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
rangeHeader := r.Header.Get("Range")
|
|
if rangeHeader == "" {
|
|
streamHits++
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
start, end, ok := parseTestByteRange(rangeHeader, total)
|
|
if !ok {
|
|
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, total))
|
|
w.WriteHeader(http.StatusPartialContent)
|
|
w.Write(body[start : end+1])
|
|
}))
|
|
defer ts.Close()
|
|
|
|
tool := NewReadVideoTool(nil, nil)
|
|
cp := &mockCredentialProvider{apiKey: "test-key"}
|
|
ctx := store.WithAgentContextWindow(context.Background(), 200_000)
|
|
ctx = store.WithAgentMaxTokens(ctx, 32_000)
|
|
|
|
_, _, err := tool.callProvider(ctx, cp, "gemini", "gemini-2.5-flash", map[string]any{
|
|
"prompt": "describe this video", "url": ts.URL, "_provider_type": "gemini",
|
|
})
|
|
if err == nil || !strings.Contains(err.Error(), "status code 500") {
|
|
t.Fatalf("expected the streaming GET's status check to reject, got: %v", err)
|
|
}
|
|
if streamHits != 1 {
|
|
t.Fatalf("streaming GET reached %d time(s), want 1", streamHits)
|
|
}
|
|
}
|