Add OpenAI Responses API support and unified refusal handling#627
Conversation
…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
There was a problem hiding this comment.
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
refusalstream events and plumb refusal accumulation throughStreamProcessor,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.
| export async function accumulateOpenAIResponsesStream( | ||
| stream: AsyncIterable<any>, | ||
| emit: (event: StreamEvent<ToolCallingTaskOutput>) => void | ||
| ): Promise<void> { |
| * 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
694f780 to
9a10e4c
Compare
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
Coverage Report
File Coverage
|
||||||||||||||||||||||||||||||||||||||
Automated review — must-fix findingsFindings from an automated review of this PR: 2 HIGH. H1 — Prompt-injection escalation via Fix:
H2 — Structured-generation streaming path diverges from Fix:
— posted by scheduled review routine Generated by Claude Code |
- 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
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.tswith helpers for the Responses API:buildResponsesInput()— converts unified message format to Responsesinput+instructionsbuildResponsesTools()— flattens tool definitions (no nestedfunctionwrapper)mapResponsesToolChoice()— maps tool choice to Responses formatparseResponsesToolCalls()— parses non-streaming tool calls fromoutput[]accumulateOpenAIResponsesStream()— adapts typed Responses events to workglowStreamEvents (text-delta, object-delta, refusal)Updated OpenAI run-fns to use Responses API:
OpenAI_TextGeneration_Stream— now usesclient.responses.create()OpenAI_ToolCalling_Stream— streams tool calls via ResponsesOpenAI_TextRewriter_Stream— uses Responses withinstructionschannelOpenAI_TextSummary_Stream— migrated to ResponsesNew OpenAI client helpers:
getReasoningConfig()— resolves reasoning settings for GPT-5.6 sol/terra/luna and o-series modelsresolvePromptCacheKey()— derives stable cache keys from model + system + tools, or uses explicit overrideisStrictCompatibleSchema()— validates JSON schemas for Responsesstrictmode compatibilityfinalizeResponsesRequest()— applies reasoning, cache key, and strict mode to Responses requestsModel capability inference for GPT-5.6 family (sol/terra/luna variants) with tool-use, json-mode, and vision support
Unified Refusal Handling
New
StreamEventtyperefusal— first-class refusal events with optionalcategoryfieldProvider-agnostic
emitRefusal()helper inRefusalHelpers.tsPer-provider refusal detection:
response.refusal.deltaevents (Responses API)delta.refusalin chat completionsmessage_deltawithstop_reason: "refusal"→Anthropic_Refusal.tsfinishReasonin{SAFETY, PROHIBITED_CONTENT, …}orpromptFeedback.blockReason→Gemini_Refusal.tsdelta.refusalin chat completionsRefusal accumulation in
StreamEventAccumulatorandStreamProcessor:refusaloutput field (concatenated if streamed)refusal_categoryfield for provider-specific reasonStructured generation respects refusals — stops retry loop if model refuses
Testing
Comprehensive test suite
OpenAIShapedResponses.test.tscovering:output_index)Cross-provider refusal tests
CrossProviderRefusals.test.tsvalidating OpenAI, Anthropic, and Gemini refusal signalsUpdated
OpenAiProvider.test.tswith reasoning config and schema validation testsImplementation Details
https://claude.ai/code/session_014wMFWnofYaAj8vcLMHS1p9