Files
thotam ce9e7af3fb fix(tools): price out-of-band media by measured duration, not by its bytes (#1564)
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.
2026-09-12 10:31:12 +07:00

182 lines
6.7 KiB
Go

package caps
import (
"context"
"errors"
"strings"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
// stubGuardProvider records whether Chat was invoked and returns a canned reply.
type stubGuardProvider struct {
called bool
}
func (p *stubGuardProvider) Name() string { return "stubguard" }
func (p *stubGuardProvider) DefaultModel() string { return "claude-3-5-sonnet" }
func (p *stubGuardProvider) Chat(_ context.Context, _ providers.ChatRequest) (*providers.ChatResponse, error) {
p.called = true
return &providers.ChatResponse{Content: "ok"}, nil
}
func (p *stubGuardProvider) ChatStream(_ context.Context, _ providers.ChatRequest, _ func(providers.StreamChunk)) (*providers.ChatResponse, error) {
p.called = true
return &providers.ChatResponse{Content: "ok"}, nil
}
// bigMessage returns a user message whose complete-input count is at least
// target tokens, using the fixed model/provider-independent budget counter.
func bigMessage(t *testing.T, target int) providers.Message {
t.Helper()
low, high := 1, target*8
for low < high {
mid := low + (high-low)/2
msg := providers.Message{Role: "user", Content: strings.Repeat("word ", mid)}
count, err := contextGuardCounter.CountMessages([]providers.Message{msg})
if err != nil {
t.Fatal(err)
}
if count < target {
low = mid + 1
} else {
high = mid
}
}
return providers.Message{Role: "user", Content: strings.Repeat("word ", low)}
}
// TestCapsChat_NonAgentCallSkipsWindowGuard proves a call with no AgentID and no
// agent budget is NOT forced against any model window: it passes straight to the
// provider even when huge. Model window is not a budget authority.
func TestCapsChat_NonAgentCallSkipsWindowGuard(t *testing.T) {
var svc *Service // nil service => Lite path
prov := &stubGuardProvider{}
// ~200k tokens: would blow any model window, but this is not agent-scoped.
msg := bigMessage(t, 200_000)
req := providers.ChatRequest{
Model: "gpt-4o",
Messages: []providers.Message{msg},
Options: map[string]any{providers.OptMaxTokens: 4096},
}
if _, err := svc.Chat(context.Background(), prov, req, ChatOptions{Purpose: "non-agent"}); err != nil {
t.Fatalf("non-agent call must not be window-guarded, got %v", err)
}
if !prov.called {
t.Fatal("provider.Chat should have been called for a non-agent call")
}
}
// TestCapsChat_AgentMissingBudgetUsesSafeDefault proves an agent-scoped call via
// Service.Chat (AgentID set) that reaches the gate WITHOUT a wired budget does
// NOT fail closed with a wiring error. These are background/utility calls (e.g.
// vault.classify) that run outside an agent turn; failing closed silently
// degrades enrichment. Instead a safe default budget is filled and the call
// reaches transport. NOTE: the agent-RUN tool path guards via GuardContextWindow
// directly (see internal/tools/usage_caps.go) and still fails closed — this
// relaxation is scoped to Service.Chat only.
func TestCapsChat_AgentMissingBudgetUsesSafeDefault(t *testing.T) {
var svc *Service
prov := &stubGuardProvider{}
req := providers.ChatRequest{
Model: "gpt-4o",
Messages: []providers.Message{{Role: "user", Content: "hello"}},
Options: map[string]any{providers.OptMaxTokens: 1024},
}
_, err := svc.Chat(context.Background(), prov, req, ChatOptions{
AgentID: uuid.New(),
Purpose: "vault.classify",
})
if err != nil {
var wiringErr *AgentBudgetWiringError
if errors.As(err, &wiringErr) {
t.Fatalf("background call must not fail closed with a wiring error: %v", err)
}
t.Fatalf("unexpected error from safe-default background call: %v", err)
}
if !prov.called {
t.Fatal("provider.Chat should have been called after filling a safe default budget")
}
}
// TestCapsChat_AgentBudgetSameRequestDiffersByWindow is the core agent-only
// contract test: the SAME request is blocked for a 128k agent and allowed for a
// 200k agent, decided only by the agent's configured window, never the model's.
func TestCapsChat_AgentBudgetSameRequestDiffersByWindow(t *testing.T) {
var svc *Service
agentID := uuid.New()
// ~140k tokens: fits a 200k window, exceeds a 128k window.
msg := bigMessage(t, 140_000)
req := providers.ChatRequest{
Model: "gpt-4o", // 128k model window — irrelevant to the decision
Messages: []providers.Message{msg},
Options: map[string]any{providers.OptMaxTokens: 4096},
}
// 128k agent: blocked.
prov128 := &stubGuardProvider{}
_, err := svc.Chat(context.Background(), prov128, req, ChatOptions{
AgentID: agentID,
Purpose: "cap-128k",
AgentContextWindow: 128_000,
AgentMaxTokens: 8_192,
})
if err == nil {
t.Fatal("128k agent: expected abort for 140k request")
}
if prov128.called {
t.Fatal("128k agent: provider must NOT be called when the guard aborts (transport calls must be 0)")
}
var ctxErr *ContextWindowExceededError
if !errors.As(err, &ctxErr) {
t.Fatalf("expected *ContextWindowExceededError, got %T: %v", err, err)
}
if ctxErr.ContextWindow != 128_000 {
t.Fatalf("guard reported window %d, want 128000 (agent window, not model)", ctxErr.ContextWindow)
}
// 200k agent: allowed, provider reached.
prov200 := &stubGuardProvider{}
if _, err := svc.Chat(context.Background(), prov200, req, ChatOptions{
AgentID: agentID,
Purpose: "cap-200k",
AgentContextWindow: 200_000,
AgentMaxTokens: 8_192,
}); err != nil {
t.Fatalf("200k agent: expected allow for 140k request, got %v", err)
}
if !prov200.called {
t.Fatal("200k agent: provider should have been called")
}
}
// TestGuardContextWindowWithMediaTokens_AddsMediaToCompleteInput proves an
// out-of-band payload counts toward completeInput even though the ChatRequest
// never shows it. The same request passes without the media charge and fails
// with it.
func TestGuardContextWindowWithMediaTokens_AddsMediaToCompleteInput(t *testing.T) {
req := providers.ChatRequest{
Model: "gemini-2.0-flash",
Messages: []providers.Message{{Role: "user", Content: "describe this video"}},
Options: map[string]any{providers.OptMaxTokens: 1000},
}
budget := AgentBudget{ContextWindow: 5_000, MaxTokens: 1_000}
if err := GuardContextWindowWithMediaTokens(req, "gemini", req.Model, "tool:read_video", budget, 0); err != nil {
t.Fatalf("text-only request must fit a 5k window: %v", err)
}
err := GuardContextWindowWithMediaTokens(req, "gemini", req.Model, "tool:read_video", budget, 4_500)
if err == nil {
t.Fatal("expected abort: 4500 media tokens plus a 1000 output reserve exceed a 5k window")
}
var exceeded *ContextWindowExceededError
if !errors.As(err, &exceeded) {
t.Fatalf("expected *ContextWindowExceededError, got %T: %v", err, err)
}
if exceeded.InputTokens < 4_500 {
t.Fatalf("InputTokens = %d, want at least the 4500 media tokens", exceeded.InputTokens)
}
}