Skip to content

fix(rerank): support Voyage AI response format (data field + total_tokens) - #13

Merged
GottZ merged 10 commits into
GottZ:rootfrom
TurgutKural:fix/voyage-rerank-data-format
Aug 2, 2026
Merged

fix(rerank): support Voyage AI response format (data field + total_tokens)#13
GottZ merged 10 commits into
GottZ:rootfrom
TurgutKural:fix/voyage-rerank-data-format

Conversation

@TurgutKural

Copy link
Copy Markdown
Contributor

Problem

Voyage AI returns reranking results under "data" (per their OpenAPI RerankingObject schema) instead of "results" (the cohere/llama.cpp wire format the client was written for).

The Go JSON decoder silently left Results empty on every Voyage call, producing:

cross-encoder rerank: rerank: result count mismatch: got 0 scores for 50 documents

Production evidence: 6/6 real Voyage rerank calls failed with this error (both rerank-2.5 and rerank-2.5-lite). The fail-open path silently degraded every query to un-reranked RRF order. Local llama.cpp (bge-reranker-v2-m3) was unaffected — it speaks cohere format natively.

Additionally, Voyage reports usage as total_tokens while llama.cpp uses prompt_tokens, so the usage metering path also needed a fallback.

Fix

internal/rerank/rerank.go:

  1. Data field added to rerankResponse (json:"data") — Voyage's OpenAPI field name
  2. Fallback logic: after decode, if Results is empty and Data is non-empty, use Data
  3. TotalTokens field added to the Usage struct; when PromptTokens is 0, fall back to TotalTokens

Precedence is preserved: "results" wins over "data" when both are present (cohere/llama.cpp path unchanged).

Tests

7 new tests added to rerank_test.go:

Test Covers
TestScore_VoyageDataFallback Core regression: "data" field decoded, scores + total_tokens returned
TestScore_VoyageDataReAlignsByIndex Index re-alignment works through the "data" path
TestScore_PrefersResultsOverData "results" takes precedence when both fields present
TestScore_VoyageTotalTokensFallback total_tokenspromptTokens fallback
TestScore_VoyageDataRejectsCountMismatch Count validation enforced on "data" path
TestScore_VoyageDataRejectsDuplicateIndex Duplicate index validation on "data" path
TestScore_NeitherResultsNorData Error when neither field present
=== RUN   TestScore_VoyageDataFallback
--- PASS
=== RUN   TestScore_VoyageDataReAlignsByIndex
--- PASS
=== RUN   TestScore_PrefersResultsOverData
--- PASS
=== RUN   TestScore_VoyageTotalTokensFallback
--- PASS
=== RUN   TestScore_VoyageDataRejectsCountMismatch
--- PASS
=== RUN   TestScore_VoyageDataRejectsDuplicateIndex
--- PASS
=== RUN   TestScore_NeitherResultsNorData
--- PASS
PASS (14/14 total, go vet clean)

Production verification

Post-deploy, 2/2 Voyage rerank calls succeeded (~690ms each, zero errors), confirmed via context_llm_log. Pre-fix: 0/6 succeeded.

TurgutKural and others added 9 commits August 2, 2026 12:06
…kens)

Voyage AI returns reranking results under "data" (OpenAPI RerankingObject
schema) instead of "results" (cohere/llama.cpp format). The Go decoder
left Results empty, causing "result count mismatch: got 0 scores for N
documents" on every Voyage rerank call — 6/6 real calls failed.

Changes:
- Add Data field to rerankResponse struct (json:"data")
- Fall back to Data when Results is empty after decode
- Add TotalTokens to Usage struct; use as fallback when PromptTokens is 0
- 7 new tests: data fallback, index re-alignment via data, results-over-data
  precedence, total_tokens fallback, count mismatch via data, duplicate index
  via data, neither-results-nor-data error

All 14 tests pass (7 existing + 7 new). go vet clean.
Verified in production: 2/2 Voyage rerank calls succeed post-fix (690ms).
Score()'s documented contract returns RAW LOGITS — the single consumer
(rrf.RerankCrossEncoder) sigmoids every score before blending. Voyage's
relevance_score is already a calibrated [0,1] relevance (all published
values are quantized probabilities, e.g. 0.94140625 = 241/256), so the
data-path fallback fed probabilities into a second sigmoid: the rerank
signal compressed into [0.5,0.73] (~29% of the llama.cpp span) and at
blend_weight < 1 — the value validate.go itself recommends with graph
expansion — RRF outvoted the reranker, producing measured rank
inversions against an identical relevance verdict expressed as logits.

Map "data" scores through logit(p) = ln(p/(1-p)) at the wire boundary:
the container name is coupled to the score domain (data ⇒ Voyage ⇒
calibrated probability), the downstream sigmoid reconstructs p exactly,
and the raw-logit contract now holds for every backend. Endpoint values
0 and 1 clamp to large finite logits (±Inf would poison the blend
arithmetic); a data score outside [0,1] breaks the documented Voyage
schema and errors → caller fails open, consistent with the strict index
validation.

Existing Voyage tests updated to expect logits; three new tests pin the
sigmoid round-trip, endpoint clamping, and out-of-range rejection. The
decorative section comment now satisfies godot (pre-commit lints the
staged file, so the fix rides in this wave).

Finding: review dimension "kern" GottZ#1 (CONFIRMED via Go probe over the
real RerankCrossEncoder path: rank inversion at blend=0.5, span
compression 0.2952 vs 0.9490 at blend=1.0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MP6ZWjHgySC74PfJJKjwBX
The validation cascade checks Index coverage only — an entry without a
relevance_score field decoded to Go's zero value and passed every gate.
Sibling rerank dialects name the score field "score" (mixedbread, some
gateways), so pointing rerank.host at such a backend yielded all-zero
scores with err=nil: sigmoid(0)=0.5 for every document, rerankNorm 1.0
across the board, reranker silently neutralized while RerankWire
reports Wired=true and ReportUsage still charges the lease. The "data"
fallback newly exposes this lattice to the data-shaped backend class;
the same gap pre-existed on the "results" path.

Decode RelevanceScore as *float64 and reject nil entries — error →
caller fails open and keeps the RRF order, with a log line instead of
a silent no-op, matching the documented index-validation contract.

New TestScore_MissingRelevanceScoreFailsOpen covers both container
paths ("results" guards the pre-existing lattice, "data" the one the
fallback newly reaches).

Finding: review dimension "kern" GottZ#2 (PARTIAL — core confirmed by
differential probe Base vs HEAD; the pre-existing results-path gap is
closed by the same pointer check).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MP6ZWjHgySC74PfJJKjwBX
A response carrying neither container — unknown backend dialect, a
gateway error object behind HTTP 200, a renamed results field — fell
through to "result count mismatch: got 0 scores for N documents": a
message claiming a counting problem where a schema break happened. The
PR's own commit body documents this exact message masking the Voyage
incident for six production calls, and the fail-open path surfaces
nothing but this error string (query.go logs "rerank failed, using
original order" + the error).

Buffer the response body (1 MiB ceiling — rerank responses are a few
KB) and return a dedicated error naming both expected fields plus a
256-byte body snippet, so the operator's only trace identifies the
dialect instead of miscounting it. "count mismatch" remains exclusive
to genuine coverage gaps (top_n truncation etc.).

TestScore_NeitherResultsNorData now pins the dedicated message; new
TestScore_SchemaErrorEchoesBodySnippet covers the HTTP-200 gateway
error case.

Finding: review dimension "claims" GottZ#3 (PARTIAL — message class
confirmed against three dialect probes on HEAD).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MP6ZWjHgySC74PfJJKjwBX
…results

"data" is the generic OpenAI-style list container, not a rerank-specific
name. Declaring it as []rerankResult made the strict top-level decode
fail on any backend that serves valid "results" alongside a non-array
"data" field — a regression surface for the primary llama.cpp path over
a field we do not even use in that case.

Keep Data as json.RawMessage and unmarshal it only when "results" is
empty and the fallback actually engages. A non-array "data" without
"results" now yields a decode error naming the field and echoing the
body snippet, consistent with the schema-break error of the previous
wave. "data": null decodes to zero entries and falls through to that
same dedicated error.

Two new tests pin the llama.cpp non-regression (valid results + foreign
data object) and the data-only decode failure.

Findings: review dimensions "kern" GottZ#3 + "claims" GottZ#2 (both CONFIRMED),
"tests" GottZ#3 (non-regression coverage for the primary path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MP6ZWjHgySC74PfJJKjwBX
…tracts

Three coverage gaps the review's mutation probes exposed:

- TestScore_PrefersResultsOverData asserted "prompt_tokens preferred
  over total_tokens" while its fixture carried no total_tokens at all —
  removing the precedence guard or inverting it survived the entire
  suite (both mutations reproduced against rerank AND rrf). The fixture
  now sets both fields with differing values, making the existing
  assertion actually discriminating.
- The documented "absent usage ⇒ 0 ⇒ uncharged, never an estimate"
  contract had no test; TestScore_UsageAbsentChargesNothing pins it.
- Voyage turns the bearer header into a load-bearing path, yet no test
  ever passed an apiKey; TestScore_AuthorizationHeader covers both the
  configured-key and the no-key (local sidecar) case.

Finding: review dimension "tests" GottZ#1 (CONFIRMED — mutations A and B
survive the pre-wave suite, both killed by the both-set fixture),
plus "tests" GottZ#4 and GottZ#5.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MP6ZWjHgySC74PfJJKjwBX
The total_tokens fallback is backend-agnostic, not Voyage-gated: any
backend whose usage carries total_tokens without prompt_tokens — Jina-
shaped servers that worked fine over the results path included —
silently moves from charge=0 + uncharged_calls++ to a real token charge
in the MW22 fairness window. Substantively correct (total_tokens is a
measurement, not an estimate — C1-conformant), but a silent semantics
jump in a meter documented as "missing usage charges 0".

Log a one-time INFO when the fallback first engages (rerank runs per
query; per-call INFO would be noise) and extend the MW22 paragraph in
docs/operations.md so anyone calibrating against the historic token
curve finds the switch.

Finding: review dimension "downstream" GottZ#2 (CONFIRMED — probe shows
identical Jina-shaped response: Base ptoks=0/uncharged, HEAD
ptoks=815/charged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MP6ZWjHgySC74PfJJKjwBX
The rerank-sidecar paragraph described the client as cohere-only local.
Record the Voyage dialect support (data container, total_tokens usage),
the logit mapping that keeps the score contract backend-invariant, and
the fail-open validation added around it.

Finding: review dimension "downstream" GottZ#5 (stale docs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MP6ZWjHgySC74PfJJKjwBX
@GottZ
GottZ force-pushed the fix/voyage-rerank-data-format branch from d7b494b to c23cb91 Compare August 2, 2026 10:35
@GottZ

GottZ commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Thanks — this is exactly the kind of contribution we want: a real production failure, traced to the wire format, fixed minimally with the cohere path's precedence preserved, and brought with a proper test story (httptest against the real decode path, both validation gates re-covered through the new container). The diagnosis was correct and complete; your fallback approach ships as-is.

The review (4 finder dimensions + adversarial per-finding verification) surfaced one deeper issue your fix made reachable, plus hardening around it. All waves keep your prefer-results-fall-back-to-data structure:

  • e328f9a — Voyage scores are calibrated [0,1] probabilities, not logits. Score()'s contract returns raw logits, and the single consumer sigmoids every score before blending. Voyage's already-calibrated values got sigmoided a second time, compressing the rerank signal into [0.5, 0.73] — and at blend_weight < 1 (the value our own validator recommends with graph expansion) RRF outvoted the reranker, with measured rank inversions against the identical relevance verdict expressed as logits. The wave maps data scores through logit(p) at the wire boundary, so the downstream sigmoid reconstructs your Voyage probabilities exactly. Your rebased tests now assert the logit values; a round-trip test pins the contract.
  • 131f3f1 — absent relevance_score now fails open instead of scoring 0. Sibling data-shaped dialects name the score field score; that body used to pass every gate and neutralize the reranker silently (all-zero logits ⇒ uniform 0.5 ⇒ RRF order, telemetry green). Pointer decode + nil check turn it into the documented fail-open error — this also closes the same pre-existing gap on the results path.
  • f9ba0ff — schema breaks are no longer reported as "count mismatch". Your PR body itself documents that message masking 6 production calls. Neither-container responses now get a dedicated error naming both fields plus a body snippet (the body is buffered, 1 MiB cap).
  • 1f3b8e7data decodes lazily (json.RawMessage), so a backend serving valid results next to a non-array data field can't fail the whole decode; llama.cpp non-regression is pinned by test.
  • 5b6a19a — test hardening: the prompt_tokens-over-total_tokens precedence was asserted but not exercised (no fixture set both fields — two mutations survived the suite); the both-set fixture now discriminates. Added the usage-absent contract and Authorization-header tests.
  • 4970856 + a9cc340 — metering + docs: the total_tokens fallback silently flips total_tokens-only backends from "uncharged" to a real charge in the fairness meter — correct, but now visible (one-time INFO + operations.md note); architecture.md documents the Voyage dialect.

Deliberately not built here: a provider_class/score-domain slot on the backend pool model (the container-name⇒score-domain coupling is documented instead; a config-level discriminator is an architecture decision for a separate change), and persisting rerank token usage into context_llm_log (pre-existing gap, independent of this PR).

One logistical note: the PR branch carried your previous #12 commit from a stale base; it dropped out automatically as a duplicate during rebase onto current root — nothing of yours was lost. Full suite (36 packages) and lint are green on the rebased branch. Merging once CI confirms.

…ot a hang

The nightly Integration Tests job failed twice (2026-07-26, 2026-08-02)
with "panic: test timed out after 10m0s" in internal/handler while push-CI
stayed green on identical code. Both panic dumps show the running test at
~2s — nothing hangs; the PER-PACKAGE -timeout=10m expires over the sum of
~500 testcontainer starts under slow shared-runner I/O, and whichever test
is on the clock gets blamed (TestWebhookW13 both times, previously
misread as the culprit). 15m absorbs runner weather while a real hang
still fails; job timeout-minutes 20→25 keeps headroom (today's run needed
18m to the abort).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014fb8cep4Ru3hyoZ6r1iPyE
@GottZ

GottZ commented Aug 2, 2026

Copy link
Copy Markdown
Owner

I'll address that score-domain slot too. it will be configurable later.

@GottZ
GottZ merged commit 355a635 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.0 — thanks again! Follow-up on top of your fix (post-merge, on root): the container⇒score-domain coupling is now a configurable per-backend slot (metadata.score_domain: auto | logit | probability, default auto = exactly the coupling this PR shipped), guarded by confirm_score_domain_change for dialect-mixing backends. Your Voyage setup needs no change — auto covers it.

GottZ pushed a commit to TurgutKural/ctx that referenced this pull request Aug 2, 2026
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).
GottZ added a commit to TurgutKural/ctx that referenced this pull request Aug 2, 2026
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
GottZ added a commit to TurgutKural/ctx that referenced this pull request Aug 2, 2026
…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 pushed a commit that referenced this pull request Aug 2, 2026
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 #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).
GottZ added a commit that referenced this pull request Aug 2, 2026
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 #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
GottZ added a commit that referenced this pull request Aug 2, 2026
…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 #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
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