feat: add OpenAI Responses API support as a selectable provider adapter#29
Merged
Conversation
Implements a second provider adapter for the OpenAI Responses API (/v1/responses) alongside the existing Chat Completions adapter, and wires provider selection through the whole stack so the agent loop, logging, and analysis stay provider-agnostic (PRD §8). - Add backend/app/services/responses_provider.py: maps the loop's Chat-Completions-style messages/tools to the Responses API input format (system -> instructions, assistant tool calls -> function_call items, tool messages -> function_call_output) and parses the Responses stream (output_item.added, function_call_arguments.delta, output_text.delta, reasoning_text.delta, response.completed usage) back into the shared normalized turn dict. - agent_loop: select adapter by ModelConfig.provider_kind (defaults to Chat Completions); references adapters by module-level name so the existing test suite's monkeypatches keep working. - schemas/router: expose and validate provider_kind on ModelConfig (allowed kinds: openai_compatible, responses_api). Freeze/export paths already carried provider_kind. - Frontend: add an 'API style' selector to Model Configs (badge in the list) with Chat Completions / Responses API options. - Docs: note the two adapters in README and architecture/workflow pages. - Tests: adapter mapping + streaming parse, provider_kind persistence/ validation, and an end-to-end loop run for both adapters (no network). All backend (103) and frontend (91) tests pass; ruff + eslint + tsc clean.
…Studio)
LM Studio's /v1/responses stream emits events where "response" is null
(particularly on response.created / response.completed). The previous
code used event.get('response', {}) which returns None (not {}) when the
key is present with a null value, then .get(...) raised
'NoneType' object has no attribute 'get'.
- Guard every .get('response') / .get('item') / .get('usage') /
.get('incomplete_details') access against None.
- A null response on response.completed is benign (LM Studio quirk after
a successful stream) — skip it and let end-of-stream inference choose
the finish reason instead of wrongly marking the turn as errored.
- A genuine streamed error (type 'error' / 'response.failed') now raises
ProviderError so the session is recorded as errored rather than swallowed.
- Map reasoning_effort into the Responses 'reasoning': {'effort': ...}
object LM Studio (and current OpenAI) expect, instead of a bare
top-level reasoning_effort field it ignores.
Adds regression tests for null-response streams and the error-event path.
The real /v1/responses request correctly mapped reasoning_effort ->
{"reasoning": {"effort": ...}}, but the logged raw_request (in the
model_request event) was still built with the old loop that emitted a
bare "reasoning_effort" key — so the audit trail didn't match what was
actually sent, breaking setup reproducibility (PRD §12).
Extract _build_responses_payload() and use it for both the live stream
and the logged raw_request so they can never diverge. Add tests
asserting the logged request carries reasoning: {effort} (or omits it
when unset) rather than reasoning_effort.
The Responses API rejected the request with
'Invalid type for input' because assistant tool calls were nested inside
a message item's content ({"type":"message","content":[{"type":"function_call",...}]}).
In the Responses API a message's content only accepts text/image parts;
assistant tool calls must be top-level function_call input items.
- _map_messages_to_input now emits assistant text as its own message item
and each tool call as a sibling top-level function_call item (matching
how function_call_output items are already handled).
- Rewrote the mapping test to assert the correct shape and added a
text+tool-calls case plus an end-to-end loop test that drives a
tool-calling Responses session and verifies the second-turn input has
no nested function_calls.
This is the root cause of the intermittent 'Invalid type for input' errors
seen with some models on the Responses API.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a second provider adapter for the OpenAI Responses API (
/v1/responses) alongside the existing Chat Completions adapter, and wires provider selection through the whole stack so the agent loop, logging, and analysis stay provider-agnostic (PRD §8).provider_kindalready existed onModelConfigbut was never persisted, exposed in the API, or used — the loop hard-called the Chat Completions adapter. This fills that gap.What changed
backend/app/services/responses_provider.py(new) — Maps the loop's Chat-Completions-stylemessages/toolsto the Responses API input format:system→instructionsassistanttool calls →function_callitemstoolmessages →function_call_outputitemsoutput_item.added,function_call_arguments.delta,output_text.delta,reasoning_text.delta,response.completedusage withreasoning_tokens) back into the shared normalized turn dict emitted by the Chat Completions adapter, so nothing downstream changes.agent_loop.py— selects the adapter byModelConfig.provider_kind(defaults to Chat Completions). References adapters by module-level name so the existing test suite'spatch("...assemble_response")still works.schemas.py/routers/model_configs.py— expose and validateprovider_kind(openai_compatible,responses_api). The freeze/export/import paths already carried it.ModelConfigs.tsx— adds an API style selector (Chat Completions / Responses API) with a badge in the list.provider_kindpersistence/validation; end-to-end loop run for both adapters (no network).Test plan
pytest— 103 passed (15 new)ruff check .— cleanvitest run— 91 passed (2 new)eslint .andtsc --noEmit— cleanNotes
tool_choicefield); tool outputs return asfunction_call_outputitems.provider_kindcolumn already exists; default isopenai_compatible, so existing configs keep working untouched.Closes nothing (feature work).