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
192 changes: 189 additions & 3 deletions aider/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,146 @@
request_timeout = 600

DEFAULT_MODEL_NAME = "gpt-4o"


class ResponsesAPIWrapper:
"""Wrapper to convert Responses API format to Chat Completions format"""

def __init__(self, responses_obj):
self._responses = responses_obj
self._convert_to_chat_format()

def _convert_to_chat_format(self):
"""Convert responses API output to chat completions choices format"""

# Create a mock choices structure
class MockChoice:
def __init__(self):
self.message = type(
"obj",
(object,),
{
"content": "",
"tool_calls": None,
"reasoning_content": None,
"reasoning": None,
},
)()
self.finish_reason = None

choice = MockChoice()

# Extract content from output items if available
if hasattr(self._responses, "output") and self._responses.output:
for item in self._responses.output:
# Handle ResponseOutputMessage type
if hasattr(item, "type") and item.type == "message":
if hasattr(item, "content") and item.content:
# Concatenate all text items to avoid losing content
text_parts = []
for content_item in item.content:
if hasattr(content_item, "text") and content_item.text:
text_parts.append(content_item.text)
if text_parts:
choice.message.content = "\n".join(text_parts)
break
# Fallback: direct text attribute
elif hasattr(item, "text") and item.text:
choice.message.content = item.text
break
# Fallback: dict format
elif isinstance(item, dict):
if "text" in item:
choice.message.content = item["text"]
break

# Always initialize self.choices for consistent interface
self.choices = [choice]
Comment on lines 58 to 85
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

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

The wrapper creates mock objects with content initialized to None, but doesn't validate that content was successfully extracted before creating the choices list. If no valid content is found in any output item (all branches fail or items are empty), choice.message.content and choice.delta.content will remain None. While this might be acceptable for some edge cases, it could lead to unexpected None values propagating through the codebase. Consider initializing content to an empty string instead of None for better consistency with the chat completions API behavior.

Copilot uses AI. Check for mistakes.

# Copy other attributes
if hasattr(self._responses, "id"):
self.id = self._responses.id
if hasattr(self._responses, "usage"):
self.usage = self._responses.usage

def __getattr__(self, name):
"""Fallback to original responses object for other attributes"""
return getattr(self._responses, name)


class StreamingResponsesAPIWrapper:
"""Wrapper for streaming responses API to mimic chat completions stream"""

def __init__(self, responses_stream):
self._stream = responses_stream

def __iter__(self):
return self

def __next__(self):
# This will properly propagate StopIteration when the stream ends
event = next(self._stream)

# Wrap each event to look like chat completions format
class MockChoice:
def __init__(self):
self.delta = type(
"obj",
(object,),
{
"content": None,
"function_call": None,
"reasoning_content": None,
"reasoning": None,
},
)()
self.finish_reason = None

class MockChunk:
def __init__(self):
self.choices = [MockChoice()]

mock_chunk = MockChunk()

# Extract finish_reason if available
finish_reason = None
if hasattr(event, "finish_reason"):
finish_reason = event.finish_reason
elif hasattr(event, "output"):
for item in event.output:
if hasattr(item, "finish_reason"):
finish_reason = item.finish_reason
break
if isinstance(item, dict) and "finish_reason" in item:
finish_reason = item["finish_reason"]
break

mock_chunk.choices[0].finish_reason = finish_reason

# Handle Responses API event stream format
# Check for OUTPUT_TEXT_DELTA events (have delta attribute with text)
if hasattr(event, "delta") and event.delta:
mock_chunk.choices[0].delta.content = event.delta
# Fallback for other formats
elif hasattr(event, "output"):
for item in event.output:
# Handle ResponseOutputMessage type
if hasattr(item, "type") and item.type == "message":
if hasattr(item, "content") and item.content:
for content_item in item.content:
if hasattr(content_item, "text") and content_item.text:
mock_chunk.choices[0].delta.content = content_item.text
break
if mock_chunk.choices[0].delta.content:
break
# Fallback: direct text attribute
elif hasattr(item, "text"):
mock_chunk.choices[0].delta.content = item.text
break

Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

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

The StreamingResponsesAPIWrapper initializes finish_reason to None but never updates it based on the chunk data. The code in base_coder.py checks for finish_reason == "length" to detect when the response is truncated (lines 1907-1911 in base_coder.py). Without populating this field from the responses API chunks, this check will never work properly for responses API streaming.

Consider extracting and setting the finish_reason from the chunk if it's available.

Suggested change
# Propagate finish_reason from the underlying chunk if available
finish_reason = None
# Check for finish_reason on delta first (most similar to chat completions)
if hasattr(chunk, "delta") and hasattr(chunk.delta, "finish_reason"):
finish_reason = chunk.delta.finish_reason
# Fallback: finish_reason directly on the chunk
elif hasattr(chunk, "finish_reason"):
finish_reason = chunk.finish_reason
# Fallback: look for finish_reason on output items
elif hasattr(chunk, "output"):
for item in chunk.output:
if hasattr(item, "finish_reason"):
finish_reason = item.finish_reason
break
if isinstance(item, dict) and "finish_reason" in item:
finish_reason = item["finish_reason"]
break
mock_chunk.choices[0].finish_reason = finish_reason

Copilot uses AI. Check for mistakes.
return mock_chunk


ANTHROPIC_BETA_HEADER = "prompt-caching-2024-07-31,pdfs-2024-09-25"

OPENAI_MODELS = """
Expand Down Expand Up @@ -131,6 +271,7 @@ class ModelSettings:
remove_reasoning: Optional[str] = None # Deprecated alias for reasoning_tag
system_prompt_prefix: Optional[str] = None
accepts_settings: Optional[list] = None
wire_api: str = "chat" # "chat" for Chat Completions API, "responses" for Responses API
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

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

The new AIDER_WIRE_API environment variable and wire_api model setting are not documented. Users need to know: (1) the purpose of this setting, (2) valid values ("chat" or "responses"), (3) when to use each option, and (4) that the environment variable overrides the model setting. Consider adding documentation in the appropriate user-facing documentation files or adding docstring documentation to the ModelSettings class explaining the wire_api field.

Copilot uses AI. Check for mistakes.


# Load model settings from package resource
Expand Down Expand Up @@ -954,6 +1095,9 @@ def send_completion(self, messages, functions, stream, temperature=None):
if self.is_deepseek_r1():
messages = ensure_alternating_roles(messages)

# Check wire_api configuration (environment variable overrides model setting)
wire_api = os.environ.get("AIDER_WIRE_API", getattr(self, "wire_api", "chat"))

kwargs = dict(
model=self.name,
stream=stream,
Expand Down Expand Up @@ -986,7 +1130,6 @@ def send_completion(self, messages, functions, stream, temperature=None):
kwargs["timeout"] = request_timeout
if self.verbose:
dump(kwargs)
kwargs["messages"] = messages

# Are we using github copilot?
if "GITHUB_COPILOT_TOKEN" in os.environ:
Expand All @@ -998,7 +1141,45 @@ def send_completion(self, messages, functions, stream, temperature=None):

self.github_copilot_token_to_open_ai_key(kwargs["extra_headers"])

res = litellm.completion(**kwargs)
# Use responses API or chat completions API based on wire_api setting
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

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

The code doesn't validate the wire_api value. If AIDER_WIRE_API or the model's wire_api setting contains an invalid value (e.g., "foo"), the code will silently default to using the chat completions API because the if condition on line 1141 only checks for "responses". This could lead to confusion if users mistype the value. Consider adding validation to raise a clear error for invalid wire_api values, or at least log a warning when an unrecognized value is encountered.

Suggested change
# Use responses API or chat completions API based on wire_api setting
# Use responses API or chat completions API based on wire_api setting
# Validate wire_api to catch misconfigurations (e.g., typos)
valid_wire_api_values = {None, "", "responses"}
if wire_api not in valid_wire_api_values:
sys.stderr.write(
f"Warning: Unrecognized wire_api value '{wire_api}'. "
"Falling back to chat completions API.\n"
)

Copilot uses AI. Check for mistakes.
# Validate wire_api value
if wire_api and wire_api not in ("chat", "responses"):
self.io.tool_warning(
f"Warning: Unrecognized wire_api value '{wire_api}'. "
"Valid values are 'chat' or 'responses'. Falling back to chat completions API."
)
wire_api = "chat"

if wire_api == "responses":
# Convert messages format for responses API
# Extract system messages as instructions, rest as input
system_messages = [msg for msg in messages if msg.get("role") == "system"]
other_messages = [msg for msg in messages if msg.get("role") != "system"]

# Combine all system messages into a single instructions string
if system_messages:
combined_instructions = "\n\n".join(
str(msg.get("content", "")) for msg in system_messages if msg.get("content")
)
if combined_instructions:
kwargs["instructions"] = combined_instructions

# For responses API, input should only contain non-system messages
# Avoid sending system messages twice
kwargs["input"] = other_messages

res = litellm.responses(**kwargs)

# Wrap the response to match chat completions format
if stream:
res = StreamingResponsesAPIWrapper(res)
else:
res = ResponsesAPIWrapper(res)
else:
# Default: use chat completions API
kwargs["messages"] = messages
res = litellm.completion(**kwargs)

return hash_object, res

def simple_send_with_retries(self, messages):
Expand All @@ -1021,9 +1202,14 @@ def simple_send_with_retries(self, messages):
}

_hash, response = self.send_completion(**kwargs)
if not response or not hasattr(response, "choices") or not response.choices:
if not response:
return None

# Use unified choices interface (provided by ResponsesAPIWrapper)
if not hasattr(response, "choices") or not response.choices:
return None
res = response.choices[0].message.content

from aider.reasoning_tags import remove_reasoning_content

return remove_reasoning_content(res, self.reasoning_tag)
Expand Down
21 changes: 20 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ aiosignal==1.4.0
# via
# -c requirements/common-constraints.txt
# aiohttp
annotated-doc==0.0.4
# via
# -c requirements/common-constraints.txt
# fastapi
annotated-types==0.7.0
# via
# -c requirements/common-constraints.txt
Expand All @@ -21,6 +25,7 @@ anyio==4.12.0
# -c requirements/common-constraints.txt
# httpx
# openai
# starlette
# watchfiles
asgiref==3.11.0
# via
Expand Down Expand Up @@ -77,6 +82,10 @@ distro==1.9.0
# -c requirements/common-constraints.txt
# openai
# posthog
fastapi==0.128.0
# via
# -c requirements/common-constraints.txt
# -r requirements/requirements.in
fastuuid==0.14.0
# via
# -c requirements/common-constraints.txt
Expand Down Expand Up @@ -110,7 +119,7 @@ grep-ast==0.9.0
# via
# -c requirements/common-constraints.txt
# -r requirements/requirements.in
grpcio==1.76.0
grpcio==1.67.0
# via
# -c requirements/common-constraints.txt
# litellm
Expand Down Expand Up @@ -219,6 +228,10 @@ openai==2.13.0
# via
# -c requirements/common-constraints.txt
# litellm
orjson==3.11.5
# via
# -c requirements/common-constraints.txt
# -r requirements/requirements.in
oslex==0.1.3
# via
# -c requirements/common-constraints.txt
Expand Down Expand Up @@ -273,6 +286,7 @@ pycparser==2.23
pydantic==2.12.5
# via
# -c requirements/common-constraints.txt
# fastapi
# litellm
# mixpanel
# openai
Expand Down Expand Up @@ -375,6 +389,10 @@ soupsieve==2.8.1
# via
# -c requirements/common-constraints.txt
# beautifulsoup4
starlette==0.50.0
# via
# -c requirements/common-constraints.txt
# fastapi
tiktoken==0.12.0
# via
# -c requirements/common-constraints.txt
Expand Down Expand Up @@ -411,6 +429,7 @@ typing-extensions==4.15.0
# via
# -c requirements/common-constraints.txt
# beautifulsoup4
# fastapi
# grpcio
# huggingface-hub
# openai
Expand Down
Loading