Skip to content

feat: add OpenAI Responses API support as a selectable provider adapter#29

Merged
vivganes merged 4 commits into
mainfrom
feat/responses-api-adapter
Jul 17, 2026
Merged

feat: add OpenAI Responses API support as a selectable provider adapter#29
vivganes merged 4 commits into
mainfrom
feat/responses-api-adapter

Conversation

@vivganes

Copy link
Copy Markdown
Collaborator

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_kind already existed on ModelConfig but 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-style messages/tools to the Responses API input format:
    • systeminstructions
    • assistant tool calls → function_call items
    • tool messages → function_call_output items
    • Parses the Responses stream (output_item.added, function_call_arguments.delta, output_text.delta, reasoning_text.delta, response.completed usage with reasoning_tokens) back into the shared normalized turn dict emitted by the Chat Completions adapter, so nothing downstream changes.
  • agent_loop.py — selects the adapter by ModelConfig.provider_kind (defaults to Chat Completions). References adapters by module-level name so the existing test suite's patch("...assemble_response") still works.
  • schemas.py / routers/model_configs.py — expose and validate provider_kind (openai_compatible, responses_api). The freeze/export/import paths already carried it.
  • Frontend ModelConfigs.tsx — adds an API style selector (Chat Completions / Responses API) with a badge in the list.
  • Docs — README + architecture/workflow pages note the two adapters.
  • Tests — adapter request mapping + streaming parse; provider_kind persistence/validation; end-to-end loop run for both adapters (no network).

Test plan

  • Backend: pytest — 103 passed (15 new)
  • Backend: ruff check . — clean
  • Frontend: vitest run — 91 passed (2 new)
  • Frontend: eslint . and tsc --noEmit — clean

Notes

  • Native tool-calling is used (no tool_choice field); tool outputs return as function_call_output items.
  • No schema migration needed — provider_kind column already exists; default is openai_compatible, so existing configs keep working untouched.

Closes nothing (feature work).

vivganes added 4 commits July 16, 2026 23:13
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.
@vivganes
vivganes merged commit d242240 into main Jul 17, 2026
2 checks passed
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.

1 participant