fix(embed): surface Voyage AI total_tokens as prompt token count - #14
Conversation
Voyage AI reports usage.total_tokens on the /v1/embeddings endpoint instead of the OpenAI-standard usage.prompt_tokens. The Go decoder left PromptTokens at 0 on every Voyage embed call, so llmlog rows carried no token accounting — cost tracking, dispatch usage metering, and the status-page embed aggregation were all silently broken. Same wire-format mismatch as the rerank data/results fix (PR GottZ#13). Changes: - Add TotalTokens field to openAIEmbedResponse.Usage struct - Fall back to TotalTokens when PromptTokens is 0 in embedOpenAI - 6 new tests: total_tokens fallback, prompt_tokens preferred, prompt_tokens-only (OpenAI compat), no usage, empty usage, vector correctness unaffected All 38 embed tests pass. Full internal/... regression clean (2 pre-existing env failures in cli/events unrelated to this change).
TestEmbedOpenAI_PromptTokensPreferred fixed both usage fields to the same value (10/10) — the assertion could not tell which field won, and inverting the precedence guard survived all six new tests (reproduced: suite stays green under the mutation). Same lesson as the rerank wave in PR GottZ#13. The fixture now sets total_tokens:99 against prompt_tokens: 10; the inversion mutation reds 4 assertions. TestEmbedOpenAI_VectorStillCorrect additionally asserts the token count (99) so it kills mutants on its own instead of always firing together with the fallback test. Finding: review dimensions "tests"/"claims"/"kern" (CONFIRMED via mutation probe, three independent finders). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MP6ZWjHgySC74PfJJKjwBX
The total_tokens fallback silently moves total_tokens-only backends from charge=0 + uncharged_calls++ to a real token charge in the MW22 fairness window — the embed twin of the semantics jump the rerank pendant made visible in 4970856. The single production call site (embedcache.go:258 → lease.ReportUsage) charges the lease, llmlog embed rows and the D1a status rollup start carrying values where they were NULL before. Mirror the 4970856 pattern: one-time INFO when the fallback first engages (embeds run per query and per backfill batch — per-call INFO would be noise), extend the MW22 paragraph in docs/operations.md from "rerank backends" to "rerank and embed backends" (the sentence had become false with this PR), and note the embed side of the Voyage dialect in docs/architecture.md. Findings: review dimensions "downstream" GottZ#1/GottZ#2 + "claims" GottZ#2/GottZ#3 (all CONFIRMED — probe shows Base ptoks=0/uncharged, HEAD charged for the identical Voyage-shaped response). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MP6ZWjHgySC74PfJJKjwBX
The fallback guards read `promptTokens == 0`: a backend emitting a negative prompt_tokens (sentinel/overflow artifact) blocked the total_tokens fallback AND flowed the negative value into lease.ReportUsage, while a negative total_tokens was already correctly discarded by the `> 0` check — an asymmetry between the two usage fields. Both packages now guard with `<= 0`, so a negative sentinel falls through to the measured total_tokens (or to 0/uncharged when none exists). No behaviour change for any real wire format observed so far; one pin test per package. Finding: review dimension "kern" GottZ#4 (CONFIRMED — hardening, applied symmetrically to embed and the rerank pendant so the two stay byte-consistent). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MP6ZWjHgySC74PfJJKjwBX
…l_tokens) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MP6ZWjHgySC74PfJJKjwBX
b849d33 to
1e83c24
Compare
|
Reviewed with the same depth as #13 (4 finder dimensions + adversarial per-finding verification, 22 agents) — and this one came in noticeably cleaner: correct diagnosis, the precedence preserved, the godot fix already on board. The embed twin of the rerank dialect fix ships with your approach intact; the branch was rebased onto current root (v4.23.0), nothing of yours was lost. Three hardening waves on top:
Deliberately not built here: decode-tolerance for malformed usage counters (a non-integral Full suite (37 packages) and lint are green on the rebased branch. CI runs next; merge follows once it confirms. |
…gate TestMCPBodyCapOnProductionMount_Integration/undersize_authenticated_ stores fixed a 900 KiB content and expected a successful store. That fixture predates wave B5 (8c597e6), which routed the direct MCP store arm through the full REST write-gate chain — blockSizeLimit rejects content > 50 KiB, so once both waves merged the "legitimate" call became a size_cap reject: deterministic red, first surfaced by the full integration suite on the v4.23.0 root push (run 30745915262) and reproduced locally. Not introduced by this PR — it rides here so the branch CI can go green and the fix reaches root with the merge, same route as the 15m-timeout fix on GottZ#13. The fixture now stores 45 KiB: a hair under the content gate, which is the actual upper bound of "large but allowed" since B5 — still probing what this test guards (the 1 MiB transport cap must not false-positive on legitimate calls). Comment records the coupling. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MP6ZWjHgySC74PfJJKjwBX
…ens) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MP6ZWjHgySC74PfJJKjwBX
|
Merged and released in v4.23.1 — that makes five merged PRs from you across the whole Voyage dialect surface. Clean submission this round: precedence right, godot handled, no stale base. The only red on CI turned out to be a pre-existing conflict between two of our own hardening waves (B5 gate parity vs. the B6 body-cap fixture), fixed as part of this release — nothing in your change. |
Problem
Voyage AI reports
usage.total_tokenson the/v1/embeddingsendpoint instead of the OpenAI-standardusage.prompt_tokens:The Go decoder (
openAIEmbedResponse.Usage) only had aprompt_tokensfield, soPromptTokenswas always 0 for Voyage. Downstream effects:—/—in the tok in/out columnSame wire-format mismatch class as the rerank
data/resultsfix (PR #13): Voyage follows their own OpenAPI schema, not the OpenAI wire format the client was written for.Fix
internal/embed/embed.go:TotalTokensfield added toopenAIEmbedResponse.Usagestruct (json:"total_tokens")embedOpenAI: whenPromptTokens == 0 && TotalTokens > 0, useTotalTokensPrecedence preserved:
prompt_tokenswins when both are present (OpenAI sends both).Tests
6 new tests in
wire_test.go:TestEmbedOpenAI_VoyageTotalTokensFallbacktotal_tokenssurfaced as prompt countTestEmbedOpenAI_PromptTokensPreferredprompt_tokenswinsTestEmbedOpenAI_PromptTokensOnlyTestEmbedOpenAI_NoUsageTestEmbedOpenAI_EmptyUsageTestEmbedOpenAI_VectorStillCorrectRegression
Full
go test ./internal/...run: all packages pass except 2 pre-existing environment failures unrelated to this change:internal/cli:gitbinary not found ingolang:1.26-alpinetest containerinternal/events:TestRebuildViaWorker_TimeoutKillsHangingChildflaky goroutine deadlockgo vet ./internal/embed/clean.