Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 37 additions & 10 deletions dspy/adapters/types/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ class QA(dspy.Signature):
file_data: str | None = None
file_id: str | None = None
filename: str | None = None
mime_type: str | None = None
resolution: str | None = None

model_config = pydantic.ConfigDict(
frozen=True,
Expand Down Expand Up @@ -66,6 +68,8 @@ 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,
"resolution": values.resolution
}

if isinstance(values, dict):
Expand All @@ -84,6 +88,10 @@ 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
if self.resolution:
file_dict["resolution"] = self.resolution
Comment on lines +91 to +94

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 File-ID Metadata Blocks Requests

When from_file_id sets a MIME type or resolution, format() emits format or resolution in the file block. Native conversion rejects either field, so a native Gemini request using the new metadata fails before reaching the provider. This must be fixed before merging.

Knowledge Base Used: Language model integration

Artifacts

Executable Gemini file-ID reproduction script

  • The authored script runs both revisions through the same DSPy call and mocks only the provider boundary, making pre-provider rejection observable.

Gemini file-ID run before the change

  • The captured baseline run shows a plain file ID reaching the mock provider while the constructor rejects the two optional arguments.

Gemini file-ID run after the change

  • The captured HEAD run shows both optional fields rejected during native conversion with zero provider calls.

View artifacts

T-Rex Ran code and verified through T-Rex


return [{"type": "file", "file": file_dict}]
except Exception as e:
Expand All @@ -98,9 +106,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=<DATA_URI({mime_type}, {len_data} chars)>")
else:
len_data = len(self.file_data)
Expand All @@ -109,16 +115,21 @@ 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}'")
if self.resolution is not None:
parts.append(f"resolution='{self.resolution}'")
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:
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}")
Expand All @@ -137,11 +148,13 @@ 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(
cls, file_bytes: bytes, filename: str | None = None, mime_type: str = "application/octet-stream"
cls, file_bytes: bytes, filename: str | None = None,
mime_type: str = "application/octet-stream",
resolution:str = "medium"
) -> "File":
"""Create a File from raw bytes.

Expand All @@ -152,12 +165,22 @@ 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) -> "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,
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, resolution=resolution)


def encode_file_to_dict(file_input: Any) -> dict:
Expand All @@ -178,6 +201,10 @@ 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
Comment on lines +204 to +205

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 MIME File Copies Fail

File(existing_file) converts an existing mime_type into a format field, which the File model forbids. Copying a file with MIME metadata therefore raises a validation error instead of producing a usable copy. This is a non-blocking concern, but callers cannot reliably reuse those files.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Artifacts

Executed File constructor reproduction script

  • The authored script constructs and copies Files with and without a MIME type, asserting the observed constructor outcomes.

Constructor output without a MIME type

  • The executed control command copied an existing File without a MIME type successfully.

Constructor output with a MIME type

  • The executed command copied an existing File with a MIME type and captured a `format` extra-forbidden validation error.

View artifacts

T-Rex Ran code and verified through T-Rex

@tyrael147 tyrael147 Oct 12, 2026 •

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The File model has aalready been updated in this fix to inlcude mime_type and resolution fields.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing that out. In the revision under review, File declares mime_type and resolution, but it does not declare a format field. format is only the outbound key produced by format(); model_config still has extra="forbid".

Therefore, File(existing_file) still fails when either new field is set: encode_file_to_dict() emits {"format": ..., "resolution": ...}, which is passed back into Pydantic, where format is rejected as an extra field. The copy path should preserve the model field name (mime_type) and translate it to format only when serializing for the provider.

Tip: You can customize Greptile's behavior for this repo with .greptile/rules.md and .greptile/config.json.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right that File now declares both fields; the issue is the conversion key, not the model definition. encode_file_to_dict() is used by File(existing_file) and returns {"format": ...} here, but format is not a File model field (extra="forbid"), so copying a file with mime_type still raises a validation error.

format should only be emitted by the adapter-facing format() method. The constructor normalization should preserve the model field name:

Suggested change
if file_input.mime_type is not None:
result["format"] = file_input.mime_type
if file_input.mime_type is not None:
result["mime_type"] = file_input.mime_type

The resolution mapping can remain unchanged. This lets File(existing_file) retain both mime_type and resolution, while File.format() still translates mime_type to LiteLLM’s format key.

if file_input.resolution is not None:
result["resolution"] = file_input.resolution
return result

elif isinstance(file_input, dict):
Expand Down
43 changes: 33 additions & 10 deletions dspy/clients/base_lm.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,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:
Expand Down Expand Up @@ -113,11 +117,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)

@property
Expand Down Expand Up @@ -152,7 +160,9 @@ def _process_lm_response(self, response, prompt, messages, **kwargs):

def _record_response(self, response, prompt, messages, outputs, kwargs, request=None):
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
Expand Down Expand Up @@ -225,7 +235,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,
Expand All @@ -236,7 +250,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
Expand Down Expand Up @@ -280,9 +296,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)
Expand Down
Loading