Skip to content

fix(embed): surface Voyage AI total_tokens as prompt token count - #14

Merged
GottZ merged 7 commits into
GottZ:rootfrom
TurgutKural:fix/voyage-embed-total-tokens
Aug 2, 2026
Merged

fix(embed): surface Voyage AI total_tokens as prompt token count#14
GottZ merged 7 commits into
GottZ:rootfrom
TurgutKural:fix/voyage-embed-total-tokens

Conversation

@TurgutKural

Copy link
Copy Markdown
Contributor

Problem

Voyage AI reports usage.total_tokens on the /v1/embeddings endpoint instead of the OpenAI-standard usage.prompt_tokens:

// Voyage AI response
{"data": [{"embedding": [...]}], "usage": {"total_tokens": 8}}

// OpenAI-standard response
{"data": [{"embedding": [...]}], "usage": {"prompt_tokens": 8, "total_tokens": 8}}

The Go decoder (openAIEmbedResponse.Usage) only had a prompt_tokens field, so PromptTokens was always 0 for Voyage. Downstream effects:

  • llmlog rows carry no token accounting — every embed row shows —/— in the tok in/out column
  • Dispatch usage metering broken — embed calls charge 0 tokens into the lease (MW22/C1)
  • Status-page embed aggregation empty — no token data to aggregate
  • Cost tracking impossible — cannot answer "how much does embed cost per month" from the DB

Same wire-format mismatch class as the rerank data/results fix (PR #13): Voyage follows their own OpenAPI schema, not the OpenAI wire format the client was written for.

Fix

internal/embed/embed.go:

  1. TotalTokens field added to openAIEmbedResponse.Usage struct (json:"total_tokens")
  2. Fallback in embedOpenAI: when PromptTokens == 0 && TotalTokens > 0, use TotalTokens

Precedence preserved: prompt_tokens wins when both are present (OpenAI sends both).

Tests

6 new tests in wire_test.go:

Test Covers
TestEmbedOpenAI_VoyageTotalTokensFallback Core regression: total_tokens surfaced as prompt count
TestEmbedOpenAI_PromptTokensPreferred Both fields present → prompt_tokens wins
TestEmbedOpenAI_PromptTokensOnly OpenAI-compat servers unchanged
TestEmbedOpenAI_NoUsage No usage field → 0 (uncharged, C1 doctrine)
TestEmbedOpenAI_EmptyUsage Both fields zero → 0
TestEmbedOpenAI_VectorStillCorrect Token fallback does not disturb the embedding vector
=== RUN   TestEmbedOpenAI_VoyageTotalTokensFallback
--- PASS
=== RUN   TestEmbedOpenAI_PromptTokensPreferred
--- PASS
=== RUN   TestEmbedOpenAI_PromptTokensOnly
--- PASS
=== RUN   TestEmbedOpenAI_NoUsage
--- PASS
=== RUN   TestEmbedOpenAI_EmptyUsage
--- PASS
=== RUN   TestEmbedOpenAI_VectorStillCorrect
--- PASS
PASS (38/38 total in embed package)

Regression

Full go test ./internal/... run: all packages pass except 2 pre-existing environment failures unrelated to this change:

  • internal/cli: git binary not found in golang:1.26-alpine test container
  • internal/events: TestRebuildViaWorker_TimeoutKillsHangingChild flaky goroutine deadlock

go vet ./internal/embed/ clean.

TurgutKural and others added 6 commits August 2, 2026 14:39
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
@GottZ
GottZ force-pushed the fix/voyage-embed-total-tokens branch from b849d33 to 1e83c24 Compare August 2, 2026 12:56
@GottZ

GottZ commented Aug 2, 2026

Copy link
Copy Markdown
Owner

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:

  • b816487 — the precedence fixture is now discriminating. TestEmbedOpenAI_PromptTokensPreferred fixed both usage fields to the same value (10/10), so inverting the precedence guard survived all six tests — the exact lesson from fix(rerank): support Voyage AI response format (data field + total_tokens) #13's rerank waves. The fixture now sets total_tokens:99 against prompt_tokens:10 (the inversion mutation reds 4 assertions), and VectorStillCorrect asserts the token count too so it kills mutants on its own.
  • abe2d21 — metering visibility, mirroring what the rerank fix got in v4.23.0. The fallback silently flips total_tokens-only backends from "uncharged" to a real charge in the fairness meter (the single production call site charges the dispatch lease; llmlog and the status rollup start carrying values where they were NULL). One-time INFO on first engagement + the operations.md meter paragraph now says "rerank and embed backends" — it had become false with this PR.
  • 6d118dc — negative prompt_tokens is a sentinel, not a charge. == 0 blocked the fallback for a negative value and passed the negative through to the meter, while negative total_tokens was already discarded — both packages now guard with <= 0, applied symmetrically to embed and rerank so the twins stay byte-consistent.

Deliberately not built here: decode-tolerance for malformed usage counters (a non-integral total_tokens is now decode-fatal, but the adversarial pass showed the same strictness pre-existed for prompt_tokens on base — a symmetric json.Number follow-up for both packages is on the backlog, not this PR's scope), and Voyage's input_type parameter for the embed request (separate feature; note that external embed backends are gated behind metadata.embed_equivalence_verified regardless — foreign quantization corrupts the shared vector space).

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
@GottZ
GottZ merged commit 9eff7de into GottZ:root Aug 2, 2026
9 checks passed
GottZ added a commit that referenced this pull request Aug 2, 2026
@GottZ

GottZ commented Aug 2, 2026

Copy link
Copy Markdown
Owner

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants