Skip to content

Commit 286ebe8

Browse files
Move retrieval wire helpers into _wire module (#214)
Move the shared wire-mapping helpers (normalize_base_url, client_side_prefix, apply_client_side_prefix, document_echo) out of retrieval/_events.py into a new retrieval/_wire.py, leaving _events.py as the observability event builders only. The providers import helpers from _wire and event builders from _events. _events.py had accreted these non-event helpers as the retrieval wire mappings landed; the _wire name states what belongs there (request-body shaping, endpoint normalization, response parsing) and what does not. Pure refactor, no behavior change.
1 parent 836779e commit 286ebe8

6 files changed

Lines changed: 123 additions & 109 deletions

File tree

src/openarmature/retrieval/_events.py

Lines changed: 2 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,15 @@
1919
from __future__ import annotations
2020

2121
from collections.abc import Mapping
22-
from typing import Any, cast
22+
from typing import Any
2323

2424
from openarmature.graph.events import (
2525
EmbeddingEvent,
2626
EmbeddingFailedEvent,
2727
RerankEvent,
2828
RerankFailedEvent,
2929
)
30-
from openarmature.llm.errors import LlmProviderError, ProviderInvalidResponse
30+
from openarmature.llm.errors import LlmProviderError
3131
from openarmature.observability.correlation import (
3232
current_attempt_index,
3333
current_branch_name,
@@ -41,98 +41,6 @@
4141
from .response import EmbeddingResponse, RerankResponse
4242

4343

44-
def normalize_base_url(base_url: str, *, guard_prefix: str) -> str:
45-
"""Strip a trailing slash from ``base_url`` and reject a doubled version prefix.
46-
47-
Returns the trailing-slash-stripped host root. Raises :class:`ValueError`
48-
when the stripped value already ends in ``guard_prefix`` (e.g. ``"/v1"`` /
49-
``"/v2"``).
50-
"""
51-
# base_url is the host root; the provider appends the versioned routes
52-
# itself (e.g. /v1/embeddings, /v2/rerank), so a base_url that already ends
53-
# in the version prefix would produce a doubled /v1/v1 (or /v2/v2) path that
54-
# 404s. Guard the footgun at construction; trailing slashes are stripped.
55-
normalized = base_url.rstrip("/")
56-
if normalized.endswith(guard_prefix):
57-
raise ValueError(
58-
f"base_url should be the host root; the provider appends the "
59-
f"{guard_prefix} routes itself, so a trailing {guard_prefix} would "
60-
f"produce a doubled {guard_prefix}{guard_prefix} path. Got {base_url!r}."
61-
)
62-
return normalized
63-
64-
65-
# retrieval-provider §8.1 client-side prefix rule, reused by §8.3: input_type
66-
# "query" selects query_prefix, "document" selects document_prefix; any other
67-
# value (including None / absent) selects no prefix -- the symmetric default.
68-
def client_side_prefix(
69-
input_type: str | None,
70-
*,
71-
query_prefix: str | None,
72-
document_prefix: str | None,
73-
) -> str | None:
74-
"""Return the client-side prefix for ``input_type``, or ``None``.
75-
76-
``"query"`` maps to ``query_prefix``, ``"document"`` to ``document_prefix``;
77-
any other value (including ``None``) maps to ``None`` (no prefix).
78-
"""
79-
if input_type == "query":
80-
return query_prefix
81-
if input_type == "document":
82-
return document_prefix
83-
return None
84-
85-
86-
# Apply the §8.1 client-side prefix to an input list for input_type: prepend the
87-
# resolved prefix to every string, or return the list unchanged when input_type
88-
# resolves to no prefix. The single source of the "prefix the inputs" step
89-
# shared by the OpenAI (§8.3) and TEI (§8.1) embed mappings -- keeps the copy /
90-
# no-copy semantics from drifting between providers.
91-
def apply_client_side_prefix(
92-
input_strings: list[str],
93-
input_type: str | None,
94-
*,
95-
query_prefix: str | None,
96-
document_prefix: str | None,
97-
) -> list[str]:
98-
"""Return ``input_strings`` prefixed per ``input_type``, or unchanged.
99-
100-
Resolves the prefix via :func:`client_side_prefix`; when it is ``None`` the
101-
list is returned as-is (no defensive copy -- the result is only serialized
102-
onto the wire), otherwise a new list with the prefix prepended to each
103-
string.
104-
"""
105-
prefix = client_side_prefix(input_type, query_prefix=query_prefix, document_prefix=document_prefix)
106-
if prefix is None:
107-
return input_strings
108-
return [prefix + s for s in input_strings]
109-
110-
111-
# §6 (0097): the rerank document-echo shape rule, shared by every RerankProvider
112-
# (Cohere / TEI / Jina). ScoredDocument.document carries the provider's string
113-
# echo verbatim when present, null otherwise -- never fabricated from the input
114-
# documents. Vendors echo it in several shapes: a bare string, a TextDoc object
115-
# {"text": "..."} (Jina's return_documents form), a text-less object (an
116-
# ImageDoc), or absent/null. Unwrap the string from a string or a TextDoc; a
117-
# text-less object or absent/null yields null (an empty string is present --
118-
# surfaced as "", not folded to null). A NON-object scalar (number / array /
119-
# bool -- outside the documented anyOf[string, object, null]) is wire corruption
120-
# and raises provider_invalid_response (§7), NOT folded to null. The verbatim
121-
# echo is preserved on RerankResponse.raw regardless.
122-
def document_echo(value: Any) -> str | None:
123-
"""Extract the string document echo from a rerank result's documented shapes."""
124-
if value is None:
125-
return None
126-
if isinstance(value, str):
127-
return value
128-
if isinstance(value, dict):
129-
text = cast("dict[str, Any]", value).get("text")
130-
return text if isinstance(text, str) else None
131-
raise ProviderInvalidResponse(
132-
f"rerank document echo must be a string, object, or null (got {type(value).__name__})"
133-
)
134-
135-
13644
def build_embedding_event(
13745
response: EmbeddingResponse,
13846
latency_ms: float,
@@ -353,12 +261,8 @@ def build_rerank_failed_event(
353261

354262

355263
__all__ = [
356-
"apply_client_side_prefix",
357264
"build_embedding_event",
358265
"build_embedding_failed_event",
359266
"build_rerank_event",
360267
"build_rerank_failed_event",
361-
"client_side_prefix",
362-
"document_echo",
363-
"normalize_base_url",
364268
]
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
"""Shared wire-format helpers for the retrieval reference providers.
2+
3+
Pure, stateless primitives reused across the vendor wire mappings:
4+
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.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
from typing import Any, cast
14+
15+
from openarmature.llm.errors import ProviderInvalidResponse
16+
17+
18+
def normalize_base_url(base_url: str, *, guard_prefix: str) -> str:
19+
"""Strip a trailing slash from ``base_url`` and reject a doubled version prefix.
20+
21+
Returns the trailing-slash-stripped host root. Raises :class:`ValueError`
22+
when the stripped value already ends in ``guard_prefix`` (e.g. ``"/v1"`` /
23+
``"/v2"``).
24+
"""
25+
# base_url is the host root; the provider appends the versioned routes
26+
# itself (e.g. /v1/embeddings, /v2/rerank), so a base_url that already ends
27+
# in the version prefix would produce a doubled /v1/v1 (or /v2/v2) path that
28+
# 404s. Guard the footgun at construction; trailing slashes are stripped.
29+
normalized = base_url.rstrip("/")
30+
if normalized.endswith(guard_prefix):
31+
raise ValueError(
32+
f"base_url should be the host root; the provider appends the "
33+
f"{guard_prefix} routes itself, so a trailing {guard_prefix} would "
34+
f"produce a doubled {guard_prefix}{guard_prefix} path. Got {base_url!r}."
35+
)
36+
return normalized
37+
38+
39+
# retrieval-provider §8.1 client-side prefix rule, reused by §8.3: input_type
40+
# "query" selects query_prefix, "document" selects document_prefix; any other
41+
# value (including None / absent) selects no prefix -- the symmetric default.
42+
def client_side_prefix(
43+
input_type: str | None,
44+
*,
45+
query_prefix: str | None,
46+
document_prefix: str | None,
47+
) -> str | None:
48+
"""Return the client-side prefix for ``input_type``, or ``None``.
49+
50+
``"query"`` maps to ``query_prefix``, ``"document"`` to ``document_prefix``;
51+
any other value (including ``None``) maps to ``None`` (no prefix).
52+
"""
53+
if input_type == "query":
54+
return query_prefix
55+
if input_type == "document":
56+
return document_prefix
57+
return None
58+
59+
60+
# Apply the §8.1 client-side prefix to an input list for input_type: prepend the
61+
# resolved prefix to every string, or return the list unchanged when input_type
62+
# resolves to no prefix. The single source of the "prefix the inputs" step
63+
# shared by the OpenAI (§8.3) and TEI (§8.1) embed mappings -- keeps the copy /
64+
# no-copy semantics from drifting between providers.
65+
def apply_client_side_prefix(
66+
input_strings: list[str],
67+
input_type: str | None,
68+
*,
69+
query_prefix: str | None,
70+
document_prefix: str | None,
71+
) -> list[str]:
72+
"""Return ``input_strings`` prefixed per ``input_type``, or unchanged.
73+
74+
Resolves the prefix via :func:`client_side_prefix`; when it is ``None`` the
75+
list is returned as-is (no defensive copy -- the result is only serialized
76+
onto the wire), otherwise a new list with the prefix prepended to each
77+
string.
78+
"""
79+
prefix = client_side_prefix(input_type, query_prefix=query_prefix, document_prefix=document_prefix)
80+
if prefix is None:
81+
return input_strings
82+
return [prefix + s for s in input_strings]
83+
84+
85+
# §6 (0097): the rerank document-echo shape rule, shared by every RerankProvider
86+
# (Cohere / TEI / Jina). ScoredDocument.document carries the provider's string
87+
# echo verbatim when present, null otherwise -- never fabricated from the input
88+
# documents. Vendors echo it in several shapes: a bare string, a TextDoc object
89+
# {"text": "..."} (Jina's return_documents form), a text-less object (an
90+
# ImageDoc), or absent/null. Unwrap the string from a string or a TextDoc; a
91+
# text-less object or absent/null yields null (an empty string is present --
92+
# surfaced as "", not folded to null). A NON-object scalar (number / array /
93+
# bool -- outside the documented anyOf[string, object, null]) is wire corruption
94+
# and raises provider_invalid_response (§7), NOT folded to null. The verbatim
95+
# echo is preserved on RerankResponse.raw regardless.
96+
def document_echo(value: Any) -> str | None:
97+
"""Extract the string document echo from a rerank result's documented shapes."""
98+
if value is None:
99+
return None
100+
if isinstance(value, str):
101+
return value
102+
if isinstance(value, dict):
103+
text = cast("dict[str, Any]", value).get("text")
104+
return text if isinstance(text, str) else None
105+
raise ProviderInvalidResponse(
106+
f"rerank document echo must be a string, object, or null (got {type(value).__name__})"
107+
)
108+
109+
110+
__all__ = [
111+
"apply_client_side_prefix",
112+
"client_side_prefix",
113+
"document_echo",
114+
"normalize_base_url",
115+
]

src/openarmature/retrieval/providers/cohere.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@
4444
)
4545
from openarmature.observability.correlation import current_dispatch
4646

47-
from .._events import build_rerank_event, build_rerank_failed_event, document_echo, normalize_base_url
47+
from .._events import build_rerank_event, build_rerank_failed_event
48+
from .._wire import document_echo, normalize_base_url
4849
from ..provider import validate_rerank_input, validate_rerank_response
4950
from ..response import RerankResponse, RerankRuntimeConfig, RerankUsage, ScoredDocument
5051

src/openarmature/retrieval/providers/jina.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,8 @@
6161
build_embedding_failed_event,
6262
build_rerank_event,
6363
build_rerank_failed_event,
64-
document_echo,
65-
normalize_base_url,
6664
)
65+
from .._wire import document_echo, normalize_base_url
6766
from ..provider import (
6867
validate_embedding_input,
6968
validate_embedding_response,

src/openarmature/retrieval/providers/openai.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,8 @@
4141
)
4242
from openarmature.observability.correlation import current_dispatch
4343

44-
from .._events import (
45-
apply_client_side_prefix,
46-
build_embedding_event,
47-
build_embedding_failed_event,
48-
normalize_base_url,
49-
)
44+
from .._events import build_embedding_event, build_embedding_failed_event
45+
from .._wire import apply_client_side_prefix, normalize_base_url
5046
from ..provider import validate_embedding_input, validate_embedding_response
5147
from ..response import EmbeddingResponse, EmbeddingRuntimeConfig, EmbeddingUsage
5248

src/openarmature/retrieval/providers/tei.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,12 @@
5050
from openarmature.observability.correlation import current_dispatch
5151

5252
from .._events import (
53-
apply_client_side_prefix,
5453
build_embedding_event,
5554
build_embedding_failed_event,
5655
build_rerank_event,
5756
build_rerank_failed_event,
58-
document_echo,
5957
)
58+
from .._wire import apply_client_side_prefix, document_echo
6059
from ..provider import (
6160
validate_embedding_input,
6261
validate_embedding_response,

0 commit comments

Comments
 (0)