fix(rerank): support Voyage AI response format (data field + total_tokens) - #13
Conversation
…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
…lect) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MP6ZWjHgySC74PfJJKjwBX
d7b494b to
c23cb91
Compare
|
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-
Deliberately not built here: a 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 |
…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
|
I'll address that score-domain slot too. it will be configurable later. |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MP6ZWjHgySC74PfJJKjwBX
|
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 ( |
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
…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
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).
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
…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
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
Resultsempty on every Voyage call, producing:Production evidence: 6/6 real Voyage rerank calls failed with this error (both
rerank-2.5andrerank-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_tokenswhile llama.cpp usesprompt_tokens, so the usage metering path also needed a fallback.Fix
internal/rerank/rerank.go:Datafield added torerankResponse(json:"data") — Voyage's OpenAPI field nameResultsis empty andDatais non-empty, useDataTotalTokensfield added to theUsagestruct; whenPromptTokensis 0, fall back toTotalTokensPrecedence is preserved:
"results"wins over"data"when both are present (cohere/llama.cpp path unchanged).Tests
7 new tests added to
rerank_test.go:TestScore_VoyageDataFallback"data"field decoded, scores + total_tokens returnedTestScore_VoyageDataReAlignsByIndex"data"pathTestScore_PrefersResultsOverData"results"takes precedence when both fields presentTestScore_VoyageTotalTokensFallbacktotal_tokens→promptTokensfallbackTestScore_VoyageDataRejectsCountMismatch"data"pathTestScore_VoyageDataRejectsDuplicateIndex"data"pathTestScore_NeitherResultsNorDataProduction verification
Post-deploy, 2/2 Voyage rerank calls succeeded (~690ms each, zero errors), confirmed via
context_llm_log. Pre-fix: 0/6 succeeded.