feat(openai): add /v1/responses support for gpt-5 (with adapter hardening) - #206
Merged
Conversation
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>
gadievron
requested review from
dgeyshis,
shahar-davidson and
sounil
as code owners
August 1, 2026 19:19
…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
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>
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.
What
Add the OpenAI
/v1/responsespath 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+reasoningon/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_unifiedMessage↔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._is_reasoning_modelwith^gpt-[5-9]so the endpoint choice and the token-param/role choice cannot disagree.pyproject.toml(theopenaiSDK version that ships the Responses API).Hardening (this branch — all confined to
utilities/llm/providers/openai.pyplus its tests):664a9a9— in_responses_to_unified, an abnormal status / unknown incomplete-reason carrying partial content is relabelledstop_reason="max_tokens"and warned once (_warn_unknown_responses_status), instead of a silentend_turn. Mirrors the chat path's unknown-finish_reasonwarning.b6eab1f—validate()pings with the samereasoning={"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_apiand_is_reasoning_model, so the non-reasoninggpt-5-chat/gpt-5-chat-latestno longer route to responses+reasoning,gpt-50is not the family, and ids are trimmed. Coupling invariant preserved.9d5f405— corrects the_complete_responsesdocstring: reasoning items are emitted and deliberately dropped; the loop survives because function_calls are re-sent withcall_idonly, never the serverfc_item id. Adds a latent-trap note.d6e178a— extends the non-reasoning exclusion from-chatto-chat+-search, sogpt-5-search-api(a web-search model served on Chat Completions, likegpt-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 of457d0b5):_is_gpt5_responses_familystays the endpoint class (chat for-chat/-search), and a new_is_gpt5_completion_token_familydrives the token-param — the whole gpt-5 generation except-search. Live-verified against the real API:gpt-5.2-chat-latestrejectsmax_tokensand needsmax_completion_tokens;gpt-5-search-apiacceptsmax_tokens. Net:-chat→ chat +max_completion_tokens,-search→ chat +max_tokens.82ed244— the chat-path analog of664a9a9:_response_to_unifieddefaulted an unknownfinish_reasontostop_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
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), andgpt-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-5id is registered (config/+utilities/model_config.pycarry only gpt-4o/4.1/o1/o3/o4), andbuild_adapterinutilities/llm/registry.pypasses onlyapi_key+base_url, so the newuse_responses_api/reasoning_effortctor 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 abase_urlproxy 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
_complete_responses+_responses_to_unified; the fixes in_responses_to_unified(abnormal-status branch),validate()(reasoning ping), the routing predicates (_is_gpt5_responses_familyfor the endpoint +_is_gpt5_completion_token_familyfor the token-param), and the_complete_responsesdocstring.status="incomplete",incomplete_details.reason=None, output[message: "…no issues fou"]returnedstop_reason="end_turn"(a clean verdict) before664a9a9; it now returnsmax_tokens+ a stderr warning. Reproduced first-hand and as a regression test.Follow-ups
stop_reasongating (stacked PR).finding_verifier.pyaccepts afinishtool block regardless ofstop_reason, so amax_tokens-truncated reply carrying a well-formedfinish(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 onstop_reason != "max_tokens"(+ a missingrecord_callon one cost-undercount path atfinding_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_callbycall_id, which the API accepts; docstring corrected in9d5f405), and the non-reasoning-variant token-param is live-verified and fixed (270fd9c). (The enhancer degenerate-exit handling was checked and already classifies these asINCOMPLETE— no change needed there.)