Skip to content

Commit 2c36b77

Browse files
Adopt 0093 nullable provider usage records
Proposal 0093 gives OA one model for "the provider reported no usage" across both retrieval responses and both typed events: no usage yields usage = null, never a fabricated record, zero, or estimate. The response types and both observers were already correct. The gap was OpenAIEmbeddingProvider, which recorded EmbeddingUsage(input_tokens=0) when the usage block was absent or malformed. A zero asserts "zero tokens billed", a claim the provider never made. It now surfaces usage = null; a reported zero still yields a record carrying 0. Reviewing that diff surfaced a second, unrelated defect. Proposal 0092 requires every capped embedding mapping to chunk-and-stitch and names OpenAI's 2048-input cap, but the 0092 implementation adopted the shared helper for TEI and Cohere only. An over-2048 embed call sent one over-cap request the wire rejects, breaking the arbitrary-length input contract. OpenAI now chunks at 2048. Conformance stayed green because the spec ships no over-cap fixture for that mapping; tracked. The shared helper now takes the response identity (id and model) from the first chunk, so OpenAI keeps reporting the provider-returned model while the wires that carry none fall back to the bound identifier. A missing usage figure and a corrupt one were both swallowed silently, so a gateway sending garbage looked identical to a healthy no-usage call. The shared nonneg_int reader now logs a warning when a figure is present but not a non-negative int, while an absent figure stays quiet. nonneg_int lifts into _wire.py and is adopted by OpenAI, Cohere, and Jina. Un-defers observability fixtures 139 and 140. Fixture 143 stays deferred behind the unimplemented section 11 embedding-metrics path, not behind anything in 0093.
1 parent af228ad commit 2c36b77

8 files changed

Lines changed: 408 additions & 119 deletions

File tree

conformance.toml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -887,15 +887,15 @@ note = "Retrieval-provider Cohere /v2/embed wire mapping (§8.4) -- the embed ha
887887
[proposals."0092"]
888888
status = "implemented"
889889
since = "0.16.0"
890-
note = "Retrieval-provider general §8 *Batch chunking* rule for the embedding side + fixture 038 (TEI /embed chunk-and-stitch by the construction chunk_size). The shared chunk_and_stitch_embed helper in _wire.py realizes the rule mapping-agnostically: split the inputs into consecutive <=cap slices preserving order, issue one request per slice via a per-mapping closure (every field other than the chunked input list identical across slices), concatenate the per-chunk vectors in input order (so §4's one-vector-per-input + input-order invariants hold across the whole call, enforced against the stitched result), combine the per-chunk usage record-aware (sum EmbeddingUsage.input_tokens when the provider reports usage, else usage = null -- never fabricated), take response_id from the first chunk, and set raw to the single response for a single-request call or the list of per-chunk responses in request order (proposal 0096). TeiEmbeddingProvider.embed() adopts it with cap = its construction chunk_size (TEI's max-client-batch-size, default 32): an over-cap embed call chunk-and-stitches (fixture 038: chunk_size 2 over 5 inputs -> 3 /embed requests sized 2/2/1 with identical per-call params), and since TEI /embed reports no usage and no id the stitched usage is null and response_id is null; the len(input) <= chunk_size path issues a single /embed request (the pre-0092 behavior preserved). CohereEmbeddingProvider migrates its existing 96-input chunk path (0091, fixture 037) onto the same helper (cap = 96, closure POSTs /v2/embed) with identical behavior. The per-chunk vector count is validated before stitching so a positional misalignment cannot pass on a compensating total. Conformance fixtures 037 (Cohere, unchanged) + 038 (TEI) pass via the general wire-capture harness; no retrieval fixtures remain deferred."
890+
note = "Retrieval-provider general §8 *Batch chunking* rule for the embedding side + fixture 038 (TEI /embed chunk-and-stitch by the construction chunk_size). The shared chunk_and_stitch_embed helper in _wire.py realizes the rule mapping-agnostically: split the inputs into consecutive <=cap slices preserving order, issue one request per slice via a per-mapping closure (every field other than the chunked input list identical across slices), concatenate the per-chunk vectors in input order (so §4's one-vector-per-input + input-order invariants hold across the whole call, enforced against the stitched result), combine the per-chunk usage record-aware (sum EmbeddingUsage.input_tokens when the provider reports usage, else usage = null -- never fabricated), take response_id from the first chunk, and set raw to the single response for a single-request call or the list of per-chunk responses in request order (proposal 0096). TeiEmbeddingProvider.embed() adopts it with cap = its construction chunk_size (TEI's max-client-batch-size, default 32): an over-cap embed call chunk-and-stitches (fixture 038: chunk_size 2 over 5 inputs -> 3 /embed requests sized 2/2/1 with identical per-call params), and since TEI /embed reports no usage and no id the stitched usage is null and response_id is null; the len(input) <= chunk_size path issues a single /embed request (the pre-0092 behavior preserved). CohereEmbeddingProvider migrates its existing 96-input chunk path (0091, fixture 037) onto the same helper (cap = 96, closure POSTs /v2/embed) with identical behavior. OpenAIEmbeddingProvider adopts it at §8.3's fixed 2048-input cap (closure POSTs /v1/embeddings): an over-2048 embed call now chunk-and-stitches instead of sending one over-cap request the wire rejects. OpenAI's summed-token ceiling is NOT addressed by the count-based rule -- an over-token chunk still fails loud as provider_invalid_request. Jina is the cap-free mapping the rule exempts (server-side batching), so it does not chunk. The per-chunk vector count is validated before stitching so a positional misalignment cannot pass on a compensating total, and the response identity (response_id + response_model) is taken from the FIRST chunk -- the model falls back to the bound identifier for the wires that carry none (TEI's bare array, Cohere /v2/embed), while OpenAI reports the body's model per §4. Conformance fixtures 037 (Cohere, unchanged) + 038 (TEI) pass via the general wire-capture harness; no retrieval fixtures remain deferred. The spec ships NO §8.3 over-cap fixture, so the OpenAI chunking is covered by unit tests only (2049 inputs -> 2 requests sized 2048/1) -- flagged to spec as a fixture-set gap."
891891

892892
# Spec v0.88.0 (proposal 0093). Nullable provider usage records:
893893
# EmbeddingResponse.usage / RerankResponse.usage become record|null and
894894
# the embedding usage emission (OTel §5.5.8 gen_ai.usage.input_tokens,
895-
# Langfuse §8.4.5 usageDetails.input) is conditional. Partial: the
896-
# response-type widening ships; the no-usage observability fixtures
897-
# 139-143 stay deferred as the 0093-last PR.
895+
# Langfuse §8.4.5 usageDetails.input) is conditional. One uniform model
896+
# for "the provider reported no usage" across both responses and both
897+
# typed events: no usage => usage = null, never fabricated.
898898
[proposals."0093"]
899-
status = "partial"
899+
status = "implemented"
900900
since = "0.16.0"
901-
note = "The response-type nullable widening ships: RerankResponse.usage is record | null from the start (0060a), and EmbeddingResponse.usage is widened to EmbeddingUsage | null here with 0077 (the OpenAIEmbeddingProvider still builds an EmbeddingUsage record; the TeiEmbeddingProvider surfaces usage = null since TEI /embed returns no usage object, never fabricated -- §4). Both bundled observers already emit the embedding usage conditionally (OTel §5.5.8 gen_ai.usage.input_tokens guarded by `event.usage is not None`; Langfuse §8.4.5 usageDetails.input built only when event.usage is not None), landed with the embedding observability in 0059b, so a null-usage EmbeddingEvent renders without a usage attribute rather than crashing. partial because the no-usage OBSERVABILITY fixtures (139 / 140 / 143, asserting the conditional-emission contract end-to-end) stay deferred as the 0093-last PR."
901+
note = "One uniform model across all four surfaces: a provider that reports no usage yields usage = null, never a fabricated record / zero / client-side estimate (§4 / §6). The response types were already widened (RerankResponse.usage is record | null from 0060a; EmbeddingResponse.usage from 0077), and both bundled observers already emitted the usage conditionally (OTel §5.5.8 gen_ai.usage.input_tokens guarded by `event.usage is not None`; Langfuse §8.4.5 usageDetails.input built only when a record is present), landed with the embedding observability in 0059b. Completed here: the OpenAIEmbeddingProvider no longer FABRICATES an EmbeddingUsage(input_tokens=0) when the OpenAI usage block is absent or malformed -- it surfaces usage = null (a *reported* zero still yields a record carrying 0; a malformed figure reads as not-reported rather than failing the call, since the vectors are sound and usage is secondary accounting -- the posture the Cohere and Jina mappings already took, and deliberately unlike the §6 document echo of 0097, where the corrupt field IS the payload). The other three embedding mappings were already conformant: TEI /embed and /rerank surface usage = null (§8.1 -- neither endpoint returns a usage object), and Cohere / Jina return null when the body reports no figure. The shared nonneg_int helper (one figure out of a vendor usage block: a non-negative int is reported, anything else is not) lifts into _wire.py and is adopted by all three of Cohere, Jina, and OpenAI. Conformance: the no-usage rendering fixtures 139 (OTel embedding span omits gen_ai.usage.input_tokens) + 140 (Langfuse embedding usageDetails carries no `input` key) are un-deferred and pass, joining the rerank pair 141 / 142 (wired with 0060b); retrieval fixtures 017 / 038 already assert usage: null for TEI /embed. Fixture 143 (embedding no-usage token METRIC not observed) stays deferred, and NOT for a 0093 reason: the OTelObserver records §11 GenAI metrics from the LLM per-attempt event only, so the embedding metric path does not exist yet -- 143 defers behind the same gate as 089, whose implementation (proposal 0067) realizes it. 0093 itself changes nothing in §11 or graph-engine §6 (both were already null-usage-aware), so no part of THIS proposal is left unimplemented by that carve-out; cf. 0060, marked implemented with the sibling metric fixture 109 deferred on the same grounds."

src/openarmature/retrieval/_wire.py

Lines changed: 70 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@
22
33
Pure, stateless primitives reused across the vendor wire mappings:
44
request-body shaping (the client-side ``input_type`` prefix), endpoint
5-
normalization (``base_url``), and response parsing (the rerank document echo).
6-
Types live in ``response.py``, the observability event builders in
7-
``_events.py``, and the provider protocols in ``provider.py``; only the
8-
mapping helpers belong here.
5+
normalization (``base_url``), batch chunking, and response parsing (the rerank
6+
document echo, usage figures). Types live in ``response.py``, the observability
7+
event builders in ``_events.py``, and the provider protocols in ``provider.py``;
8+
only the mapping helpers belong here.
99
"""
1010

1111
from __future__ import annotations
1212

13+
import logging
1314
from collections.abc import Awaitable, Callable
1415
from typing import Any, cast
1516

@@ -18,6 +19,46 @@
1819
from .provider import validate_embedding_input, validate_embedding_response
1920
from .response import EmbeddingResponse, EmbeddingUsage
2021

22+
_log = logging.getLogger(__name__)
23+
24+
25+
# retrieval-provider §4 / §6 (proposal 0093): a usage figure is reported or it
26+
# is not -- a mapping MUST NOT fabricate one (an empty record, a zero, or a
27+
# client-side estimate). This reads ONE figure out of a vendor's usage block:
28+
# a non-negative int is the reported count, anything else is "not reported"
29+
# -> None. A malformed figure does NOT fail the call: the embed / rerank itself
30+
# succeeded and its payload is sound, so a corrupt secondary accounting field
31+
# surfaces as unknown rather than sinking the whole response (unlike the §6
32+
# document echo, where the corrupt field IS the payload -- proposal 0097).
33+
# Callers fold the Nones into a record-or-null per their vendor's shape.
34+
#
35+
# Two "not reported" cases with different signals: an ABSENT figure (None -- the
36+
# key was missing or the value was JSON null) is the ordinary no-usage case
37+
# (e.g. every TEI call) and returns quietly; a PRESENT-but-unusable figure (a
38+
# string, a negative, a bool, a float) means the provider / gateway is
39+
# misbehaving on a field it did populate, so it logs at WARNING before returning
40+
# None -- otherwise a corrupt count is indistinguishable from the healthy absent
41+
# case and usage silently goes missing. ``field`` names the figure in the log.
42+
def nonneg_int(value: Any, *, field: str = "usage figure") -> int | None:
43+
"""Return ``value`` when it is a non-negative int, else ``None``.
44+
45+
``None`` (the figure was not reported) returns quietly; any other
46+
non-conforming value is logged at WARNING as a misbehaving provider before
47+
returning ``None``. ``field`` names the figure in that log line.
48+
"""
49+
# bool is an int subclass, so exclude it explicitly.
50+
if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
51+
return value
52+
if value is not None:
53+
_log.warning(
54+
"retrieval provider reported a %s that is not a non-negative int "
55+
"(got %s %.80r); recording usage as unknown",
56+
field,
57+
type(value).__name__,
58+
value,
59+
)
60+
return None
61+
2162

2263
def normalize_base_url(base_url: str, *, guard_prefix: str) -> str:
2364
"""Strip a trailing slash from ``base_url`` and reject a doubled version prefix.
@@ -99,23 +140,32 @@ def apply_client_side_prefix(
99140
# that request's id). raw is that one response for a single-request call, or the
100141
# LIST of per-chunk responses in request order for a chunked call (proposal
101142
# 0096). embed_chunk owns the per-mapping wire shaping / POST / parse and returns
102-
# (vectors, input_tokens, response_id, raw_body) for its slice -- so the loop /
103-
# stitch / validation is mapping-agnostic. When len(input) <= cap this issues a
104-
# single request (the single-iteration path). Valid because each input's
105-
# embedding is independent of the others in its batch.
143+
# (vectors, input_tokens, response_id, response_model, raw_body) for its slice --
144+
# so the loop / stitch / validation is mapping-agnostic. The RESPONSE IDENTITY
145+
# (response_id + response_model) is the FIRST chunk's, since every chunk is the
146+
# same call with the same params; a mapping whose wire carries no id / model
147+
# (TEI's bare array, Cohere /v2/embed) reports None and the bound model is the
148+
# fallback (§4: the response model is the provider-returned identifier, which may
149+
# be more specific than the bound one, when the body carries one). When
150+
# len(input) <= cap this issues a single request (the single-iteration path).
151+
# Valid because each input's embedding is independent of the others in its batch.
106152
async def chunk_and_stitch_embed(
107153
input_strings: list[str],
108154
*,
109155
model: str,
110156
cap: int,
111-
embed_chunk: Callable[[list[str]], Awaitable[tuple[list[list[float]], int | None, str | None, Any]]],
157+
embed_chunk: Callable[
158+
[list[str]],
159+
Awaitable[tuple[list[list[float]], int | None, str | None, str | None, Any]],
160+
],
112161
) -> EmbeddingResponse:
113162
"""Issue one embed request per ``<= cap`` chunk and stitch the vectors.
114163
115164
``embed_chunk`` sends one chunk's request and returns
116-
``(vectors, input_tokens, response_id, raw_body)`` for that chunk; the
117-
per-mapping wire shaping, POST, and parse live in the closure. Returns the
118-
stitched :class:`EmbeddingResponse`.
165+
``(vectors, input_tokens, response_id, response_model, raw_body)`` for that
166+
chunk; the per-mapping wire shaping, POST, and parse live in the closure.
167+
``model`` is the bound identifier, reported when the chunk bodies carry no
168+
model of their own. Returns the stitched :class:`EmbeddingResponse`.
119169
"""
120170
# cap is the provider's per-call input limit; a non-positive cap is a caller
121171
# misconfiguration -- fail loudly rather than surfacing a raw range() error
@@ -132,9 +182,10 @@ async def chunk_and_stitch_embed(
132182
chunk_bodies: list[Any] = []
133183
input_tokens_total: int | None = None
134184
response_id: str | None = None
185+
response_model: str | None = None
135186
for offset in range(0, len(input_strings), cap):
136187
chunk = input_strings[offset : offset + cap]
137-
chunk_vectors, chunk_tokens, chunk_id, chunk_body = await embed_chunk(chunk)
188+
chunk_vectors, chunk_tokens, chunk_id, chunk_model, chunk_body = await embed_chunk(chunk)
138189
# Per-chunk count MUST match the chunk's inputs before stitching: the
139190
# stitch is positional (no index re-basing), so a chunk returning the
140191
# wrong vector count would silently misalign vectors that a compensating
@@ -145,8 +196,12 @@ async def chunk_and_stitch_embed(
145196
)
146197
stitched_vectors.extend(chunk_vectors)
147198
chunk_bodies.append(chunk_body)
199+
# The response identity is the FIRST chunk's (every chunk is the same
200+
# call with the same params -- §8 batch chunking step 4 for the id, and
201+
# the same reasoning for the model).
148202
if offset == 0:
149203
response_id = chunk_id
204+
response_model = chunk_model
150205
# Sum input_tokens across chunks; a chunk that omits usage does not
151206
# contribute, and usage stays null only when NO chunk reports it (§4 /
152207
# §8 batch-chunking step 4 -- never fabricate).
@@ -162,7 +217,7 @@ async def chunk_and_stitch_embed(
162217
raw: dict[str, Any] | list[Any] = chunk_bodies[0] if len(chunk_bodies) == 1 else chunk_bodies
163218
return EmbeddingResponse(
164219
vectors=stitched_vectors,
165-
model=model,
220+
model=response_model if response_model is not None else model,
166221
usage=usage,
167222
response_id=response_id,
168223
dimensions=dimensions,
@@ -200,5 +255,6 @@ def document_echo(value: Any) -> str | None:
200255
"chunk_and_stitch_embed",
201256
"client_side_prefix",
202257
"document_echo",
258+
"nonneg_int",
203259
"normalize_base_url",
204260
]

src/openarmature/retrieval/providers/cohere.py

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@
6464
build_rerank_event,
6565
build_rerank_failed_event,
6666
)
67-
from .._wire import chunk_and_stitch_embed, document_echo, normalize_base_url
67+
from .._wire import chunk_and_stitch_embed, document_echo, nonneg_int, normalize_base_url
6868
from ..provider import (
6969
validate_embedding_input,
7070
validate_rerank_input,
@@ -198,15 +198,6 @@ def _embedding_request_params(config: EmbeddingRuntimeConfig | None) -> dict[str
198198
return out
199199

200200

201-
def _nonneg_int(value: Any) -> int | None:
202-
"""Return a non-negative int value, or None (bool excluded)."""
203-
# bool is an int subclass, so exclude it explicitly; a malformed value falls
204-
# back to None (the call succeeded; usage is secondary).
205-
if isinstance(value, int) and not isinstance(value, bool) and value >= 0:
206-
return value
207-
return None
208-
209-
210201
def _billed_units(body: dict[str, Any]) -> dict[str, Any] | None:
211202
"""Return the Cohere ``meta.billed_units`` object, or None."""
212203
# Both /v2/rerank and /v2/embed report usage under meta.billed_units; the
@@ -454,8 +445,8 @@ def _parse_usage(self, body: dict[str, Any]) -> RerankUsage | None:
454445
billed = _billed_units(body)
455446
if billed is None:
456447
return None
457-
search_units = _nonneg_int(billed.get("search_units"))
458-
input_tokens = _nonneg_int(billed.get("input_tokens"))
448+
search_units = nonneg_int(billed.get("search_units"), field="search_units")
449+
input_tokens = nonneg_int(billed.get("input_tokens"), field="input_tokens")
459450
if search_units is None and input_tokens is None:
460451
return None
461452
return RerankUsage(search_units=search_units, input_tokens=input_tokens)
@@ -633,7 +624,7 @@ async def _embed_chunked(
633624
# input's embedding is independent of the others in its batch.
634625
async def _embed_one(
635626
chunk: list[str],
636-
) -> tuple[list[list[float]], int | None, str | None, dict[str, Any]]:
627+
) -> tuple[list[list[float]], int | None, str | None, None, dict[str, Any]]:
637628
body = self._build_request_body(chunk, input_type, dimensions, request_extras)
638629
try:
639630
resp = await self._client.post("/v2/embed", json=body)
@@ -704,8 +695,8 @@ def _build_request_body(
704695
def _parse_chunk(
705696
self,
706697
resp: httpx.Response,
707-
) -> tuple[list[list[float]], int | None, str | None, dict[str, Any]]:
708-
"""Parse one /v2/embed chunk into (vectors, input_tokens, id, raw)."""
698+
) -> tuple[list[list[float]], int | None, str | None, None, dict[str, Any]]:
699+
"""Parse one /v2/embed chunk into (vectors, input_tokens, id, None, raw)."""
709700
# Cohere /v2/embed returns an object envelope {id, embeddings: {float:
710701
# [[float, ...], ...]}, texts, meta: {billed_units: {input_tokens}}}.
711702
# embeddings.float is a list of vector lists IN INPUT ORDER (positional
@@ -739,7 +730,9 @@ def _parse_chunk(
739730
vectors.append([float(x) for x in values])
740731
input_tokens = self._parse_input_tokens(body)
741732
response_id = body.get("id")
742-
return vectors, input_tokens, response_id if isinstance(response_id, str) else None, body
733+
# The /v2/embed envelope carries an id but no model, so the stitched
734+
# response reports the bound model.
735+
return vectors, input_tokens, response_id if isinstance(response_id, str) else None, None, body
743736

744737
def _parse_input_tokens(self, body: dict[str, Any]) -> int | None:
745738
"""Extract meta.billed_units.input_tokens, or None.
@@ -750,7 +743,7 @@ def _parse_input_tokens(self, body: dict[str, Any]) -> int | None:
750743
billed = _billed_units(body)
751744
if billed is None:
752745
return None
753-
return _nonneg_int(billed.get("input_tokens"))
746+
return nonneg_int(billed.get("input_tokens"), field="input_tokens")
754747

755748

756749
__all__ = ["CohereEmbeddingProvider", "CohereRerankProvider"]

0 commit comments

Comments
 (0)