feat: canonical model client types, protocols, and LiteLLM bridge adapter#359
feat: canonical model client types, protocols, and LiteLLM bridge adapter#359
Conversation
Greptile SummaryThis PR establishes a provider-agnostic Previously flagged critical issues have been verified as resolved:
One remaining concern:
|
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
packages/data-designer-engine/tests/engine/models/clients/test_litellm_bridge.py
Outdated
Show resolved
Hide resolved
packages/data-designer-engine/tests/engine/models/clients/test_litellm_bridge.py
Outdated
Show resolved
Hide resolved
packages/data-designer-engine/tests/engine/models/clients/test_client_errors.py
Outdated
Show resolved
Hide resolved
andreatgretel
left a comment
There was a problem hiding this comment.
Ran some smoke tests against edge cases in the bridge helpers. Most things held up well -- malformed responses, missing choices, None messages, non-list embeddings all degrade gracefully. Plain text content doesn't false-positive as an image either.
One thing that came up: _extract_usage returns a Usage(input_tokens=None, output_tokens=None, ...) object (not None) when providers return token counts as strings (e.g. "10" instead of 10). That's because _to_int_or_none only handles int and float, so the string passes the is None guard on line 319 but then gets coerced to None by _to_int_or_none. End result: callers can't rely on if usage is not None to check if usage data was actually captured. Probably worth adding string handling to _to_int_or_none.
...ages/data-designer-engine/src/data_designer/engine/models/clients/adapters/litellm_bridge.py
Outdated
Show resolved
Hide resolved
andreatgretel
left a comment
There was a problem hiding this comment.
Thinking ahead to PR-3+: a bunch of the parsing helpers in the bridge (_parse_image_payload, _extract_images_from_chat_message, _extract_usage, _coerce_message_content) will be needed by the native OpenAI adapter too -- the image extraction waterfall in the plan is basically the same logic. Right now they're private functions inside the bridge module, which is supposed to be temporary. Might be worth pulling them into a shared clients/parsing.py (or similar) before PR-2 adds dependencies on the bridge. Easier to move now than later.
packages/data-designer-engine/src/data_designer/engine/models/clients/errors.py
Show resolved
Hide resolved
...ages/data-designer-engine/src/data_designer/engine/models/clients/adapters/litellm_bridge.py
Outdated
Show resolved
Hide resolved
andreatgretel
left a comment
There was a problem hiding this comment.
Requesting changes for 2 small but meaningful things before we merge:
-
In LiteLLMBridgeClient, raw router/LiteLLM exceptions can still leak out. At this layer, we should normalize those into ProviderError/ProviderErrorKind so callers only see the canonical error shape.
-
There’s a small usage parsing edge case: if a provider returns token counts as strings (like "10"), _extract_usage can return a Usage object where token fields end up None. That makes usage is not None look like we captured usage when we didn’t. Can we either parse numeric strings in _to_int_or_none or return None when normalized usage is empty?
Everything else I left is optional cleanup/follow-up.
…provements - Wrap all LiteLLM router calls in try/except to normalize raw exceptions into canonical ProviderError at the bridge boundary (blocking review item) - Extract reusable response-parsing helpers into clients/parsing.py for shared use across future native adapters - Add async image parsing path using httpx.AsyncClient to avoid blocking the event loop in agenerate_image - Add retry_after field to ProviderError for future retry engine support - Fix _to_int_or_none to parse numeric strings from providers - Create test conftest.py with shared mock_router/bridge_client fixtures - Parametrize duplicate image generation and error mapping tests - Add tests for exception wrapping across all bridge methods
Thanks @andreatgretel! Addressed in ec5ed9b |
...ages/data-designer-engine/src/data_designer/engine/models/clients/adapters/litellm_bridge.py
Show resolved
Hide resolved
...ages/data-designer-engine/src/data_designer/engine/models/clients/adapters/litellm_bridge.py
Show resolved
Hide resolved
packages/data-designer-engine/src/data_designer/engine/models/clients/errors.py
Show resolved
Hide resolved
packages/data-designer-engine/src/data_designer/engine/models/clients/parsing.py
Show resolved
Hide resolved
…larity - Parse RFC 7231 HTTP-date strings in Retry-After header (used by Azure and Anthropic during rate-limiting) in addition to numeric delay-seconds - Clarify collect_non_none_optional_fields docstring explaining why f.default is None is the correct check for optional field forwarding - Add tests for HTTP-date and garbage Retry-After values
...ages/data-designer-engine/src/data_designer/engine/models/clients/adapters/litellm_bridge.py
Outdated
Show resolved
Hide resolved
packages/data-designer-engine/src/data_designer/engine/models/clients/errors.py
Show resolved
Hide resolved
packages/data-designer-engine/src/data_designer/engine/models/clients/parsing.py
Show resolved
Hide resolved
- Fix misleading comment about prompt field defaults in _IMAGE_EXCLUDE - Handle list-format detail arrays in _extract_structured_message for FastAPI/Pydantic validation errors - Document scope boundary for vision content in collect_raw_image_candidates
| def parse_image_payload(raw_image: Any) -> ImagePayload | None: | ||
| try: | ||
| result = resolve_image_payload(raw_image) | ||
| if isinstance(result, str): | ||
| return ImagePayload(b64_data=load_image_url_to_base64(result), mime_type=None) | ||
| return result | ||
| except Exception: | ||
| logger.debug("Unable to parse image payload from response object.", exc_info=True) | ||
| return None |
There was a problem hiding this comment.
Silent image download failures produce empty response with no diagnostic
parse_image_payload (and its async twin aparse_image_payload) catch every exception from load_image_url_to_base64 and log it only at DEBUG level before returning None. Since parse_image_list filters out None values, a caller receives an ImageGenerationResponse.images = [] when all images were URL-format and every download failed — identical to the response for a provider that simply returned no images.
DALL-E 3 (and similar diffusion APIs) commonly return temporary presigned URLs when response_format="url" is requested. If the network download fails (transient 5xx, expired URL, DNS failure), the entire image result is silently lost. There is no WARNING/ERROR log, no raised exception, and no way for callers to distinguish "no images generated" from "images generated but download failed".
Consider logging at WARNING level so operators can diagnose missing images in production:
def parse_image_payload(raw_image: Any) -> ImagePayload | None:
try:
result = resolve_image_payload(raw_image)
if isinstance(result, str):
return ImagePayload(b64_data=load_image_url_to_base64(result), mime_type=None)
return result
except Exception:
logger.warning("Failed to parse image payload from response object; image dropped.", exc_info=True)
return NoneSame change applies to aparse_image_payload at line 131.
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/data-designer-engine/src/data_designer/engine/models/clients/parsing.py
Line: 120-128
Comment:
**Silent image download failures produce empty response with no diagnostic**
`parse_image_payload` (and its async twin `aparse_image_payload`) catch every exception from `load_image_url_to_base64` and log it only at `DEBUG` level before returning `None`. Since `parse_image_list` filters out `None` values, a caller receives an `ImageGenerationResponse.images = []` when all images were URL-format and every download failed — identical to the response for a provider that simply returned no images.
DALL-E 3 (and similar diffusion APIs) commonly return temporary presigned URLs when `response_format="url"` is requested. If the network download fails (transient 5xx, expired URL, DNS failure), the entire image result is silently lost. There is no `WARNING`/`ERROR` log, no raised exception, and no way for callers to distinguish "no images generated" from "images generated but download failed".
Consider logging at `WARNING` level so operators can diagnose missing images in production:
```python
def parse_image_payload(raw_image: Any) -> ImagePayload | None:
try:
result = resolve_image_payload(raw_image)
if isinstance(result, str):
return ImagePayload(b64_data=load_image_url_to_base64(result), mime_type=None)
return result
except Exception:
logger.warning("Failed to parse image payload from response object; image dropped.", exc_info=True)
return None
```
Same change applies to `aparse_image_payload` at line 131.
How can I resolve this? If you propose a fix, please make it concise.
packages/data-designer-engine/src/data_designer/engine/models/clients/parsing.py
Outdated
Show resolved
Hide resolved
...ages/data-designer-engine/src/data_designer/engine/models/clients/adapters/litellm_bridge.py
Show resolved
Hide resolved
packages/data-designer-engine/src/data_designer/engine/models/clients/parsing.py
Outdated
Show resolved
Hide resolved
andreatgretel
left a comment
There was a problem hiding this comment.
looks good! all the original feedback was addressed. left two minor nits on the image generation exception scope and the total_tokens coercion order, neither blocking.
...ages/data-designer-engine/src/data_designer/engine/models/clients/adapters/litellm_bridge.py
Show resolved
Hide resolved
Thanks @andreatgretel! All should be resolved now. |
📋 Summary
Introduces a provider-agnostic
ModelClientadapter boundary underengine/models/clients/as the foundation for replacing LiteLLM with native provider adapters. This is PR-1 of the model facade overhaul described inplans/343/model-facade-overhaul-plan-step-1.md.PR-1 is intentionally non-invasive: no
ModelFacadecall-site changes, no provider routing changes, no retry/throttle migration.🔄 Changes
✨ Added
clients/types.py) —ChatCompletionRequest/Response,EmbeddingRequest/Response,ImageGenerationRequest/Response, plus supporting types (Usage,ToolCall,ImagePayload,AssistantMessage)ModelClientprotocol (clients/base.py) — Composable protocols for chat completion, embeddings, and image generation with sync/async parity, capability checks, andclose/acloselifecycle methodsclients/errors.py) —ProviderErrorwith 13ProviderErrorKindvalues, deterministic HTTP status mapping (401→AUTHENTICATION, 403→PERMISSION_DENIED, etc.), and proper Python exception chaining via__cause__clients/adapters/litellm_bridge.py) — Wraps the existing LiteLLM router and normalizes responses into canonical types. Handles all three operations (chat, embeddings, images) with robust parsing for tool calls, image format waterfall (data URIs, base64, URLs, dicts), list-of-blocks content coercion, and usage extractionmodel-facade-overhaul-pr-1-architecture-notes.md) — Documents canonical adapter boundary contract, bridge purpose, and planned follow-ontest_client_errors.pyandtest_litellm_bridge.py— Covers error mapping (12 parametrized status codes), exception chaining, sync/async completion, embeddings, image generation (chat + diffusion paths), list-content coercion, tool-call argument serialization, empty-choices edge case, and lifecycle methods🔍 Attention Areas
clients/errors.py—_extract_response_texttries structured JSON extraction before falling back to raw text. This matters because httpxresponse.textcontains the full body (including raw JSON), so without this priority order every error message from providers with structured error bodies would be a raw JSON blobclients/adapters/litellm_bridge.py— Image parsing waterfall in_parse_image_payloadhandles 5+ response formats. Verify coverage against known provider response shapesclients/base.py—ModelClientprotocol includesclose/acloselifecycle methods for PR-2 registry/resource teardown wiring🤖 Generated with AI