Skip to content

Add OpenAI Responses API support and unified refusal handling#627

Merged
sroussey merged 13 commits into
mainfrom
claude/openai-models-luna-terra-sol-jqz9hj
Jul 10, 2026
Merged

Add OpenAI Responses API support and unified refusal handling#627
sroussey merged 13 commits into
mainfrom
claude/openai-models-luna-terra-sol-jqz9hj

Conversation

@sroussey

Copy link
Copy Markdown
Collaborator

Summary

This PR adds comprehensive support for OpenAI's Responses API (the successor to Chat Completions) and implements unified refusal handling across all providers. The Responses API offers typed streaming events, better tool-call streaming, and reasoning support for the GPT-5.6 family.

Key Changes

OpenAI Responses API Support

  • New module OpenAIShapedResponses.ts with helpers for the Responses API:

    • buildResponsesInput() — converts unified message format to Responses input + instructions
    • buildResponsesTools() — flattens tool definitions (no nested function wrapper)
    • mapResponsesToolChoice() — maps tool choice to Responses format
    • parseResponsesToolCalls() — parses non-streaming tool calls from output[]
    • accumulateOpenAIResponsesStream() — adapts typed Responses events to workglow StreamEvents (text-delta, object-delta, refusal)
  • Updated OpenAI run-fns to use Responses API:

    • OpenAI_TextGeneration_Stream — now uses client.responses.create()
    • OpenAI_ToolCalling_Stream — streams tool calls via Responses
    • OpenAI_TextRewriter_Stream — uses Responses with instructions channel
    • OpenAI_TextSummary_Stream — migrated to Responses
  • New OpenAI client helpers:

    • getReasoningConfig() — resolves reasoning settings for GPT-5.6 sol/terra/luna and o-series models
    • resolvePromptCacheKey() — derives stable cache keys from model + system + tools, or uses explicit override
    • isStrictCompatibleSchema() — validates JSON schemas for Responses strict mode compatibility
    • finalizeResponsesRequest() — applies reasoning, cache key, and strict mode to Responses requests
  • Model capability inference for GPT-5.6 family (sol/terra/luna variants) with tool-use, json-mode, and vision support

Unified Refusal Handling

  • New StreamEvent type refusal — first-class refusal events with optional category field

  • Provider-agnostic emitRefusal() helper in RefusalHelpers.ts

  • Per-provider refusal detection:

    • OpenAI: response.refusal.delta events (Responses API)
    • OpenAI Chat: delta.refusal in chat completions
    • Anthropic: message_delta with stop_reason: "refusal"Anthropic_Refusal.ts
    • Gemini: finishReason in {SAFETY, PROHIBITED_CONTENT, …} or promptFeedback.blockReasonGemini_Refusal.ts
    • xAI: delta.refusal in chat completions
  • Refusal accumulation in StreamEventAccumulator and StreamProcessor:

    • Refusals fold into reserved refusal output field (concatenated if streamed)
    • Optional refusal_category field for provider-specific reason
    • Task still COMPLETES (not an error)
  • Structured generation respects refusals — stops retry loop if model refuses

Testing

  • Comprehensive test suite OpenAIShapedResponses.test.ts covering:

    • Text delta mapping
    • Tool call argument accumulation and fragmentation
    • Concurrent tool calls (separate by output_index)
    • Interleaved text and tool-call streams
    • Orphaned deltas (synthesized IDs)
    • Refusal deltas
    • Lifecycle event filtering
  • Cross-provider refusal tests CrossProviderRefusals.test.ts validating OpenAI, Anthropic, and Gemini refusal signals

  • Updated OpenAiProvider.test.ts with reasoning config and schema validation tests

Implementation Details

https://claude.ai/code/session_014wMFWnofYaAj8vcLMHS1p9

claude added 9 commits July 10, 2026 05:29
…nAI provider

Register the GPT-5.6 model family (gpt-5.6 alias, gpt-5.6-sol flagship,
gpt-5.6-terra, gpt-5.6-luna) in the OpenAI model-search fallback list.
Capability inference already recognizes the gpt-5 prefix, so these models
advertise chat + tool-use + json-mode + vision-input; a new test locks
that in.

Add an optional reasoning_effort field to the OpenAI provider_config schema
(none/low/medium/high/xhigh/max) and plumb it through the text-generation,
tool-calling, and structured-generation request builders. This enables the
quality-first "pro" configuration (max effort) for the reasoning-capable
GPT-5.6 family without inventing a nonexistent model slug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wMFWnofYaAj8vcLMHS1p9
Phase 1 of the Responses API migration (additive, no runtime behavior change):

- Rename accumulateOpenAIStream -> accumulateOpenAIChatStream in the shared
  Chat Completions helper; update the HFI and OpenAI tool-calling callers.
- Add provider-utils/OpenAIShapedResponses.ts: accumulateOpenAIResponsesStream
  (typed Responses events -> text-delta/object-delta, keyed by output_index so
  concurrent tool calls don't collide), plus buildResponsesInput,
  buildResponsesTools, mapResponsesToolChoice, parseResponsesToolCalls.
- Recorded-event unit tests covering text, single/concurrent tool calls,
  fragmented args, interleaving, orphan deltas, and lifecycle-event skipping.

No run-fn is wired to Responses yet; the OpenAI provider still uses Chat
Completions. HFI and the Chat Completions path are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wMFWnofYaAj8vcLMHS1p9
Phase 2-4 of the migration. The five chat-shaped run-fns now call
client.responses.create instead of client.chat.completions.create:

- TextGeneration / AiChat, ToolCalling, StructuredGeneration (json-mode via
  text.format.json_schema), TextRewriter, TextSummary.
- Tool calls and text stream through accumulateOpenAIResponsesStream; the
  json-mode finish.data.object contract is preserved.
- buildResponsesInput fully translates chat-shaped history into Responses
  input: system/developer -> instructions, assistant tool_calls -> function_call
  items, tool results -> function_call_output items, and user image_url parts ->
  input_image. Multi-turn agent tool loops and vision inputs stay correct.

Reasoning config is reshaped from the reasoning_effort stopgap to
provider_config.reasoning { effort, mode }, sent as the Responses `reasoning`
parameter (mode:"pro" enables pro mode). getReasoningEffort -> getReasoningConfig.

Validated live against the OpenAI API via the conformance suite (text, tool-use,
json-mode). HFI and the shared Chat Completions path are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wMFWnofYaAj8vcLMHS1p9
Phase 5. Every Responses request now carries a prompt_cache_key so requests
sharing a system-instructions + tools prefix converge on one key and hit
GPT-5.6's prompt cache. The key is auto-derived (FNV-1a over model +
instructions + tools) and overridable via provider_config.prompt_cache_key.
A stable key is the cost-correct default now that 5.6 bills cache writes.

Adds unit tests for the cache-key derivation (stability, prefix-sensitivity,
override) and getReasoningConfig. Validated live against the OpenAI API.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wMFWnofYaAj8vcLMHS1p9
Code-review cleanups on the Responses migration:

- Export parseToolArgs from OpenAIShapedChat and reuse it in
  OpenAIShapedResponses instead of a second verbatim copy.
- Add finalizeResponsesRequest(model, params) to apply the model reasoning
  config + a stable prompt_cache_key in one place, replacing the block that
  was copy-pasted across all five Responses run-fns.

No behavior change; unit + live conformance tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wMFWnofYaAj8vcLMHS1p9
… output

Addresses two review findings on the Responses migration:

- Structured generation now sends text.format strict:true only when the
  output schema actually satisfies OpenAI's strict subset (every object has
  additionalProperties:false and lists all properties in required, recursively;
  combinators and $ref are treated conservatively). Otherwise strict:false, so
  arbitrary user schemas no longer 400. The StructuredGenerationTask consumer
  still re-validates and retries against the original schema.
- Model refusals are surfaced instead of silently dropped: the shared
  Responses accumulator maps response.refusal.delta to text on the text port,
  and structured generation throws a clear "OpenAI refused..." error when a
  refusal arrives with no JSON output (rather than returning {}).

Adds unit tests for isStrictCompatibleSchema and refusal handling; live
conformance (incl. json-mode) passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wMFWnofYaAj8vcLMHS1p9
Direction A: a refusal is a valid outcome that COMPLETES the task with a
distinguishable `refusal` field, not an error and not ordinary content.

Core (@workglow/task-graph):
- New StreamRefusal event ({ type: "refusal"; refusal; category? }) in the
  StreamEvent union, plus reserved REFUSAL_OUTPUT_KEY / REFUSAL_CATEGORY_OUTPUT_KEY.
- StreamProcessor + StreamEventAccumulator accumulate refusal deltas and fold
  them into output.refusal (+ refusalCategory). Dataflow ignores the event on
  data edges (metadata, like phase).
- Guardrail 1: CacheCoordinator.save skips memoizing any output carrying a
  truthy `refusal`, so a transient refusal never replays as the answer.

Providers (@workglow/ai + @workglow/openai):
- emitRefusal(emit, text, category?) provider-utils helper for uniform mapping.
- OpenAI Responses adapter maps response.refusal.delta to a refusal event
  (was folded into text); structured generation emits a refusal event, and
  StructuredGenerationTask short-circuits (completes with the refusal field
  instead of retrying against empty JSON).

Anthropic/Gemini native-signal mapping and the opt-in failOnRefusal are
phases 3-4 (spec: cross-provider-refusals-design; tracks libs#625).

Unit tests: StreamEventAccumulator refusal folding, OpenAI refusal-event mapping.
Live OpenAI conformance passes; 140 streaming/cache + 68 structured tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wMFWnofYaAj8vcLMHS1p9
Each provider now maps its native refusal signal through emitRefusal so refusals
surface uniformly via the reserved `refusal` output field:

- OpenAI-compatible chat (xAI, HFI): the shared accumulateOpenAIChatStream maps
  a chat-completion delta.refusal to a refusal event; xAI's inline text /
  rewriter / summary / structured loops do the same.
- Anthropic: maybeEmitAnthropicRefusal detects message_delta stop_reason
  "refusal" across all five streaming run-fns.
- Gemini: geminiRefusalCategory classifies blocked finishReasons
  (SAFETY / PROHIBITED_CONTENT / BLOCKLIST / SPII / RECITATION / IMAGE_SAFETY /
  LANGUAGE) and promptFeedback.blockReason (input-blocked); emitGeminiRefusal
  fires once per run.

Also fixes the xAI provider, which the main rebase broke: it imported the
pre-rename accumulateOpenAIStream (now accumulateOpenAIChatStream).

Unit tests cover all three provider mappings; live conformance passes; provider
+ streaming/cache/structured suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wMFWnofYaAj8vcLMHS1p9
The test helper returned ModelRecord (provider: string) but getReasoningConfig /
resolvePromptCacheKey take OpenAiModelConfig (provider: "OPENAI" literal). CI's
build job type-checks @workglow/test; vitest transpiles without surfacing this.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wMFWnofYaAj8vcLMHS1p9

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR migrates the OpenAI provider’s streaming text/tool/structured-generation run-fns to the Responses API and adds a provider-agnostic “refusal” stream event that is accumulated into reserved output fields so callers can reliably detect policy/safety declines across providers.

Changes:

  • Add OpenAI Responses API shaping helpers (input/tools/tool choice/tool call parsing + typed event stream adapter) and migrate OpenAI streaming run-fns to client.responses.create(...).
  • Introduce first-class refusal stream events and plumb refusal accumulation through StreamProcessor, StreamEventAccumulator, StructuredGenerationTask, and cache behavior.
  • Add/refine refusal detection for Anthropic, Gemini, and OpenAI-compatible chat providers (xAI / HFI), plus new test coverage for Responses streaming + cross-provider refusals.

Reviewed changes

Copilot reviewed 42 out of 42 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
providers/xai/src/ai/common/Xai_ToolCalling.ts Switches xAI tool-calling stream adapter to the renamed OpenAI-chat accumulator.
providers/xai/src/ai/common/Xai_TextSummary.ts Emits refusal events when xAI chat deltas include delta.refusal.
providers/xai/src/ai/common/Xai_TextRewriter.ts Emits refusal events when xAI chat deltas include delta.refusal.
providers/xai/src/ai/common/Xai_TextGeneration.ts Adds delta.refusal support alongside streamed content.
providers/xai/src/ai/common/Xai_StructuredGeneration.ts Accumulates chat refusal deltas and emits a terminal refusal event.
providers/openai/src/ai/index.ts Exposes additional OpenAI internals via _testOnly for new tests.
providers/openai/src/ai/common/OpenAI_ToolCalling.ts Migrates tool-use streaming to OpenAI Responses API + Responses-shaped tools/tool-choice/input.
providers/openai/src/ai/common/OpenAI_TextSummary.ts Migrates summary streaming to OpenAI Responses API via Responses stream accumulator.
providers/openai/src/ai/common/OpenAI_TextRewriter.ts Migrates rewrite streaming to OpenAI Responses API via Responses stream accumulator.
providers/openai/src/ai/common/OpenAI_TextGeneration.ts Migrates unified text generation streaming to OpenAI Responses API; uses Responses input/instructions mapping.
providers/openai/src/ai/common/OpenAI_StructuredGeneration.ts Uses Responses text.format=json_schema and adds strict-schema compatibility detection + refusal capture.
providers/openai/src/ai/common/OpenAI_ModelSearch.ts Adds GPT-5.6 family IDs to fallback model list.
providers/openai/src/ai/common/OpenAI_ModelSchema.ts Adds provider-config knobs for prompt_cache_key override and reasoning settings.
providers/openai/src/ai/common/OpenAI_Client.ts Adds reasoning config resolution + deterministic prompt cache key derivation and request finalization.
providers/huggingface-inference/src/ai/common/HFI_ToolCalling.ts Switches HFI tool-calling to OpenAI-chat accumulator rename.
providers/google-gemini/src/ai/index.ts Exposes refusal helpers via _testOnly.
providers/google-gemini/src/ai/common/Gemini_ToolCalling.ts Detects refusal category during streaming and emits a normalized refusal at end.
providers/google-gemini/src/ai/common/Gemini_TextSummary.ts Detects refusal category during streaming and emits a normalized refusal at end.
providers/google-gemini/src/ai/common/Gemini_TextRewriter.ts Detects refusal category during streaming and emits a normalized refusal at end.
providers/google-gemini/src/ai/common/Gemini_TextGeneration.ts Detects refusal category during streaming and emits a normalized refusal at end.
providers/google-gemini/src/ai/common/Gemini_StructuredGeneration.ts Detects refusal category during streaming and emits a normalized refusal at end.
providers/google-gemini/src/ai/common/Gemini_Refusal.ts New helper to classify Gemini refusal/block signals and emit normalized refusal events.
providers/anthropic/src/ai/index.ts Exposes refusal helper via _testOnly.
providers/anthropic/src/ai/common/Anthropic_ToolCalling.ts Emits refusal when Anthropic stream indicates stop_reason: "refusal".
providers/anthropic/src/ai/common/Anthropic_TextSummary.ts Emits refusal when Anthropic stream indicates stop_reason: "refusal".
providers/anthropic/src/ai/common/Anthropic_TextRewriter.ts Emits refusal when Anthropic stream indicates stop_reason: "refusal".
providers/anthropic/src/ai/common/Anthropic_TextGeneration.ts Emits refusal when Anthropic stream indicates stop_reason: "refusal".
providers/anthropic/src/ai/common/Anthropic_StructuredGeneration.ts Emits refusal when Anthropic stream indicates stop_reason: "refusal".
providers/anthropic/src/ai/common/Anthropic_Refusal.ts New helper to detect/emit Anthropic refusals during streaming.
packages/task-graph/src/task/StreamTypes.ts Adds StreamRefusal plus reserved output keys for refusal text/category.
packages/task-graph/src/task/StreamProcessor.ts Accumulates streamed refusals into reserved output fields without treating them as errors.
packages/task-graph/src/task/CacheCoordinator.ts Prevents caching refused outputs.
packages/ai/src/task/StructuredGenerationTask.ts Stops retry loop when a refusal is observed (terminal valid outcome).
packages/ai/src/provider-utils/RefusalHelpers.ts New provider-agnostic emitRefusal(...) helper.
packages/ai/src/provider-utils/OpenAIShapedResponses.ts New Responses API adapter helpers (input/tools/tool choice/tool call parsing + typed stream mapping).
packages/ai/src/provider-utils/OpenAIShapedChat.ts Renames stream accumulator to accumulateOpenAIChatStream and adds chat refusal delta mapping.
packages/ai/src/provider-utils.ts Re-exports new Responses + refusal helper modules.
packages/ai/src/capability/StreamEventAccumulator.ts Accumulates refusals into reserved output fields during materialization.
packages/test/src/test/ai/StreamEventAccumulator.test.ts Adds unit tests for refusal accumulation semantics.
packages/test/src/test/ai-provider/OpenAIShapedResponses.test.ts New tests for Responses input/tool shaping and typed stream adaptation (text/tool/refusal).
packages/test/src/test/ai-provider/CrossProviderRefusals.test.ts New cross-provider refusal tests for OpenAI-chat, Anthropic, and Gemini signals.
packages/test/src/test/ai-provider-api/OpenAiProvider.test.ts Adds tests for reasoning config, strict-schema compatibility, and prompt cache key derivation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +236 to +239
export async function accumulateOpenAIResponsesStream(
stream: AsyncIterable<any>,
emit: (event: StreamEvent<ToolCallingTaskOutput>) => void
): Promise<void> {
Comment on lines +231 to +234
* Reasoning-summary, refusal, and lifecycle events are ignored here. The caller
* emits its own `finish` event after this returns (per the streaming convention),
* typically with structural defaults so the output satisfies
* {@link ToolCallingTaskOutput} even when only tool calls streamed.
- OpenRouter (OpenAI-compatible chat, added on main): map chat delta.refusal to
  refusal events across all five run-fns, and fix its tool-calling import of the
  renamed accumulateOpenAIStream -> accumulateOpenAIChatStream (a rebase break,
  same class as xAI).
- Address Copilot review on #627: make accumulateOpenAIResponsesStream generic
  over the output type (non-tool run-fns pass their own emit under
  strictFunctionTypes; OpenAI_ToolCalling supplies the type arg explicitly), and
  correct the JSDoc that wrongly said refusals are ignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wMFWnofYaAj8vcLMHS1p9
@sroussey sroussey force-pushed the claude/openai-models-luna-terra-sol-jqz9hj branch from 694f780 to 9a10e4c Compare July 10, 2026 05:38
providers/openai grew from the Responses API migration (new Responses adapter,
reasoning/cache-key/strict-schema helpers) — the tracked +21% is expected, not a
regression. Also picks up the OpenRouter and xAI provider baselines added on main
and the small task-graph/ai/test growth from the refusal mechanism.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wMFWnofYaAj8vcLMHS1p9
@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 62.68% 26409 / 42132
🔵 Statements 62.52% 27320 / 43698
🔵 Functions 63.46% 4967 / 7826
🔵 Branches 51.22% 13000 / 25376
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
scripts/lib/test-credentials.ts 90.62% 78.57% 100% 89.28% 94-99
Generated in workflow #2695 for commit 6e49702 by the Vitest Coverage Report Action

Copy link
Copy Markdown
Collaborator Author

Automated review — must-fix findings

Findings from an automated review of this PR: 2 HIGH.

H1 — Prompt-injection escalation via buildResponsesInput (packages/ai/src/provider-utils/OpenAIShapedResponses.ts).
The loop lifts every role: "system" | "developer" message into instructionParts, not just leading ones. A mid-conversation attacker-injected system turn from a partially-untrusted messages array (RAG-retrieved content, chat-history forwarding, tool outputs shaped as messages) is promoted into the Responses instructions channel — a strict privilege escalation vs. Chat Completions.

Fix:

  • Track a liftingInstructions flag that flips false on the first user/assistant/tool message
  • Only leading system/developer messages lift; subsequent ones demote to a defanged user-role turn ("[attempted mid-conversation system message, ignored]: …")
  • Add regression tests with [system, user, system, user]

H2 — Structured-generation streaming path diverges from .execute() (packages/task-graph/src/task/StreamProcessor.ts finish handler interacting with StructuredGenerationTask.executeStream).
merged[port] = obj from the last accumulator object-delta overrides the run-fn's authoritative finish.data.object. .execute() returns the validated object; .run() returns the accumulator's last partial-parse snapshot. Cache writes store the divergent value.

Fix:

  • In StructuredGenerationTask.executeStream, immediately before the successful finish, emit a terminal { type: "object-delta", port: "object", objectDelta: finalObject }
  • Preserves delta-wins semantics for both accumulators
  • Add a .run() vs .execute() parity regression test

— posted by scheduled review routine


Generated by Claude Code

sroussey and others added 2 commits July 10, 2026 16:43
- Add OpenRouter_Generic.integration.test.ts (gated on OPENROUTER_API_KEY,
  hydrated by the new credential entry) exercising openai/gpt-4o-mini live —
  text generation, tool-use, and json-mode through the OpenRouter provider,
  including the refusal wiring.
- HFI integration test: meta-llama/Llama-3.1-8B-Instruct is no longer routable
  via HF Inference ("no inference provider information for model"). Switch to
  meta-llama/Llama-3.3-70B-Instruct pinned to the `together` router provider
  (tool-calling capable). Unrelated to the Responses/refusal work; surfaced when
  the HFI integration test began running under the new credentials.

Both verified live: 27 passed / 11 (capability-gated) skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wMFWnofYaAj8vcLMHS1p9
@sroussey sroussey merged commit da4d331 into main Jul 10, 2026
14 checks passed
@sroussey sroussey deleted the claude/openai-models-luna-terra-sol-jqz9hj branch July 10, 2026 17:00
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.

3 participants