From 939f387d5da0824c3c9dedfb8e797b1d47966ba0 Mon Sep 17 00:00:00 2001 From: Gustavo Larrea Date: Sat, 5 Sep 2026 00:50:16 +0200 Subject: [PATCH 1/6] fix: File type can be used with gemini --- dspy/adapters/types/file.py | 24 ++++++++++++++++++------ dspy/clients/openai_format.py | 2 ++ dspy/core/types.py | 2 +- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/dspy/adapters/types/file.py b/dspy/adapters/types/file.py index e2723abb23..2bd45cff84 100644 --- a/dspy/adapters/types/file.py +++ b/dspy/adapters/types/file.py @@ -36,6 +36,7 @@ class QA(dspy.Signature): file_data: str | None = None file_id: str | None = None filename: str | None = None + mime_type: str | None = None model_config = pydantic.ConfigDict( frozen=True, @@ -66,6 +67,7 @@ def validate_input(cls, values: Any) -> Any: "file_data": values.file_data, "file_id": values.file_id, "filename": values.filename, + "mime_type": values.mime_type, } if isinstance(values, dict): @@ -84,6 +86,8 @@ def format(self) -> list[dict[str, Any]]: file_dict["file_id"] = self.file_id if self.filename: file_dict["filename"] = self.filename + if self.mime_type: + file_dict["format"] = self.mime_type return [{"type": "file", "file": file_dict}] except Exception as e: @@ -98,9 +102,7 @@ def __repr__(self): if self.file_data.startswith("data:"): # file data has "data:text/plain;base64,..." format mime_type = self.file_data.split(";")[0].split(":")[1] - len_data = ( - len(self.file_data.split("base64,")[1]) if "base64," in self.file_data else len(self.file_data) - ) + len_data = len(self.file_data.split("base64,")[1]) if "base64," in self.file_data else len(self.file_data) parts.append(f"file_data=") else: len_data = len(self.file_data) @@ -109,6 +111,8 @@ def __repr__(self): parts.append(f"file_id='{self.file_id}'") if self.filename is not None: parts.append(f"filename='{self.filename}'") + if self.mime_type is not None: + parts.append(f"mime_type='{self.mime_type}'") return f"File({', '.join(parts)})" @classmethod @@ -155,9 +159,15 @@ def from_bytes( return cls(file_data=file_data, filename=filename) @classmethod - def from_file_id(cls, file_id: str, filename: str | None = None) -> "File": - """Create a File from an uploaded file ID.""" - return cls(file_id=file_id, filename=filename) + def from_file_id(cls, file_id: str, filename: str | None = None, mime_type: str | None = None) -> "File": + """Create a File from an uploaded file ID. + + Args: + file_id: The uploaded file ID + filename: Optional filename + mime_type: Optional MIME type (passed as 'format' to the API) + """ + return cls(file_id=file_id, filename=filename, mime_type=mime_type) def encode_file_to_dict(file_input: Any) -> dict: @@ -178,6 +188,8 @@ def encode_file_to_dict(file_input: Any) -> dict: result["file_id"] = file_input.file_id if file_input.filename is not None: result["filename"] = file_input.filename + if file_input.mime_type is not None: + result["format"] = file_input.mime_type return result elif isinstance(file_input, dict): diff --git a/dspy/clients/openai_format.py b/dspy/clients/openai_format.py index 2a18b136ee..9fc60c2e60 100644 --- a/dspy/clients/openai_format.py +++ b/dspy/clients/openai_format.py @@ -350,6 +350,8 @@ def binary_to_openai(binary: LMBinaryPart) -> dict[str, Any]: file_data["file_id"] = binary.file_id if binary.filename is not None: file_data["filename"] = binary.filename + if binary.media_type is not None: + file_data["format"] = binary.media_type return {"type": "file", "file": file_data} diff --git a/dspy/core/types.py b/dspy/core/types.py index 62f708ef2d..27250a88a6 100644 --- a/dspy/core/types.py +++ b/dspy/core/types.py @@ -1908,7 +1908,7 @@ def _binary_dict_to_part(file: dict[str, Any]) -> LMBinaryPart: media_type, data = _split_data_uri(file["data"]) return LMBinaryPart(data=data, media_type=media_type, filename=file.get("filename")) if file.get("file_id") is not None: - return LMBinaryPart(file_id=file["file_id"], filename=file.get("filename")) + return LMBinaryPart(file_id=file.get("file_id"), filename=file.get("filename"), media_type=file.get("format")) raise ValueError("Binary content block requires data, file_data, or file_id.") From edb02b574c38f2734a9c678184a1d573499c84ce Mon Sep 17 00:00:00 2001 From: Gustavo Larrea Date: Mon, 7 Sep 2026 14:45:53 +0200 Subject: [PATCH 2/6] fix: gemini media resolution added --- dspy/adapters/types/file.py | 17 ++++- dspy/clients/base_lm.py | 137 ++++++++++++++++++++++++---------- dspy/clients/openai_format.py | 3 + dspy/core/types.py | 4 +- 4 files changed, 118 insertions(+), 43 deletions(-) diff --git a/dspy/adapters/types/file.py b/dspy/adapters/types/file.py index 2bd45cff84..d2a18e7cc5 100644 --- a/dspy/adapters/types/file.py +++ b/dspy/adapters/types/file.py @@ -37,6 +37,7 @@ class QA(dspy.Signature): file_id: str | None = None filename: str | None = None mime_type: str | None = None + resolution: str | None = None model_config = pydantic.ConfigDict( frozen=True, @@ -68,6 +69,7 @@ def validate_input(cls, values: Any) -> Any: "file_id": values.file_id, "filename": values.filename, "mime_type": values.mime_type, + "resolution": values.resolution } if isinstance(values, dict): @@ -88,6 +90,8 @@ def format(self) -> list[dict[str, Any]]: file_dict["filename"] = self.filename if self.mime_type: file_dict["format"] = self.mime_type + if self.resolution: + file_dict["resolution"] = self.resolution return [{"type": "file", "file": file_dict}] except Exception as e: @@ -113,6 +117,8 @@ def __repr__(self): parts.append(f"filename='{self.filename}'") if self.mime_type is not None: parts.append(f"mime_type='{self.mime_type}'") + if self.resolution is not None: + parts.append(f"resolution='{self.resolution}'") return f"File({', '.join(parts)})" @classmethod @@ -123,6 +129,7 @@ def from_path(cls, file_path: str, filename: str | None = None, mime_type: str | file_path: Path to the file to read filename: Optional filename to use (defaults to basename of path) mime_type: Optional MIME type (defaults to auto-detection from file extension) + resolution: Optional resolution used to rescale the file by some providers """ if not os.path.isfile(file_path): raise ValueError(f"File not found: {file_path}") @@ -159,15 +166,19 @@ def from_bytes( return cls(file_data=file_data, filename=filename) @classmethod - def from_file_id(cls, file_id: str, filename: str | None = None, mime_type: str | None = None) -> "File": + def from_file_id(cls, file_id: str, filename: str | None = None, + mime_type: str | None = None, + resolution: str | None = None + ) -> "File": """Create a File from an uploaded file ID. Args: file_id: The uploaded file ID filename: Optional filename mime_type: Optional MIME type (passed as 'format' to the API) + resolution: Optional file resolution (e.g., low, medium, high) """ - return cls(file_id=file_id, filename=filename, mime_type=mime_type) + return cls(file_id=file_id, filename=filename, mime_type=mime_type, resolution=resolution) def encode_file_to_dict(file_input: Any) -> dict: @@ -190,6 +201,8 @@ def encode_file_to_dict(file_input: Any) -> dict: result["filename"] = file_input.filename if file_input.mime_type is not None: result["format"] = file_input.mime_type + if file_input.resolution is not None: + result["resolution"] = file_input.resolution return result elif isinstance(file_input, dict): diff --git a/dspy/clients/base_lm.py b/dspy/clients/base_lm.py index 172f8e9044..bd4c5e2a4f 100644 --- a/dspy/clients/base_lm.py +++ b/dspy/clients/base_lm.py @@ -48,10 +48,14 @@ def _import_lm_class(class_path: str) -> type: continue if not isinstance(obj, type): - raise TypeError(f"Serialized LM class `{class_path}` did not resolve to a class.") + raise TypeError( + f"Serialized LM class `{class_path}` did not resolve to a class." + ) return obj - raise ImportError(f"Could not import serialized LM class `{class_path}`.") from last_error + raise ImportError( + f"Could not import serialized LM class `{class_path}`." + ) from last_error class BaseLM: @@ -198,11 +202,15 @@ def __init__( self.cache = cache self.callbacks = list(callbacks or []) self.num_retries = num_retries - self.kwargs = self._get_initial_kwargs(temperature=temperature, max_tokens=max_tokens, **kwargs) + self.kwargs = self._get_initial_kwargs( + temperature=temperature, max_tokens=max_tokens, **kwargs + ) self.history = [] self._warned_zero_temp_rollout = False - def _get_initial_kwargs(self, *, temperature, max_tokens, **kwargs) -> dict[str, Any]: + def _get_initial_kwargs( + self, *, temperature, max_tokens, **kwargs + ) -> dict[str, Any]: return dict(temperature=temperature, max_tokens=max_tokens, **kwargs) def _declares_forward_contract(self) -> bool: @@ -292,7 +300,9 @@ def _process_lm_response(self, response, prompt, messages, **kwargs): outputs = self._process_completion(response, merged_kwargs) if not getattr(response, "cache_hit", False) and settings.usage_tracker: - settings.usage_tracker.add_usage(self.model, dict(getattr(response, "usage", {}) or {})) + settings.usage_tracker.add_usage( + self.model, dict(getattr(response, "usage", {}) or {}) + ) if settings.disable_history: return outputs @@ -353,19 +363,25 @@ def __call__( `LMResponse` for explicit `LMRequest` calls or `experimental=True`; otherwise DSPy's legacy list of output strings or dictionaries, even when a typed LM subclass uses the typed path internally. """ - return_typed_response, forward_contract, normalized_request = self._prepare_lm_call( - items=items, - prompt=prompt, - messages=messages, - request=request, - kwargs=kwargs, + return_typed_response, forward_contract, normalized_request = ( + self._prepare_lm_call( + items=items, + prompt=prompt, + messages=messages, + request=request, + kwargs=kwargs, + ) ) if normalized_request is None: - return self._legacy_call_direct(*items, prompt=prompt, messages=messages, **kwargs) + return self._legacy_call_direct( + *items, prompt=prompt, messages=messages, **kwargs + ) if forward_contract == "typed_lm": response = self.forward(normalized_request) - response = self._finalize_lm_response(normalized_request, self._validate_typed_lm_response(response)) + response = self._finalize_lm_response( + normalized_request, self._validate_typed_lm_response(response) + ) else: response = self._legacy_forward_as_lm_response(normalized_request) if return_typed_response: @@ -386,19 +402,25 @@ async def acall( This is the async equivalent of `__call__()`. It preserves legacy outputs by default and returns `dspy.LMResponse` for explicit `LMRequest` calls or experimental direct calls. """ - return_typed_response, forward_contract, normalized_request = self._prepare_lm_call( - items=items, - prompt=prompt, - messages=messages, - request=request, - kwargs=kwargs, + return_typed_response, forward_contract, normalized_request = ( + self._prepare_lm_call( + items=items, + prompt=prompt, + messages=messages, + request=request, + kwargs=kwargs, + ) ) if normalized_request is None: - return await self._legacy_acall_direct(*items, prompt=prompt, messages=messages, **kwargs) + return await self._legacy_acall_direct( + *items, prompt=prompt, messages=messages, **kwargs + ) if forward_contract == "typed_lm": response = await self.aforward(normalized_request) - response = self._finalize_lm_response(normalized_request, self._validate_typed_lm_response(response)) + response = self._finalize_lm_response( + normalized_request, self._validate_typed_lm_response(response) + ) else: response = await self._legacy_aforward_as_lm_response(normalized_request) if return_typed_response: @@ -414,8 +436,12 @@ def _prepare_lm_call( request: LMRequest | None, kwargs: dict[str, Any], ) -> tuple[bool, ForwardContract, LMRequest | None]: - explicit_request = request is not None or bool(items and isinstance(items[0], LMRequest)) - return_typed_response = explicit_request or bool(settings.get("experimental", False)) + explicit_request = request is not None or bool( + items and isinstance(items[0], LMRequest) + ) + return_typed_response = explicit_request or bool( + settings.get("experimental", False) + ) forward_contract = self._get_forward_contract() if not return_typed_response and forward_contract != "typed_lm": return return_typed_response, forward_contract, None @@ -463,7 +489,9 @@ async def _legacy_acall_direct( ) return self._process_lm_response(response, prompt, messages, **kwargs) - def _legacy_prompt_from_items(self, items: tuple[Any, ...], *, prompt: str | None) -> str | None: + def _legacy_prompt_from_items( + self, items: tuple[Any, ...], *, prompt: str | None + ) -> str | None: """Validate and extract the one positional prompt accepted by legacy calls.""" if len(items) > 1: raise TypeError( @@ -471,9 +499,13 @@ def _legacy_prompt_from_items(self, items: tuple[Any, ...], *, prompt: str | Non "Use dspy.context(experimental=True) or pass an LMRequest for typed multi-item LM calls." ) if items and prompt is not None: - raise TypeError("Pass a prompt either positionally or by keyword, not both.") + raise TypeError( + "Pass a prompt either positionally or by keyword, not both." + ) if items and isinstance(items[0], LMRequest): - raise TypeError("LMRequest calls require the typed LM path; this should be unreachable.") + raise TypeError( + "LMRequest calls require the typed LM path; this should be unreachable." + ) return items[0] if items else prompt def _normalize_lm_call( @@ -520,7 +552,9 @@ def _legacy_forward_as_lm_response(self, request: LMRequest) -> LMResponse: return self._finalize_lm_response(request, typed_response) with settings.context(disable_history=True, usage_tracker=None): outputs = self._process_lm_response(response, prompt, messages, **data) - lm_response = self._legacy_outputs_to_lm_response(outputs, request=request, provider_response=response) + lm_response = self._legacy_outputs_to_lm_response( + outputs, request=request, provider_response=response + ) return self._finalize_lm_response(request, lm_response) async def _legacy_aforward_as_lm_response(self, request: LMRequest) -> LMResponse: @@ -536,7 +570,9 @@ async def _legacy_aforward_as_lm_response(self, request: LMRequest) -> LMRespons return self._finalize_lm_response(request, typed_response) with settings.context(disable_history=True, usage_tracker=None): outputs = self._process_lm_response(response, prompt, messages, **data) - lm_response = self._legacy_outputs_to_lm_response(outputs, request=request, provider_response=response) + lm_response = self._legacy_outputs_to_lm_response( + outputs, request=request, provider_response=response + ) return self._finalize_lm_response(request, lm_response) def _legacy_outputs_to_lm_response( @@ -585,7 +621,9 @@ def _prompt_from_lm_request(self, request: LMRequest) -> str | None: part = message.parts[0] return part.text if getattr(part, "type", None) == "text" else None - def _finalize_lm_response(self, request: LMRequest, response: LMResponse) -> LMResponse: + def _finalize_lm_response( + self, request: LMRequest, response: LMResponse + ) -> LMResponse: """Record usage and typed history for a normalized LM response.""" if not getattr(response, "cache_hit", False) and settings.usage_tracker: usage = response.usage_as_dict() @@ -607,7 +645,7 @@ def forward( self, prompt: str | None = None, messages: list[dict[str, Any]] | None = None, - **kwargs + **kwargs, ): """Forward pass for the language model. @@ -637,7 +675,7 @@ async def aforward( self, prompt: str | None = None, messages: list[dict[str, Any]] | None = None, - **kwargs + **kwargs, ): """Async forward pass for the language model. @@ -674,7 +712,11 @@ def dump_state(self) -> dict[str, Any]: A dictionary that can be passed to `BaseLM.load_state`. The state excludes API keys. """ - filtered_kwargs = {key: value for key, value in self.kwargs.items() if key not in ("api_key", LM_CLASS_STATE_KEY)} + filtered_kwargs = { + key: value + for key, value in self.kwargs.items() + if key not in ("api_key", LM_CLASS_STATE_KEY) + } return { LM_CLASS_STATE_KEY: f"{type(self).__module__}.{type(self).__qualname__}", "model": self.model, @@ -685,7 +727,9 @@ def dump_state(self) -> dict[str, Any]: } @classmethod - def load_state(cls, state: dict[str, Any], *, allow_custom_lm_class: bool = False) -> "BaseLM": + def load_state( + cls, state: dict[str, Any], *, allow_custom_lm_class: bool = False + ) -> "BaseLM": """Reconstruct an LM from `dump_state` output. Legacy states without a class marker load as `dspy.LM`. Custom LM @@ -725,9 +769,16 @@ def load_state(cls, state: dict[str, Any], *, allow_custom_lm_class: bool = Fals lm_cls = _import_lm_class(class_path) if not issubclass(lm_cls, BaseLM): - raise TypeError(f"Serialized LM class `{class_path}` must be a subclass of dspy.BaseLM.") - if "allow_custom_lm_class" in inspect.signature(lm_cls.load_state).parameters: - return lm_cls.load_state(state, allow_custom_lm_class=allow_custom_lm_class) + raise TypeError( + f"Serialized LM class `{class_path}` must be a subclass of dspy.BaseLM." + ) + if ( + "allow_custom_lm_class" + in inspect.signature(lm_cls.load_state).parameters + ): + return lm_cls.load_state( + state, allow_custom_lm_class=allow_custom_lm_class + ) return lm_cls.load_state(state) return cls(**state) @@ -815,11 +866,17 @@ def _process_completion(self, response, merged_kwargs): output = {} output["text"] = c.message.content if hasattr(c, "message") else c["text"] - if hasattr(c, "message") and hasattr(c.message, "reasoning_content") and c.message.reasoning_content: + if ( + hasattr(c, "message") + and hasattr(c.message, "reasoning_content") + and c.message.reasoning_content + ): output["reasoning_content"] = c.message.reasoning_content if merged_kwargs.get("logprobs"): - output["logprobs"] = c.logprobs if hasattr(c, "logprobs") else c["logprobs"] + output["logprobs"] = ( + c.logprobs if hasattr(c, "logprobs") else c["logprobs"] + ) if hasattr(c, "message") and getattr(c.message, "tool_calls", None): output["tool_calls"] = c.message.tool_calls @@ -849,7 +906,9 @@ def _extract_citations_from_response(self, choice): # Check for citations in LiteLLM provider_specific_fields citations_data = choice.message.provider_specific_fields.get("citations") if isinstance(citations_data, list): - return [citation for citations in citations_data for citation in citations] + return [ + citation for citations in citations_data for citation in citations + ] except Exception: return None diff --git a/dspy/clients/openai_format.py b/dspy/clients/openai_format.py index 9fc60c2e60..ab76fa006b 100644 --- a/dspy/clients/openai_format.py +++ b/dspy/clients/openai_format.py @@ -352,6 +352,9 @@ def binary_to_openai(binary: LMBinaryPart) -> dict[str, Any]: file_data["filename"] = binary.filename if binary.media_type is not None: file_data["format"] = binary.media_type + if binary.resolution is not None: + file_data["detail"]=binary.resolution + return {"type": "file", "file": file_data} diff --git a/dspy/core/types.py b/dspy/core/types.py index 27250a88a6..2034058737 100644 --- a/dspy/core/types.py +++ b/dspy/core/types.py @@ -137,7 +137,7 @@ class LMBinaryPart(LMSourcePart): type: Literal["binary"] = "binary" media_type: str = "application/octet-stream" filename: str | None = None - + resolution: str | None = None class LMToolCallPart(LMBasePart): """A model request to call a tool. @@ -1908,7 +1908,7 @@ def _binary_dict_to_part(file: dict[str, Any]) -> LMBinaryPart: media_type, data = _split_data_uri(file["data"]) return LMBinaryPart(data=data, media_type=media_type, filename=file.get("filename")) if file.get("file_id") is not None: - return LMBinaryPart(file_id=file.get("file_id"), filename=file.get("filename"), media_type=file.get("format")) + return LMBinaryPart(file_id=file.get("file_id"), filename=file.get("filename"), media_type=file.get("format"), resolution=file.get("resolution")) raise ValueError("Binary content block requires data, file_data, or file_id.") From 0b717c128394b59e37f33a110d68d013b4339d22 Mon Sep 17 00:00:00 2001 From: Gustavo Larrea Date: Wed, 23 Sep 2026 21:57:31 +0200 Subject: [PATCH 3/6] fix: resolution included in File type --- dspy/adapters/types/file.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dspy/adapters/types/file.py b/dspy/adapters/types/file.py index d2a18e7cc5..dfb11c1890 100644 --- a/dspy/adapters/types/file.py +++ b/dspy/adapters/types/file.py @@ -122,7 +122,7 @@ def __repr__(self): return f"File({', '.join(parts)})" @classmethod - def from_path(cls, file_path: str, filename: str | None = None, mime_type: str | None = None) -> "File": + def from_path(cls, file_path: str, filename: str | None = None, mime_type: str | None = None, resolution: str | None = None) -> "File": """Create a File from a local file path. Args: @@ -148,7 +148,7 @@ def from_path(cls, file_path: str, filename: str | None = None, mime_type: str | encoded_data = base64.b64encode(file_bytes).decode("utf-8") file_data = f"data:{mime_type};base64,{encoded_data}" - return cls(file_data=file_data, filename=filename) + return cls(file_data=file_data, filename=filename, resolution=resolution) @classmethod def from_bytes( From dd6fbfacc2055af7c8d0fad3078158787c941c6b Mon Sep 17 00:00:00 2001 From: Gustavo Larrea Date: Fri, 25 Sep 2026 17:38:26 +0200 Subject: [PATCH 4/6] fix: resolution added to from_bytes --- dspy/adapters/types/file.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/dspy/adapters/types/file.py b/dspy/adapters/types/file.py index dfb11c1890..f8ac23b553 100644 --- a/dspy/adapters/types/file.py +++ b/dspy/adapters/types/file.py @@ -152,7 +152,9 @@ def from_path(cls, file_path: str, filename: str | None = None, mime_type: str | @classmethod def from_bytes( - cls, file_bytes: bytes, filename: str | None = None, mime_type: str = "application/octet-stream" + cls, file_bytes: bytes, filename: str | None = None, + resolution:str="medium", + mime_type: str = "application/octet-stream", ) -> "File": """Create a File from raw bytes. @@ -163,7 +165,7 @@ def from_bytes( """ encoded_data = base64.b64encode(file_bytes).decode("utf-8") file_data = f"data:{mime_type};base64,{encoded_data}" - return cls(file_data=file_data, filename=filename) + return cls(file_data=file_data, filename=filename, resolution=resolution) @classmethod def from_file_id(cls, file_id: str, filename: str | None = None, From 25d70d91496e334f5e9da56b135adc8caac377a5 Mon Sep 17 00:00:00 2001 From: Gustavo Larrea Date: Fri, 25 Sep 2026 19:17:13 +0200 Subject: [PATCH 5/6] cleaning files --- dspy/clients/openai_format.py | 1037 --------------------------------- 1 file changed, 1037 deletions(-) delete mode 100644 dspy/clients/openai_format.py diff --git a/dspy/clients/openai_format.py b/dspy/clients/openai_format.py deleted file mode 100644 index ab76fa006b..0000000000 --- a/dspy/clients/openai_format.py +++ /dev/null @@ -1,1037 +0,0 @@ -"""Translate between DSPy's LM types and OpenAI-shaped JSON. - -This module does not call any provider. It only maps data shapes: - -```text -LMRequest -> OpenAI Chat / Responses / text-completion kwargs -provider response -> LMResponse -``` - -The concrete OpenAI backends import these functions, then add transport, -streaming, and caching around them. Read the file in -this order when learning it: - -1. `to_openai_chat_request()` maps chat-completion requests. -2. `to_openai_responses_request()` maps Responses API requests. -3. `to_openai_text_request()` maps legacy text-completion requests. -4. `completion_to_lm_response()` and `responses_to_lm_response()` map outputs. -5. The final utility section handles media sources, data URIs, and object/dict - access. -""" - -from __future__ import annotations - -import base64 -import json -import mimetypes -import os -from typing import Any - -import pydantic - -from dspy.core.types import ( - LMAudioPart, - LMBinaryPart, - LMCitationPart, - LMConfig, - LMDocumentPart, - LMImagePart, - LMMessage, - LMOutput, - LMRefusalPart, - LMRequest, - LMResponse, - LMTextPart, - LMThinkingPart, - LMToolCallPart, - LMToolChoice, - LMToolResultPart, - LMToolSpec, - LMUsage, - LMVideoPart, -) - -__all__ = [ - "to_openai_chat_request", - "to_openai_responses_request", - "to_openai_text_request", - "tool_to_openai_responses", - "completion_to_lm_response", - "responses_to_lm_response", - "provider_tool_call_to_part", - "responses_function_call_to_part", - "usage_from_response", -] - - -# --------------------------------------------------------------------------- -# DSPy request -> OpenAI Chat Completions -# -# Chat Completions expects: -# {"model": ..., "messages": [...], "tools": [...], ...config} -# Each DSPy message stays one OpenAI message, except assistant tool calls are -# split into OpenAI's top-level `tool_calls` field on the assistant message. -# --------------------------------------------------------------------------- - - -def to_openai_chat_request(request: LMRequest) -> dict[str, Any]: - """Convert a normalized DSPy request into Chat Completions kwargs.""" - data = {"model": request.model, "messages": [message_to_openai_chat(message) for message in request.messages]} - data.update(common_config_kwargs(request.config, model=request.model, endpoint="chat")) - if request.config.tool_choice is not None: - data.update(tool_choice_to_openai(request.config.tool_choice)) - if request.tools: - data["tools"] = [tool_to_openai(tool) for tool in request.tools] - return data - - -def message_to_openai_chat(message: LMMessage) -> dict[str, Any]: - """Convert one DSPy message into one Chat Completions message.""" - output: dict[str, Any] = {"role": message.role} - if message.name is not None: - output["name"] = message.name - - if message.role == "assistant": - tool_calls = [part for part in message.parts if isinstance(part, LMToolCallPart)] - content_parts = [part for part in message.parts if not isinstance(part, LMToolCallPart)] - output["content"] = None if tool_calls and not content_parts else parts_to_openai_content(content_parts) - if tool_calls: - output["tool_calls"] = [assistant_tool_call_to_openai(part) for part in tool_calls] - return output - - if message.role == "tool" and len(message.parts) == 1 and isinstance(message.parts[0], LMToolResultPart): - result = message.parts[0] - output.update(tool_result_to_openai(result)) - if result.call_id is not None: - output["tool_call_id"] = result.call_id - if result.name is not None: - output["name"] = result.name - return output - - output["content"] = parts_to_openai_content(message.parts) - return output - - -# --------------------------------------------------------------------------- -# DSPy request -> OpenAI Responses -# -# Responses expects a flat `input` list, not a chat `messages` list. A single -# DSPy assistant message can therefore become two kinds of input items: one -# message item for content and one function_call item for each tool call. -# --------------------------------------------------------------------------- - - -def to_openai_responses_request(request: LMRequest, *, enforce_reasoning_temperature: bool = True) -> dict[str, Any]: - """Convert a normalized DSPy request into Responses API kwargs.""" - config = request.config - data: dict[str, Any] = { - "model": request.model, - "input": [item for message in request.messages for item in message_to_responses_input_items(message)], - } - data.update( - responses_config_kwargs(config, model=request.model, enforce_reasoning_temperature=enforce_reasoning_temperature) - ) - if config.tool_choice is not None: - data.update(tool_choice_to_openai_responses(config.tool_choice)) - if request.tools: - data["tools"] = [tool_to_openai_responses(tool) for tool in request.tools] - return data - - -def message_to_responses_input_items(message: LMMessage) -> list[dict[str, Any]]: - """Convert one DSPy message into one or more Responses input items.""" - if message.role == "tool" and len(message.parts) == 1 and isinstance(message.parts[0], LMToolResultPart): - result = message.parts[0] - item = {"type": "function_call_output", "output": responses_tool_output_text(tool_result_to_openai(result)["content"])} - if result.call_id is not None: - item["call_id"] = result.call_id - return [item] - - tool_calls = [part for part in message.parts if isinstance(part, LMToolCallPart)] - content_parts = [part for part in message.parts if not isinstance(part, LMToolCallPart)] - content = parts_to_responses_content(content_parts, role=message.role) - items: list[dict[str, Any]] = [] - - if content or message.role != "assistant" or not tool_calls: - item: dict[str, Any] = {"role": message.role, "content": content} - if message.name is not None: - item["name"] = message.name - items.append(item) - - if message.role == "assistant": - items.extend(tool_call_to_responses_input(tool_call) for tool_call in tool_calls) - return items - - -def parts_to_responses_content(parts: list[Any], role: str = "user") -> list[dict[str, Any]]: - blocks = parts_to_openai_content(parts) - if isinstance(blocks, str): - return [{"type": _responses_text_type(role), "text": blocks}] - return [content_block_to_responses(block, role) for block in blocks] - - -def _responses_text_type(role: str) -> str: - # The Responses API types text by direction, not just by role slot: assistant - # input items replay *model output*, so their text must be "output_text"; - # "input_text" on an assistant item is rejected with a 400. - return "output_text" if role == "assistant" else "input_text" - - -def tool_call_to_responses_input(tool_call_part: LMToolCallPart) -> dict[str, Any]: - tool_call = assistant_tool_call_to_openai(tool_call_part) - function = tool_call.get("function", {}) - item = {"type": "function_call", "name": function.get("name", ""), "arguments": function.get("arguments", "{}")} - call_id = tool_call.get("id") or tool_call.get("call_id") - if call_id is not None: - item["call_id"] = call_id - return item - - -def content_block_to_responses(block: dict[str, Any], role: str = "user") -> dict[str, Any]: - block_type = block.get("type") - if block_type == "text": - return {"type": _responses_text_type(role), "text": block.get("text", "")} - if block_type == "image_url": - image_url = block.get("image_url", {}) - out = {"type": "input_image", "image_url": image_url.get("url", "")} - if image_url.get("detail") is not None: - out["detail"] = image_url["detail"] - return out - if block_type == "input_audio": - return block - if block_type == "file": - file = block.get("file", {}) - return { - "type": "input_file", - "file_data": file.get("file_data"), - "filename": file.get("filename"), - "file_id": file.get("file_id"), - } - return block - - -# --------------------------------------------------------------------------- -# DSPy request -> OpenAI text completions -# -# Text completions have no native message roles. We concatenate text-only -# messages with blank lines and append DSPy's historical response marker. -# --------------------------------------------------------------------------- - - -def to_openai_text_request(request: LMRequest) -> dict[str, Any]: - """Convert a normalized DSPy request into text-completion kwargs.""" - data = {"model": request.model, "prompt": messages_to_text_prompt(request.messages)} - data.update(text_config_kwargs(request.config)) - return data - - -def messages_to_text_prompt(messages: list[LMMessage]) -> str: - """Flatten text-only messages into the prompt used by text completions.""" - chunks = [] - for message in messages: - texts = [] - for part in message.parts: - if not isinstance(part, LMTextPart): - raise ValueError(f"OpenAI text completions only support text parts, but received {type(part).__name__}.") - texts.append(part.text) - chunks.append("".join(texts)) - return "\n\n".join(chunks + ["BEGIN RESPONSE:"]) - - -# --------------------------------------------------------------------------- -# Shared request mappers -# -# The three OpenAI endpoint families use slightly different outer envelopes, but most -# leaf values are the same: text parts, media parts, tools, tool choices, and -# generation config. The helpers below keep those conversions in one place. -# --------------------------------------------------------------------------- - - -def parts_to_openai_content(parts: list[Any]) -> str | list[dict[str, Any]]: - """Convert DSPy parts into OpenAI `content`. - - OpenAI accepts a bare string for the common single-text case. Mixed content - becomes a list of content blocks. - """ - if len(parts) == 1 and isinstance(parts[0], LMTextPart) and "legacy_content_block" not in parts[0].metadata: - return parts[0].text - blocks: list[dict[str, Any]] = [] - for part in parts: - blocks.extend(part_to_openai_blocks(part)) - return blocks - - -def part_to_openai_blocks(part: Any) -> list[dict[str, Any]]: - """Convert one DSPy part into one or more OpenAI content blocks.""" - legacy_block = getattr(part, "metadata", {}).get("legacy_content_block") - if legacy_block is not None: - return [dict(legacy_block)] - if isinstance(part, LMTextPart): - return [{"type": "text", "text": part.text}] - if isinstance(part, LMImagePart): - return [image_to_openai(part)] - if isinstance(part, LMDocumentPart): - return document_to_openai_blocks(part) - if isinstance(part, LMAudioPart): - return [audio_to_openai(part)] - if isinstance(part, LMVideoPart): - return [video_to_openai(part)] - if isinstance(part, LMBinaryPart): - return [binary_to_openai(part)] - if isinstance(part, LMThinkingPart): - return [{"type": "text", "text": part.text}] - if isinstance(part, LMCitationPart): - citation = " ".join(value for value in (part.title, part.text, part.url) if value) - return [{"type": "text", "text": citation}] - if isinstance(part, LMToolResultPart): - return part_to_openai_blocks(LMTextPart(text="".join(part_text(value) for value in part.content))) - return [{"type": "text", "text": str(part)}] - - -def image_to_openai(image: LMImagePart) -> dict[str, Any]: - image_url: dict[str, Any] = {"url": media_source(image)} - if image.detail is not None: - image_url["detail"] = image.detail - return {"type": "image_url", "image_url": image_url} - - -def audio_to_openai(audio: LMAudioPart) -> dict[str, Any]: - if audio.data is not None: - data = audio.data - media_type = audio.media_type - elif audio.path is not None: - data = read_path_base64(audio.path) - media_type = media_type_for_path(audio.path, fallback=audio.media_type) - else: - raise ValueError("OpenAI-format audio input requires base64 `data` or local `path`.") - return {"type": "input_audio", "input_audio": {"data": data, "format": media_format(media_type)}} - - -def document_to_openai_blocks(document: LMDocumentPart) -> list[dict[str, Any]]: - block: dict[str, Any] = {"type": "document"} - if document.source is not None: - block["source"] = document.source - else: - block["source"] = media_source(document) - block["media_type"] = document.media_type - if document.citations: - block["citations"] = document.citations - if document.title is not None: - block["title"] = document.title - if document.context is not None: - block["context"] = document.context - return [block] - - -def video_to_openai(video: LMVideoPart) -> dict[str, Any]: - filename = os.path.basename(video.path) if video.path is not None else None - return binary_to_openai( - LMBinaryPart( - data=video.data, - url=video.url, - file_id=video.file_id, - path=video.path, - media_type=video.media_type, - filename=filename, - ) - ) - - -def binary_to_openai(binary: LMBinaryPart) -> dict[str, Any]: - file_data: dict[str, Any] = {} - if binary.data is not None: - file_data["file_data"] = data_uri(binary.media_type, binary.data) - elif binary.path is not None: - file_data["file_data"] = data_uri_from_path(binary.path, fallback_media_type=binary.media_type) - file_data["filename"] = binary.filename or os.path.basename(binary.path) - elif binary.url is not None: - file_data["file_data"] = binary.url - if binary.file_id is not None: - file_data["file_id"] = binary.file_id - if binary.filename is not None: - file_data["filename"] = binary.filename - if binary.media_type is not None: - file_data["format"] = binary.media_type - if binary.resolution is not None: - file_data["detail"]=binary.resolution - - return {"type": "file", "file": file_data} - - -_TOOL_SPEC_WIRE_KEYS = {"type", "name", "description", "parameters", "strict"} - - -def _tool_provider_extras(tool: LMToolSpec) -> dict[str, Any]: - return {key: value for key, value in tool.provider_data.items() if key not in _TOOL_SPEC_WIRE_KEYS} - - -def tool_to_openai(tool: LMToolSpec) -> dict[str, Any]: - # provider_data holds function-scoped extras; each dialect emits them where - # it puts function fields — nested under "function" here, flattened to the - # top level in the Responses shape. - data = {"type": "function", "function": {"name": tool.name, "parameters": tool.parameters}} - if tool.description is not None: - data["function"]["description"] = tool.description - if tool.strict is not None: - data["function"]["strict"] = tool.strict - data["function"].update(_tool_provider_extras(tool)) - return data - - -def tool_to_openai_responses(tool: LMToolSpec) -> dict[str, Any]: - """Convert a normalized tool spec into Responses API function-tool shape.""" - data = {"type": "function", "name": tool.name, "parameters": tool.parameters} - if tool.description is not None: - data["description"] = tool.description - if tool.strict is not None: - data["strict"] = tool.strict - data.update(_tool_provider_extras(tool)) - return data - - -def tool_choice_to_openai(choice: LMToolChoice) -> dict[str, Any]: - if choice.allowed: - if len(choice.allowed) != 1 or choice.mode not in {"required", "auto"}: - raise ValueError( - "OpenAI-format tool_choice only supports constraining to a single allowed tool " - "with mode 'required' or 'auto'." - ) - data: dict[str, Any] = {"tool_choice": {"type": "function", "function": {"name": choice.allowed[0]}}} - else: - data = {"tool_choice": choice.mode} - if choice.parallel is not None: - data["parallel_tool_calls"] = choice.parallel - return data - - -def tool_choice_to_openai_responses(choice: LMToolChoice) -> dict[str, Any]: - if choice.allowed: - if len(choice.allowed) != 1 or choice.mode not in {"required", "auto"}: - raise ValueError( - "OpenAI Responses tool_choice only supports constraining to a single allowed tool " - "with mode 'required' or 'auto'." - ) - data: dict[str, Any] = {"tool_choice": {"type": "function", "name": choice.allowed[0]}} - else: - data = {"tool_choice": choice.mode} - if choice.parallel is not None: - data["parallel_tool_calls"] = choice.parallel - return data - - -# provider_data on parsed tool calls echoes raw provider fields (the fc_* item -# id, status) and parser diagnostics; none of these may override the canonical -# wire fields or leak into requests. -_TOOL_CALL_INTERNAL_KEYS = {"id", "call_id", "status", "raw_arguments", "arguments_parse_error"} - - -def assistant_tool_call_to_openai(call: LMToolCallPart) -> dict[str, Any]: - data = {key: value for key, value in call.provider_data.items() if key not in _TOOL_CALL_INTERNAL_KEYS} - data["type"] = "function" - data["function"] = {"name": call.name, "arguments": json.dumps(call.args)} - if call.id is not None: - data["id"] = call.id - return data - - -def tool_result_to_openai(result: LMToolResultPart) -> dict[str, Any]: - return {"content": parts_to_openai_content(result.content)} - - -def common_config_kwargs(config: LMConfig, *, model: str | None = None, endpoint: str = "chat") -> dict[str, Any]: - """Convert shared DSPy config fields into Chat Completions kwargs.""" - data = dict(config.extensions) - _validate_openai_reasoning_temperature(config, model=model, endpoint=endpoint) - for key in ("temperature", "top_p"): - value = getattr(config, key) - if value is not None: - data[key] = value - if config.max_tokens is not None: - token_key = "max_completion_tokens" if _uses_max_completion_tokens(model) else "max_tokens" - data[token_key] = config.max_tokens - if config.stop: - data["stop"] = config.stop - if config.logprobs is not None: - data["logprobs"] = config.logprobs - if config.n is not None: - data["n"] = config.n - if config.response_format is not None: - data["response_format"] = config.response_format - if config.reasoning is not None: - data.update(reasoning_to_chat_kwargs(config.reasoning)) - if config.prompt_cache is not None: - data.update(prompt_cache_to_kwargs(config.prompt_cache)) - return data - - -def responses_config_kwargs( - config: LMConfig, *, model: str | None = None, enforce_reasoning_temperature: bool = True -) -> dict[str, Any]: - """Convert shared DSPy config fields into Responses API kwargs.""" - data = dict(config.extensions) if config.extensions else {} - if enforce_reasoning_temperature: - _validate_openai_reasoning_temperature(config, model=model, endpoint="responses") - for key in ("temperature", "top_p"): - value = getattr(config, key) - if value is not None: - data[key] = value - if config.max_tokens is not None: - data["max_output_tokens"] = config.max_tokens - if config.n is not None: - data["n"] = config.n - if config.logprobs is not None: - data["logprobs"] = config.logprobs - if config.stop: - data["stop"] = config.stop - if config.reasoning is not None: - data.update(reasoning_to_responses_kwargs(config.reasoning)) - if config.prompt_cache is not None: - data.update(prompt_cache_to_kwargs(config.prompt_cache)) - if config.response_format is not None: - text = data.pop("text", {}) - data["text"] = {**text, "format": response_format_to_responses(config.response_format)} - return data - - -def text_config_kwargs(config: LMConfig) -> dict[str, Any]: - """Convert shared DSPy config fields into text-completion kwargs.""" - data = dict(config.extensions) - for key in ("temperature", "max_tokens", "top_p"): - value = getattr(config, key) - if value is not None: - data[key] = value - if config.stop: - data["stop"] = config.stop - if config.logprobs is not None: - data["logprobs"] = config.logprobs - if config.n is not None: - data["n"] = config.n - return data - - -def reasoning_to_chat_kwargs(reasoning: Any) -> dict[str, Any]: - data = {} - if reasoning.effort is not None: - data["reasoning_effort"] = reasoning.effort - return data - - -def reasoning_to_responses_kwargs(reasoning: Any) -> dict[str, Any]: - data = {} - if reasoning.effort is not None: - data["effort"] = reasoning.effort - if reasoning.summary is not None: - data["summary"] = reasoning.summary - return {"reasoning": data} if data else {} - - -def _validate_openai_reasoning_temperature(config: LMConfig, *, model: str | None, endpoint: str) -> None: - if not _is_openai_reasoning_model(model): - return - effort = getattr(config.reasoning, "effort", None) if config.reasoning is not None else None - if effort in {None, "none"}: - return - if config.temperature in {None, 1}: - return - - from dspy.utils.exceptions import LMUnsupportedFeatureError - - raise LMUnsupportedFeatureError( - "OpenAI reasoning models only support the default temperature when reasoning effort is active. " - "Use temperature=None or temperature=1, or set reasoning_effort='none'.", - model=model, - provider="openai", - features=["temperature", "reasoning"], - issues=[ - f"{endpoint} request used reasoning effort {effort!r} with temperature={config.temperature!r}.", - ], - ) - - -def _uses_max_completion_tokens(model: str | None) -> bool: - return _is_openai_reasoning_model(model) - - -def _is_openai_reasoning_model(model: str | None) -> bool: - if not isinstance(model, str): - return False - model_name = model.removeprefix("openai/").lower() - if "chat" in model_name: - return False - return model_name.startswith(("o1", "o3", "o4", "gpt-5")) - - -def prompt_cache_to_kwargs(cache: Any) -> dict[str, Any]: - data = {} - if cache.key is not None: - data["prompt_cache_key"] = cache.key - if cache.enabled is False: - data["prompt_cache"] = False - return data - - -def response_format_to_responses(value: Any) -> Any: - if isinstance(value, type) and issubclass(value, pydantic.BaseModel): - return { - "name": value.__name__, - "type": "json_schema", - "schema": _close_object_schemas(value.model_json_schema()), - } - return value - - -def _close_object_schemas(schema: Any) -> Any: - """Recursively supply ``additionalProperties: false`` on object schemas. - - The Responses API rejects ``json_schema`` formats whose object schemas leave - ``additionalProperties`` unspecified; pydantic's ``model_json_schema()`` - omits it. Schemas that already set it (e.g. ``dict[str, T]`` fields) are - left untouched. Recursion follows JSON Schema keyword positions only, so - schema-shaped values inside ``default``/``examples``/``const`` are never - rewritten. - """ - if not isinstance(schema, dict): - return schema - closed = dict(schema) - for key in ("items", "additionalProperties", "not", "if", "then", "else", "contains", "propertyNames"): - if isinstance(closed.get(key), dict): - closed[key] = _close_object_schemas(closed[key]) - for key in ("items", "prefixItems", "anyOf", "oneOf", "allOf"): - if isinstance(closed.get(key), list): - closed[key] = [_close_object_schemas(item) for item in closed[key]] - for key in ("properties", "patternProperties", "$defs", "definitions"): - if isinstance(closed.get(key), dict): - closed[key] = {name: _close_object_schemas(sub) for name, sub in closed[key].items()} - if closed.get("type") == "object" and "additionalProperties" not in closed: - closed["additionalProperties"] = False - return closed - - -def responses_tool_output_text(content: Any) -> str: - if isinstance(content, str): - return content - if isinstance(content, list): - return "".join( - block.get("text", "") if isinstance(block, dict) and block.get("type") in {"text", "input_text"} else str(block) - for block in content - ) - return str(content) - - -# --------------------------------------------------------------------------- -# OpenAI response -> DSPy response -# -# Chat and text completions return `choices`; Responses returns one `output` -# list. Both are normalized to an `LMResponse` with one or more `LMOutput`s and -# typed DSPy parts. -# --------------------------------------------------------------------------- - - -def completion_to_lm_response(response: Any, request: LMRequest) -> LMResponse: - """Convert an OpenAI Chat or text completion response into `LMResponse`.""" - choices = get_value(response, "choices", []) or [] - return LMResponse( - model=get_value(response, "model") or request.model, - outputs=[choice_to_lm_output(choice) for choice in choices], - usage=usage_from_response(response), - cache_hit=bool(get_value(response, "cache_hit", False)), - response_id=get_value(response, "id"), - provider_response=response, - ) - - -def choice_to_lm_output(choice: Any) -> LMOutput: - """Convert one completion choice into one DSPy output candidate.""" - message = get_value(choice, "message") - parts = [] - if message is not None: - reasoning = get_value(message, "reasoning_content") - if reasoning: - parts.append(LMThinkingPart(text=str(reasoning))) - content = get_value(message, "content") - if content: - parts.extend(message_content_to_parts(content)) - for tool_call in get_value(message, "tool_calls") or []: - parts.append(provider_tool_call_to_part(tool_call)) - parts.extend(extract_citations_from_choice(choice)) - else: - text = get_value(choice, "text") - if text: - parts.extend(message_content_to_parts(text)) - finish_reason = get_value(choice, "finish_reason") - return LMOutput( - parts=parts, - finish_reason=finish_reason, - truncated=finish_reason == "length", - logprobs=get_value(choice, "logprobs"), - provider_output=choice, - ) - - -def responses_to_lm_response(response: Any, request: LMRequest) -> LMResponse: - """Convert an OpenAI Responses object into `LMResponse`. - - The Responses API represents one assistant answer as a sequence of output - items: messages, function calls, reasoning, binary artifacts, images, and refusals. - DSPy stores those as typed parts on one `LMOutput`. - """ - parts = [] - for output_item in get_value(response, "output", []) or []: - output_type = get_value(output_item, "type") - if output_type == "message": - for content_item in get_value(output_item, "content", []) or []: - parts.extend(response_content_item_to_parts(content_item)) - parts.extend(responses_annotations_to_citations(content_item)) - elif output_type == "function_call": - parts.append(responses_function_call_to_part(output_item)) - elif output_type in {"image", "output_image", "image_generation_call"}: - parts.append(output_image_to_part(output_item)) - elif output_type in {"audio", "output_audio"}: - parts.append(output_audio_to_part(output_item)) - elif output_type in {"file", "output_file"}: - parts.append(output_file_to_part(output_item)) - elif output_type == "refusal": - parts.append(refusal_to_part(output_item)) - elif output_type == "reasoning": - for item in get_value(output_item, "content") or get_value(output_item, "summary") or []: - text = get_value(item, "text") - if text: - parts.append(LMThinkingPart(text=text)) - return LMResponse( - model=get_value(response, "model") or request.model, - outputs=[LMOutput(parts=parts, provider_output=response)], - usage=usage_from_response(response), - cache_hit=bool(get_value(response, "cache_hit", False)), - response_id=get_value(response, "id"), - provider_response=response, - ) - - -def message_content_to_parts(content: Any) -> list[Any]: - if isinstance(content, str): - return [LMTextPart(text=content)] - if not isinstance(content, list): - return [LMTextPart(text=str(content))] - parts = [] - for item in content: - parts.extend(response_content_item_to_parts(item)) - return parts - - -def response_content_item_to_parts(item: Any) -> list[Any]: - item_type = get_value(item, "type") - text = get_value(item, "text") - if item_type in {"text", "output_text", "input_text"} or (text is not None and item_type is None): - return [LMTextPart(text=text)] - if item_type in {"refusal", "output_refusal"}: - return [refusal_to_part(item)] - if item_type in {"image", "output_image", "image_url"}: - return [output_image_to_part(item)] - if item_type in {"audio", "output_audio", "input_audio"}: - return [output_audio_to_part(item)] - if item_type in {"file", "output_file", "input_file"}: - return [output_file_to_part(item)] - if item_type in {"tool_call", "function_call"}: - return [provider_tool_call_to_part(item)] - return [] - - -def provider_tool_call_to_part(tool_call: Any) -> LMToolCallPart: - """Convert an OpenAI-shaped tool call into a DSPy tool-call part.""" - function = get_value(tool_call, "function", {}) - name = get_value(function, "name") or get_value(tool_call, "name") - arguments = get_value(function, "arguments", get_value(tool_call, "arguments", "{}")) - provider_data = model_dump(tool_call) - try: - args = json.loads(arguments) if isinstance(arguments, str) else dict(arguments) - except Exception as error: - args = {} - provider_data["raw_arguments"] = arguments - provider_data["arguments_parse_error"] = str(error) - call_id = get_value(tool_call, "call_id") or get_value(tool_call, "id") - return LMToolCallPart(id=call_id, name=name or "", args=args, provider_data=provider_data) - - -def responses_function_call_to_part(output_item: Any) -> LMToolCallPart: - """Convert one Responses function_call item into a DSPy tool-call part.""" - args = get_value(output_item, "arguments", {}) - provider_data = model_dump(output_item) - if isinstance(args, str): - try: - args = json.loads(args) - except Exception as error: - provider_data["raw_arguments"] = args - provider_data["arguments_parse_error"] = str(error) - args = {} - return LMToolCallPart(id=get_value(output_item, "call_id"), name=get_value(output_item, "name", ""), args=args, provider_data=provider_data) - - -def citation_to_part(citation: Any) -> LMCitationPart: - if hasattr(citation, "model_dump"): - citation = model_dump(citation) - if not isinstance(citation, dict): - citation = {"text": str(citation)} - citation_fields = {"cited_text", "text", "supported_text", "document_title", "title", "url"} - return LMCitationPart( - text=citation.get("cited_text") or citation.get("text") or citation.get("supported_text"), - title=citation.get("document_title") or citation.get("title"), - url=citation.get("url"), - metadata={key: value for key, value in citation.items() if key not in citation_fields}, - ) - - -def extract_citations_from_choice(choice: Any) -> list[LMCitationPart]: - try: - message = get_value(choice, "message") - provider_specific_fields = get_value(message, "provider_specific_fields", {}) or {} - citations_data = provider_specific_fields.get("citations") - if isinstance(citations_data, list): - citations = [] - for item in citations_data: - citations.extend(item if isinstance(item, list) else [item]) - return [citation_to_part(citation) for citation in citations] - except Exception: - return [] - return [] - - -def responses_annotations_to_citations(content_item: Any) -> list[LMCitationPart]: - return [citation_to_part(annotation) for annotation in get_value(content_item, "annotations", []) or []] - - -def output_image_to_part(value: Any) -> LMImagePart: - data = model_dump(value) - image_url = data.get("image_url") - if isinstance(image_url, dict): - image_url = image_url.get("url") - source = image_url or data.get("url") - b64_data = data.get("b64_json") or data.get("data") - file_id = data.get("file_id") - media_type = data.get("media_type") or data.get("mime_type") or "image/png" - detail = data.get("detail") - if b64_data is not None: - if isinstance(b64_data, str) and b64_data.startswith("data:"): - media_type, b64_data = split_data_uri(b64_data) - return LMImagePart(data=b64_data, media_type=media_type, detail=detail) - if source is not None: - return LMImagePart(url=source, media_type=media_type, detail=detail) - if file_id is not None: - return LMImagePart(file_id=file_id, media_type=media_type, detail=detail) - raise ValueError("Provider image output did not include data, url, or file_id.") - - -def output_audio_to_part(value: Any) -> LMAudioPart: - data = model_dump(value) - audio = data.get("audio") if isinstance(data.get("audio"), dict) else data - source = audio.get("url") - b64_data = audio.get("data") or audio.get("b64_json") - file_id = audio.get("file_id") - media_type = audio.get("media_type") or audio.get("mime_type") or "audio/wav" - if b64_data is not None: - if isinstance(b64_data, str) and b64_data.startswith("data:"): - media_type, b64_data = split_data_uri(b64_data) - return LMAudioPart(data=b64_data, media_type=media_type) - if source is not None: - return LMAudioPart(url=source, media_type=media_type) - if file_id is not None: - return LMAudioPart(file_id=file_id, media_type=media_type) - raise ValueError("Provider audio output did not include data, url, or file_id.") - - -def output_file_to_part(value: Any) -> LMBinaryPart: - data = model_dump(value) - file = data.get("file") if isinstance(data.get("file"), dict) else data - source = file.get("url") - b64_data = file.get("file_data") or file.get("data") - file_id = file.get("file_id") or file.get("id") - filename = file.get("filename") - media_type = file.get("media_type") or file.get("mime_type") or "application/octet-stream" - if b64_data is not None: - if isinstance(b64_data, str) and b64_data.startswith("data:"): - media_type, b64_data = split_data_uri(b64_data) - return LMBinaryPart(data=b64_data, media_type=media_type, filename=filename) - if source is not None: - return LMBinaryPart(url=source, media_type=media_type, filename=filename) - if file_id is not None: - return LMBinaryPart(file_id=file_id, media_type=media_type, filename=filename) - raise ValueError("Provider file output did not include data, url, or file_id.") - - -def refusal_to_part(value: Any) -> LMRefusalPart: - text = get_value(value, "refusal") or get_value(value, "text") or get_value(value, "content") or str(value) - return LMRefusalPart(text=str(text)) - - -def cost_from_response(response: Any) -> float | None: - hidden = getattr(response, "_hidden_params", None) or {} - return hidden.get("response_cost") if isinstance(hidden, dict) else None - - -def usage_from_response(response: Any) -> LMUsage | None: - """Convert provider usage objects or dictionaries into `LMUsage`.""" - usage = get_value(response, "usage") - if usage is None: - return None - if hasattr(usage, "model_dump"): - usage = model_dump(usage) - elif not isinstance(usage, dict): - data = {} - for key in dir(usage): - if key.startswith("_"): - continue - try: - value = getattr(usage, key) - except Exception: - continue - if value is not None and not callable(value): - data[key] = value - usage = data - return LMUsage(**dict(usage)) - - -# --------------------------------------------------------------------------- -# Small utilities -# --------------------------------------------------------------------------- - - -def media_source(part: LMImagePart | LMAudioPart | LMDocumentPart | LMBinaryPart) -> str: - if part.data is not None: - return data_uri(part.media_type, part.data) - if part.url is not None: - return part.url - if part.file_id is not None: - return part.file_id - if part.path is not None: - return data_uri_from_path(part.path, fallback_media_type=part.media_type) - raise ValueError(f"{type(part).__name__} has no media source.") - - -def read_path_base64(path: str) -> str: - with open(path, "rb") as file: - return base64.b64encode(file.read()).decode("ascii") - - -def media_type_for_path(path: str, *, fallback: str) -> str: - return mimetypes.guess_type(path)[0] or fallback - - -def data_uri_from_path(path: str, *, fallback_media_type: str) -> str: - return data_uri(media_type_for_path(path, fallback=fallback_media_type), read_path_base64(path)) - - -def data_uri(media_type: str, data: str) -> str: - if data.startswith("data:"): - return data - return f"data:{media_type};base64,{data}" - - -def split_data_uri(value: str) -> tuple[str, str]: - if not value.startswith("data:") or "," not in value: - return "application/octet-stream", value - header, data = value.split(",", 1) - media_type = header.removeprefix("data:").split(";", 1)[0] - return media_type, data - - -def media_format(media_type: str) -> str: - format_ = media_type.split("/", 1)[1] if "/" in media_type else media_type - return {"x-wav": "wav", "mpeg": "mp3"}.get(format_, format_) - - -def part_text(value: Any) -> str: - return value.text if isinstance(value, LMTextPart) else str(value) - - -def get_value(value: Any, key: str, default: Any = None) -> Any: - return value.get(key, default) if isinstance(value, dict) else getattr(value, key, default) - - -def _rebuild_pydantic_serializers(value: Any) -> None: - stack = [value] - seen = set() - for item in stack: - if id(item) in seen: - continue - seen.add(id(item)) - if isinstance(item, pydantic.BaseModel): - type(item).model_rebuild(force=True, raise_errors=False) - stack.extend([*item.__dict__.values(), *((item.__pydantic_extra__ or {}).values())]) - elif isinstance(item, (dict, list, tuple)): - stack.extend(item.values() if isinstance(item, dict) else item) - - -def model_dump(value: Any) -> dict[str, Any]: - if hasattr(value, "model_dump"): - try: - return value.model_dump(exclude_none=True) - except TypeError as error: - if "MockValSer" not in str(error): - raise - _rebuild_pydantic_serializers(value) - return value.model_dump(exclude_none=True) - if isinstance(value, dict): - return dict(value) - data = {} - for key in ("id", "call_id", "type", "name", "arguments", "status", "text", "refusal", "url", "data", "file_id", "filename", "media_type", "mime_type"): - item = getattr(value, key, None) - if item is not None: - data[key] = item - return data - - -# --------------------------------------------------------------------------- -# Current BaseLM adapter compatibility helpers -# -# These helpers keep today's adapter->BaseLM boundary normalized internally while -# BaseLM still returns legacy `list[str | dict | None]` outputs. Future native -# LanguageModel implementations should use completion_to_lm_response() / -# responses_to_lm_response() directly. -# --------------------------------------------------------------------------- - - -def lm_response_from_legacy_outputs(outputs: list[dict[str, Any] | str | None], request: LMRequest) -> LMResponse: - """Normalize current legacy `BaseLM` outputs into an `LMResponse`.""" - if not outputs: - return LMResponse(model=request.model, outputs=[LMOutput(parts=[], metadata={"empty_legacy_outputs": True})]) - return LMResponse(model=request.model, outputs=[lm_output_from_legacy_output(output) for output in outputs]) - - -def legacy_outputs_from_lm_response(response: LMResponse) -> list[dict[str, Any] | str | None]: - """Return legacy adapter postprocess values from a normalized response. - - This is a temporary compatibility helper for current adapter postprocessing. - Prefer the exact original legacy output when it is available so compatibility - hooks that distinguish `str` from provider dictionaries keep working. - """ - outputs = [] - for output in response.outputs: - if output.metadata.get("empty_legacy_outputs"): - continue - if output.provider_output is not None: - outputs.append(output.provider_output) - elif output.text is not None and not output.reasoning_content and not output.tool_calls and not output.citations and output.logprobs is None: - outputs.append(output.text) - else: - outputs.append(output.to_output_dict()) - return outputs - - -def lm_output_from_legacy_output(output: dict[str, Any] | str | None) -> LMOutput: - """Normalize one current legacy `BaseLM` output item into an `LMOutput`.""" - if isinstance(output, str): - return LMOutput(parts=[LMTextPart(text=output)], provider_output=output) - if output is None: - return LMOutput(parts=[]) - - parts = [] - text = output.get("text") - if text: - parts.append(LMTextPart(text=text)) - reasoning = output.get("reasoning_content") - if reasoning: - parts.append(LMThinkingPart(text=str(reasoning))) - for tool_call in output.get("tool_calls") or []: - parts.append(provider_tool_call_to_part(tool_call)) - for citation in output.get("citations") or []: - parts.append(citation_to_part(citation)) - return LMOutput(parts=parts, logprobs=output.get("logprobs"), provider_output=output) From 1d77e39982464a1ce09205e159379d6f8079c164 Mon Sep 17 00:00:00 2001 From: Gustavo Larrea Date: Mon, 12 Oct 2026 16:04:28 +0200 Subject: [PATCH 6/6] fix: positional MIME argument fixed --- dspy/adapters/types/file.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dspy/adapters/types/file.py b/dspy/adapters/types/file.py index f8ac23b553..3031b57b62 100644 --- a/dspy/adapters/types/file.py +++ b/dspy/adapters/types/file.py @@ -153,8 +153,8 @@ def from_path(cls, file_path: str, filename: str | None = None, mime_type: str | @classmethod def from_bytes( cls, file_bytes: bytes, filename: str | None = None, - resolution:str="medium", mime_type: str = "application/octet-stream", + resolution:str = "medium" ) -> "File": """Create a File from raw bytes.