Skip to content

feat(openai): add /v1/responses support for gpt-5 (with adapter hardening) - #206

Merged
gadievron merged 8 commits into
masterfrom
feat/openai-gpt5-responses
Aug 2, 2026
Merged

feat(openai): add /v1/responses support for gpt-5 (with adapter hardening)#206
gadievron merged 8 commits into
masterfrom
feat/openai-gpt5-responses

Conversation

@gadievron

@gadievron gadievron commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

What

Add the OpenAI /v1/responses path for gpt-5 models to the LLM adapter, plus hardening fixes on that new path found in a follow-up bug hunt — several settled against the live gpt-5 API.

Why

gpt-5-generation models reject tools + reasoning on /v1/chat/completions, which the agentic-enhance and attacker-sim verify phases need — so the adapter routes ^gpt-[5-9] to /v1/responses (_complete_responses) while gpt-4o and the o-series stay on the unchanged chat path. The four follow-up fixes close correctness gaps on that path; the most consequential is a reliability issue specific to a SAST tool — a truncated or abnormal model response could read to the pipeline as a clean, finding-free verdict.

How

Feature (commit 79fb6e0):

  • _complete_responses + _responses_to_unified Message↔responses-item translation, store=False, a reasoning-aware output-token floor, and a status-before-content parser so a failed/cancelled/content-filtered response raises a typed error instead of an empty pass.
  • Union _is_reasoning_model with ^gpt-[5-9] so the endpoint choice and the token-param/role choice cannot disagree.
  • Dependency bump in pyproject.toml (the openai SDK version that ships the Responses API).

Hardening (this branch — all confined to utilities/llm/providers/openai.py plus its tests):

  • 664a9a9 — in _responses_to_unified, an abnormal status / unknown incomplete-reason carrying partial content is relabelled stop_reason="max_tokens" and warned once (_warn_unknown_responses_status), instead of a silent end_turn. Mirrors the chat path's unknown-finish_reason warning.
  • b6eab1fvalidate() pings with the same reasoning={"effort": …} the scan will send, so a model-rejected effort fails at init instead of 400-ing every unit.
  • 457d0b5_is_gpt5_responses_family (^gpt-[5-9]([.-]|$) and no -chat), used by both _use_responses_api and _is_reasoning_model, so the non-reasoning gpt-5-chat/gpt-5-chat-latest no longer route to responses+reasoning, gpt-50 is not the family, and ids are trimmed. Coupling invariant preserved.
  • 9d5f405 — corrects the _complete_responses docstring: reasoning items are emitted and deliberately dropped; the loop survives because function_calls are re-sent with call_id only, never the server fc_ item id. Adds a latent-trap note.
  • d6e178a — extends the non-reasoning exclusion from -chat to -chat+-search, so gpt-5-search-api (a web-search model served on Chat Completions, like gpt-4o-search-preview) is no longer swept onto responses+reasoning. Reasoning ids are guarded by a companion test.
  • 270fd9c — decouples the endpoint decision from the token-param/role decision (superseding part of 457d0b5): _is_gpt5_responses_family stays the endpoint class (chat for -chat/-search), and a new _is_gpt5_completion_token_family drives the token-param — the whole gpt-5 generation except -search. Live-verified against the real API: gpt-5.2-chat-latest rejects max_tokens and needs max_completion_tokens; gpt-5-search-api accepts max_tokens. Net: -chat → chat + max_completion_tokens, -search → chat + max_tokens.
  • 82ed244 — the chat-path analog of 664a9a9: _response_to_unified defaulted an unknown finish_reason to stop_reason="end_turn"; it now defaults to "max_tokens" so a proxy/future-value truncation on the chat path isn't laundered into a clean completion. Known reasons (stop/tool_calls/length/content_filter) are unchanged.

Tests

# adapter file
pytest -q tests/test_llm_openai_adapter.py | tail -1   ->  71 passed   (was 60; grep -c "def test_" = 45)
# wider llm + agent suite
pytest -q tests/ -k "llm or openai or provider or adapter or agent" | tail -1  ->  409 passed, 4 skipped, 0 failed
# full suite (whole module) green in CI on macos/ubuntu/windows (the only local reds are a zig-parser subprocess PYTHONPATH quirk, absent in CI; 0 parser files in this diff)

Each behavioral fix has a git-history red/green receipt (/rebugz). Additionally, the adapter was run end-to-end against the real gpt-5 API through openant's own config→registry path: gpt-5-nano (responses), gpt-5.2-chat-latest (chat + max_completion_tokens), and gpt-5-search-api (chat + max_tokens) all return valid completions; the abnormal-status and reasoning-drop behaviors were confirmed on the live API.

Compatibility

No breaking change; gpt-4o and the o-series paths are untouched (verified by the existing gate/coupling tests, still green).

Reachability — this path is dormant in shipped config. No gpt-5 id is registered (config/ + utilities/model_config.py carry only gpt-4o/4.1/o1/o3/o4), and build_adapter in utilities/llm/registry.py passes only api_key+base_url, so the new use_responses_api/reasoning_effort ctor args are not settable from config (effort is env-only, default "medium"). This PR hardens a path nothing in shipped config reaches yet — it activates only when an operator registers a gpt-5 id or points a base_url proxy at one. Registering a gpt-5 model is intentionally out of scope here. (The path itself is proven working: the adapter was run end-to-end against the real gpt-5 API — see Tests — so the dormancy is purely a config choice, not an untested path.)

Author notes

  • Q1 (which code implements it): feature in _complete_responses + _responses_to_unified; the fixes in _responses_to_unified (abnormal-status branch), validate() (reasoning ping), the routing predicates (_is_gpt5_responses_family for the endpoint + _is_gpt5_completion_token_family for the token-param), and the _complete_responses docstring.
  • Q2 (concrete input previously handled wrong): a Responses object status="incomplete", incomplete_details.reason=None, output [message: "…no issues fou"] returned stop_reason="end_turn" (a clean verdict) before 664a9a9; it now returns max_tokens + a stderr warning. Reproduced first-hand and as a regression test.
  • Q3 (likely reviewer pushback + answer): "these paths aren't reachable yet, why fix them?" — the fixes are cheap, single-file, and land before gpt-5 is ever enabled; the abnormal-status one is the silent-false-negative class a SAST tool must not ship. The one deeper item is out of scope (below).

Follow-ups

  • Verify-stage stop_reason gating (stacked PR). finding_verifier.py accepts a finish tool block regardless of stop_reason, so a max_tokens-truncated reply carrying a well-formed finish(agree=false, "safe") could be read as a completed verdict (a narrow silent-FN; malformed-mid-JSON truncation is already caught). BUG-7 makes the adapter report truncation honestly; the verifier must then gate finish-acceptance on stop_reason != "max_tokens" (+ a missing record_call on one cost-undercount path at finding_verifier.py:425-432). Different subsystem than this adapter PR → stacked follow-up.

Resolved during review (were open questions, now settled): the dropped-reasoning-item concern is live-confirmed benign (3/3 on gpt-5-nano — the stateless replay re-sends function_call by call_id, which the API accepts; docstring corrected in 9d5f405), and the non-reasoning-variant token-param is live-verified and fixed (270fd9c). (The enhancer degenerate-exit handling was checked and already classifies these as INCOMPLETE — no change needed there.)

gadievron and others added 7 commits August 1, 2026 13:57
gpt-5-generation models reject `tools` + `reasoning` on
/v1/chat/completions, which the agentic enhance and attacker-sim verify
phases require. Route gpt-5+ (`^gpt-[5-9]`) to /v1/responses, which
supports both; gpt-4o and the o-series stay on the unchanged chat path.

- `_complete_responses` + Message<->responses-item translation
  (function_call / function_call_output, flattened tool schema),
  `store=False`, and a reasoning-aware output-token floor.
- Response parser inspects status before content so a failed / cancelled
  / content-filtered / empty response raises a typed error instead of
  reading as a clean, finding-free pass (a silent false-negative for a
  SAST tool). Refusal is read from the nested message content part.
- Endpoint choice is overridable per-adapter (`use_responses_api`) for
  base_url proxies that expose a gpt-5 id over a chat-only endpoint.

Also fix a coupling bug: `_is_reasoning_model` matched only `^o[1-9]`,
so a gpt-5 forced onto the chat path via the override sent `max_tokens`
+ the `system` role and 400d. Union it with `^gpt-[5-9]` so the endpoint
decision and the token-param/role decisions cannot disagree.

Tests: 60 adapter tests (gate both ways, tool round-trip, parallel
calls, status guards, the forced-chat coupling case); full llm suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ean end_turn

_responses_to_unified used blocklist status handling: stop_reason defaulted to
"end_turn" and only failed/cancelled (status) and content_filter/max_output_tokens
(incomplete reason) were acted on. An `incomplete` with an unknown/None reason, or an
unrecognized top-level status (in_progress/queued/requires_action/a future value),
that still carried PARTIAL text was returned as a clean stop_reason="end_turn" — a
silent false-negative for a SAST tool. The empty-content guard only fires on EMPTY
content, so the old comment claiming other reasons "fall through to the empty-content
guard rather than a silent clean pass" was false when partial content was present.

Relabel such abnormal-but-non-empty responses to stop_reason="max_tokens" (the honest
"not a clean finish" signal; partial content preserved) and warn once via a new
_warn_unknown_responses_status helper, mirroring the chat path's unknown-finish_reason
warning (_warned_finish_reasons). Existing guards unchanged and regression-tested:
content_filter->LLMRefusalError, failed/cancelled->LLMResponseError,
max_output_tokens->max_tokens, empty content->LLMResponseError, refusal part->refusal.

Scoped entirely to utilities/llm/providers/openai.py (+ its tests) — no change to
core/, parsers/, context/, or any reachability/edges/seeds code.

Tests: added 4 (3 abnormal-case RED + 1 regression-guard).
  grep -c "def test_" tests/test_llm_openai_adapter.py -> 38  (was 34)
  pytest -q tests/test_llm_openai_adapter.py | tail -1 -> 64 passed  (was 60)
  wider llm/agent suite -> 402 passed, 4 skipped, 0 failed
Investigation: /bug-research trace openai-pr-bug2-20260801-162848

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ail fast)

validate() on the /v1/responses path deliberately omitted the reasoning param,
but _complete_responses always sends reasoning={"effort": self._reasoning_effort}.
A reasoning_effort the target model rejects (effort values are model-specific —
e.g. gpt-5.6 rejects "minimal", set via OPENANT_OPENAI_REASONING_EFFORT) therefore
passed init and then 400'd every unit — the exact "pass init, fail every unit"
failure endpoint-aware validate() was written to prevent, reopened on the effort
dimension.

validate()'s responses ping now sends the same reasoning={"effort": ...} the scan
will use, so a rejected effort fails fast at construction. Chat path unchanged.

Scoped to utilities/llm/providers/openai.py (+ tests); no core/parsers/context change.

Tests: +1 (fail-fast on model-rejected effort); updated the endpoint test to assert
the effort is now on the ping.
  grep -c "def test_" tests/test_llm_openai_adapter.py -> 39
  pytest -q tests/test_llm_openai_adapter.py | tail -1 -> 65 passed, 1 warning in 0.40s
  wider llm/agent suite -> 403 passed, 4 skipped, 0 failed

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…trim, tail-anchor)

_RESPONSES_MODEL_RE = ^gpt-[5-9] over-matched two ways:
- C1: the non-reasoning chat variants gpt-5-chat / gpt-5-chat-latest were routed
  to /v1/responses, which always sends reasoning={effort} — a param those models
  reject (400 on every call), with no escape hatch (the chat override still marked
  them reasoning). MED.
- C3: unrelated numbering (gpt-50) matched the family, and ids were not
  whitespace-trimmed (' gpt-5' fell to chat, 'gpt-5 ' to responses). LOW.

New _is_gpt5_responses_family() = ^gpt-[5-9]([.-]|$) AND no '-chat', used by BOTH
_use_responses_api and _is_reasoning_model so the endpoint and token-param/role
decisions stay coupled (a -chat id is now chat-path + max_tokens + system; gpt-50
is not the family; ids are stripped). o-series and the forced-chat gpt-5 coupling
case are unchanged (verified by the existing gate/coupling tests, still green).

Scoped to utilities/llm/providers/openai.py (+ tests); no core/parsers/context change.

Tests: +2 (chat-variant exclusion; family boundary + whitespace).
  grep -c "def test_" tests/test_llm_openai_adapter.py -> 41
  pytest -q tests/test_llm_openai_adapter.py | tail -1 -> 67 passed, 1 warning in 0.38s
  wider llm/agent suite -> 405 passed, 4 skipped, 0 failed

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…g (BUG-4)

The docstring claimed "empirically the loop's tool-call turns emit no reasoning
items to carry" — false: gpt-5 emits reasoning items at effort>=medium and they
are silently dropped at the `elif itype == "reasoning": pass` branch. The loop
survives not because there's nothing to carry, but because _messages_to_responses
re-sends function_call items with call_id only (never the server fc_ item id), so
the history reads as developer-synthesized tool history the Responses API accepts
(the model re-reasons, no reasoning-pairing 400).

Documents the real reason and adds a LATENT-TRAP warning: echoing the fc_/id on
re-sent function_calls, or re-sending rs_ reasoning items under store=False
without include=["reasoning.encrypted_content"], would make every follow-up turn
400. Verdict basis: contract-reasoning (fable expert) + first-hand code re-derive;
the id-keyed-enforcement claim is pending a live gpt-5 tool-loop test (account
credits exhausted). No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…soning

The /v1/responses feature (79fb6e0) routes ^gpt-[5-9] to responses+reasoning, which
swept in gpt-5-search-api (and dated variants) — a non-reasoning web-search model
served on Chat Completions (like gpt-4o-search-preview), which rejects the reasoning
param the responses path always sends. Extend _is_gpt5_responses_family's
non-reasoning exclusion from just "-chat" to "-chat"+"-search", so search models take
the unchanged chat path (their master-2ed78f6 routing), faithful to the feature's
intent of routing only *reasoning* models to responses. Reasoning ids (gpt-5,
-mini, -nano, -codex, -pro, .N) are unaffected (guarded by a new companion test).

The token-param OpenAI expects for these non-reasoning gpt-5 variants on Chat
Completions (max_tokens vs max_completion_tokens) is a separate, pre-existing
question (master routes them the same) left to the chat path; a wrong choice fails
loud at validate() init, not silently. Surfaced by an independent expert review.

Scoped to utilities/llm/providers/openai.py (+ tests); no core/parsers/context change.

Tests: +1 (reasoning-family stays on responses); extended the non-reasoning test to search ids.
  grep -c "def test_" tests/test_llm_openai_adapter.py -> 42
  pytest -q tests/test_llm_openai_adapter.py | tail -1 -> 68 passed, 1 warning in 0.43s
  wider llm/agent suite -> 406 passed, 4 skipped, 0 failed

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ompletions

BUG-3/BUG-5 routed the non-reasoning gpt-5 variants (-chat, -search) off /v1/responses
to Chat Completions via one shared predicate, which also drove the token-param — giving
them max_tokens. LIVE-VERIFIED against the real API this is wrong for the -chat variants:
gpt-5.2-chat-latest rejects max_tokens (400 "Unsupported parameter") and requires
max_completion_tokens (200); gpt-5-search-api, by contrast, accepts max_tokens (200) and
is rejected by the Responses API (400) — like gpt-4o-search-preview.

Decouple the two decisions: _is_gpt5_responses_family stays the ENDPOINT class (exclude
-chat AND -search), and a new _is_gpt5_completion_token_family drives the token-param/role
class (the whole gpt-5 generation EXCEPT -search). Net: -chat -> chat + max_completion_tokens
+ developer (both roles accepted, live-verified); -search -> chat + max_tokens + system;
reasoning ids -> responses + max_completion_tokens (unchanged). Coupling invariant preserved.

Surfaced by an independent expert review, then settled by live gpt-5 probes.

Scoped to utilities/llm/providers/openai.py (+ tests); no core/parsers/context change.

Tests: +1 (split chat vs search token-param expectations; both now assert _token_param).
  grep -c "def test_" tests/test_llm_openai_adapter.py -> 43
  pytest -q tests/test_llm_openai_adapter.py | tail -1 -> 69 passed, 1 warning in 0.38s
  wider llm/agent suite -> 407 passed, 4 skipped, 0 failed

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…an end_turn

The chat-path analog of BUG-2 (664a9a9). _response_to_unified mapped an UNKNOWN
finish_reason to stop_reason="end_turn" via _OPENAI_FINISH_REASONS.get(raw_finish,
"end_turn") (:820) — so a proxy value (vLLM/LiteLLM) or a future OpenAI termination
reason carrying partial content read to the pipeline as a clean, finding-free
completion. On stock api.openai.com the finish_reason set is stop/tool_calls/length/
content_filter (all handled explicitly), so this only bites via a base_url proxy or a
future value, but by this adapter's own standard (BUG-2 fixed the identical case on
the Responses path) it is an inconsistent silent-false-negative residue.

Default the unknown-finish lookup to "max_tokens" instead of "end_turn" (the warn at
:800-813 already fires), mirroring _responses_to_unified. Known reasons are unchanged
(stop->end_turn, length->max_tokens, tool_calls->tool_use, content_filter->raise) —
the default only affects values absent from _OPENAI_FINISH_REASONS.

Surfaced by an independent auditor + expert + judge review of the silent-FN family.
NOTE (follow-up): the verify-stage consumer (finding_verifier.py) still harvests a
finish tool block regardless of stop_reason; that gating fix ships in a stacked PR.

Scoped to utilities/llm/providers/openai.py (+ tests); no core/parsers/context change.

Tests: +2 (unknown finish -> max_tokens; known reasons unchanged).
  grep -c "def test_" tests/test_llm_openai_adapter.py -> 45
  pytest -q tests/test_llm_openai_adapter.py | tail -1 -> 71 passed, 1 warning in 0.40s
  wider llm/agent suite -> 409 passed, 4 skipped, 0 failed

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@gadievron
gadievron merged commit f9f200f into master Aug 2, 2026
9 checks passed
gadievron pushed a commit that referenced this pull request Aug 2, 2026
…t a verdict

Stacked follow-up to the openai-adapter PR (#206). The adapter now honestly reports
an abnormal/truncated termination as stop_reason="max_tokens" (BUG-2 responses path,
BUG-7 chat path). But finding_verifier's block loop harvested a finish ToolUseBlock
regardless of stop_reason (:438-476), so a max_tokens-truncated reply carrying a
well-formed finish(agree=False, correct_finding="safe") was parsed as a COMPLETE
verdict (incomplete=False) and silently downgraded a Stage-1 vulnerable to safe — the
verify-stage tail of the same silent-false-negative family, and one that BUG-7 slightly
widens (an unknown finish_reason now reaches this path instead of the end_turn fail-safe).

C(b): gate finish-acceptance on stop_reason != "max_tokens"; a truncated finish is
treated as verification-incomplete (agree=False, Stage-1 verdict preserved for triage),
mirroring the enhancer's degenerate-exit handling. A malformed-mid-JSON finish was
already caught (input_dict={} -> agree missing -> incomplete); this closes the narrower
fully-formed-finish-under-truncation window (proxy / trailing-tokens / length).

C(a): the end_turn+unparseable-text fail-safe (:425) returned without record_call,
unlike its three sibling degenerate exits (finish/no-tool-calls/max-iterations) — a
per-unit cost undercount. Added the missing record_call.

Scoped to utilities/finding_verifier.py (+ tests); no core/parsers/reachability change
(82/82 verifier-subsystem tests green; full module suite green in CI).

Surfaced by an independent auditor + expert + judge review of the silent-FN family.

Tests: +2 (truncated finish -> incomplete; normal finish still accepted).
  grep -c "def test_" tests/test_verifier_max_tokens_finish_incomplete.py -> 2
  verifier-subsystem suite -> 82 passed, 2470 deselected, 1 warning in 0.67s

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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