8000
Skip to content

Introduce normalized LM boundary for adapters - #9802

Merged
MaximeRivest merged 1 commit into
stanfordnlp:mainfrom
MaximeRivest:adapters-normalized-boundary-pr1
May 23, 2026
Merged

Introduce normalized LM boundary for adapters#9802
MaximeRivest merged 1 commit into
stanfordnlp:mainfrom
MaximeRivest:adapters-normalized-boundary-pr1

Conversation

@MaximeRivest
Copy link
Copy Markdown
Collaborator

Summary

This is the first PR in a stack to move adapters toward normalized LM requests and responses.

Adapters still format prompts the same way, and current BaseLM calls still receive OpenAI/LiteLLM-shaped kwargs. The difference is that the adapter call path now passes through a normalized boundary internally:

adapter messages
-> LMRequest
-> OpenAI/LiteLLM kwargs
-> current BaseLM
-> LMResponse
-> existing adapter postprocess path

Why

Today adapters talk directly in provider-shaped messages and legacy output objects. That makes it hard to move tools, reasoning, citations, multimodal inputs, and future adapter types onto one LM type system.

This PR adds the seam without changing parser behavior yet. We normalize before and after the LM call, then convert back to the existing postprocess shape so this can stay behavior-preserving.

Changes

  • Add dspy.clients.openai_format for normalized LMRequest / LMResponse conversion to and from OpenAI/LiteLLM-shaped data.
  • Add dspy.adapters._legacy_type_markers for the old custom type marker expansion.
  • Update Adapter.__call__() and acall() to route through LMRequest and LMResponse.
  • Preserve unknown legacy content blocks during round-trip conversion.
  • Preserve text block metadata when coercing OpenAI-style content into LMTextPart.
  • Add TODOs in base.py marking the follow-up adapter plan and direct LMResponse parsing work.

Notes

dspy.clients.openai_format is used instead of an adapter-local bridge because provider-format conversion belongs in the client/LM layer. Later LM backends can reuse the same formatter.

The conversion back from LMResponse to legacy outputs is temporary. A follow-up PR should replace _call_preprocess() / _call_postprocess() with an explicit adapter plan and direct LMResponse parsing.

Tests

uv run ruff check dspy/adapters/base.py dspy/adapters/_legacy_type_markers.py dspy/clients/openai_format.py dspy/core/types.py
uv run pytest -q tests/adapters/test_chat_adapter.py tests/adapters/test_json_adapter.py tests/adapters/test_xml_adapter.py tests/adapters/test_audio.py tests/adapters/test_base_type.py tests/adapters/test_document.py tests/adapters/test_citation.py tests/adapters/test_reasoning.py tests/adapters/test_tool.py tests/adapters/test_two_step_adapter.py

Targeted adapter suite: 169 passed.

@MaximeRivest
MaximeRivest force-pushed the adapters-normalized-boundary-pr1 branch from 0833e8d to 5f5cfa1 Compare May 22, 2026 23:42
@greptile-apps
greptile-apps Bot commented May 22, 2026
Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces a normalized LMRequest / LMResponse boundary inside the adapter call path without changing external parser behavior: messages from Adapter.format() are coerced into typed LMMessage/LMPart objects, passed through LMRequest, serialized back to OpenAI/LiteLLM kwargs for the current BaseLM, and the legacy outputs are immediately re-wrapped into LMResponse before being handed to _call_postprocess.

  • Adds dspy/clients/openai_format.py (~923 lines) with bidirectional conversion between DSPy normalized types and OpenAI Chat / Responses / text-completion shapes.
  • Adds dspy/adapters/_legacy_type_markers.py to encapsulate the existing custom dspy.Type marker expansion logic that was previously in base_type.py.
  • Updates dspy/core/types.py to preserve block metadata during LMMessage construction and to carry extra top-level OpenAI tool fields into LMToolSpec.provider_data.

Confidence Score: 5/5

The change is behavior-preserving for all tested adapter paths: messages are normalized to LMRequest and immediately converted back to legacy kwargs, so the LM receives the same OpenAI-shaped dict as before. The 169-test adapter suite validates the most common flows.

The round-trip conversion is the dominant risk area, and it is well-guarded: provider_output on each LMOutput carries the original BaseLM output so legacy_outputs_from_lm_response can return the exact original value to postprocessing without loss. The two edge cases flagged are both off the common adapter paths tested by the suite and are straightforward to address.

dspy/clients/openai_format.py deserves a focused read, especially response_content_item_to_parts, message_to_openai_chat, and the legacy compatibility helpers at the bottom of the file.

Important Files Changed

Filename Overview
dspy/clients/openai_format.py New 923-line module for bidirectional conversion between DSPy normalized types and OpenAI/LiteLLM-shaped JSON. Logic is thorough but has a few edge cases: response_content_item_to_parts can produce LMTextPart(text=None) for text items lacking a text key, and parts_to_openai_content([]) returns [] rather than null for empty content, which some providers reject for assistant messages with tool calls.
dspy/adapters/base.py Core adapter call path now routes through LMRequest/LMResponse boundary. Changes are well-scoped and behavior-preserving; TODOs clearly mark the intended follow-up work. Round-trip through legacy outputs is correctly implemented.
dspy/adapters/_legacy_type_markers.py New backward-compatibility helper for custom type marker expansion. Clean factoring of logic previously in base_type.py. Three-pass parser (json, doubly-quoted json, json_repair) is robust.
dspy/core/types.py Small, targeted changes: preserves metadata on text content blocks during LMMessage construction, populates provider_data on LMToolSpec from extra top-level OpenAI tool fields, and adds OpenAI-shaped tool choice coercion in LMToolChoice.from_value.
tests/adapters/test_two_step_adapter.py Minimal fix: adds mock_main_lm.model and mock_extraction_lm.model to sync mock LMs with the new code path that reads lm.model.

Reviews (3): Last reviewed commit: "Introduce normalized LM boundary for ada..." | Re-trigger Greptile

Comment thread dspy/clients/openai_format.py Outdated
Comment thread dspy/clients/openai_format.py
Comment thread dspy/clients/openai_format.py Outdated
Comment thread dspy/clients/openai_format.py
@isaacbmiller isaacbmiller left a comment
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM. Some questions + one inline import.

Some edge cases that agent found which seem reasonable:
Image

return None


def _parse_doubly_quoted_json(value: str) -> Any:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

uh

Comment thread dspy/adapters/_legacy_type_markers.py Outdated
if block_type == "image_url":
image_url = block.get("image_url", {})
source = image_url.get("url") if isinstance(image_url, dict) else image_url
from dspy.core.types import LMImagePart
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

inline import?

context=block.get("context"),
)

return LMTextPart(text="", metadata={"legacy_content_block": block})
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Should this raise instead of being a generic fallback? Do we read this legacy_content_block anywhere?

Comment thread dspy/adapters/base.py Outdated
planned message/part insertions before creating `LMRequest`.
"""
return LMRequest.from_call(
model=getattr(lm, "model", ""),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: does LM ever not have model?

I have seen this particular LLMism about BaseLM and solely using getattr many times.

Comment thread dspy/adapters/base.py
def _call_lm(self, lm: BaseLM, request: LMRequest) -> LMResponse:
"""Call current `BaseLM` through the normalized request/response boundary.

TODO(language-models): When `BaseLM` is replaced by/updated to the
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: this method should probably disappear rather than becoming a direct call

@MaximeRivest
MaximeRivest force-pushed the adapters-normalized-boundary-pr1 branch from 984022f to a4011e9 Compare May 23, 2026 10:49
@MaximeRivest
MaximeRivest force-pushed the adapters-normalized-boundary-pr1 branch from a4011e9 to 6e90339 Compare May 23, 2026 10:53
@MaximeRivest
Copy link
Copy Markdown
Collaborator Author

Thanks, fixed the small nits:

  • added a comment explaining the doubly-encoded JSON legacy parser;
  • moved LMImagePart to the top-level import;
  • added a comment explaining the legacy_content_block fallback and where it is rehydrated;
  • changed getattr(lm, "model", "") to lm.model;
  • reworded the _call_lm() TODO to say this compatibility shim should disappear.

I also fixed the provider-shaped tool_choice compatibility case so OpenAI/LiteLLM-shaped values like:

{"type": "function", "function": {"name": "get_weather"}}

@MaximeRivest
MaximeRivest merged commit ee2c580 into stanfordnlp:main May 23, 2026
14 checks passed
@MaximeRivest
MaximeRivest deleted the adapters-normalized-boundary-pr1 branch May 23, 2026 11:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

0