From ab48cbbeb0e47df902de837058c11df06350218b Mon Sep 17 00:00:00 2001 From: PredictabilityAtScale <131020168+PredictabilityAtScale@users.noreply.github.com> Date: Thu, 23 Apr 2026 19:58:52 -0700 Subject: [PATCH] docs(providers): clarify streaming support across adapters --- README.md | 8 +- docs/api-reference.md | 3 +- docs/providers.md | 95 ++++++- package.json | 10 + src/index.ts | 5 +- src/overrides/apply-overrides.ts | 15 ++ src/providers/anthropic.ts | 16 ++ src/providers/gemini.ts | 19 +- src/providers/index.ts | 3 + src/providers/openai-responses.ts | 138 ++++++++++ src/providers/openai.ts | 15 +- src/providers/types.ts | 14 + src/schema/schema.ts | 31 ++- tests/providers.test.ts | 417 +++++++++++++++++++++++++++++- tsup.config.ts | 1 + 15 files changed, 775 insertions(+), 15 deletions(-) create mode 100644 src/providers/openai-responses.ts diff --git a/README.md b/README.md index 1f1b687..a4c6246 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ Supported values for `warnings.contextSize` are `auto`, `off`, `result-only`, `c - **Composition** — `includes` to share system instructions across prompts, with circular detection - **Folder defaults** — `defaults.md` inheritance for shared provider, model, metadata, and system instructions - **Overrides** — Environment and tier-based overrides (base → env → tier → runtime) -- **4 provider adapters** — OpenAI, Anthropic, Gemini, OpenRouter — body-only output +- **5 provider adapters** — OpenAI (Chat), OpenAI (Responses), Anthropic, Gemini, OpenRouter — body-only output - **Validation** — Zod schema validation, Levenshtein-based "did you mean?" for typos, variable usage checks - **Context hardening** — structured regexes with flags, `/pattern/i` convenience syntax, and built-in `non_empty` / `reject_secrets` validators - **Optional short-circuit messages** — validators can return a structured `returnMessage` instead of throwing when configured @@ -193,6 +193,7 @@ Provider adapters are also available as direct imports: ```typescript import { openaiAdapter } from 'promptopskit/openai'; +import { openaiResponsesAdapter } from 'promptopskit/openai-responses'; import { anthropicAdapter } from 'promptopskit/anthropic'; import { geminiAdapter } from 'promptopskit/gemini'; import { openrouterAdapter } from 'promptopskit/openrouter'; @@ -501,7 +502,7 @@ Renders a prompt for a specific provider. Returns `{ resolved, request?, returnM |--------|------|-------------| | `path` | `string` | Prompt path (no extension), e.g. `'support/reply'` | | `source` | `string` | Inline prompt source (alternative to path) | -| `provider` | `string` | `'openai'`, `'anthropic'`, `'gemini'`, `'openrouter'` | +| `provider` | `string` | `'openai'`, `'openai-responses'`, `'anthropic'`, `'gemini'`, `'openrouter'` | | `variables` | `Record` | Template variables | | `onContextOverflow` | `(info) => string` | Optional callback to transform oversized context values before rendering | | `environment` | `string` | Environment override name | @@ -509,6 +510,7 @@ Renders a prompt for a specific provider. Returns `{ resolved, request?, returnM | `history` | `Array<{ role, content }>` | Conversation history | | `toolRegistry` | `Record` | Tool definitions for resolving string tool references | | `strict` | `boolean` | Fail on missing variables | +| `openaiResponses` | `object` | Optional Responses API extras (`previous_response_id`, `conversation`, `instructions`, `parallel_tool_calls`, `max_tool_calls`, `store`, `metadata`, `include`, `background`) | ### `kit.loadPrompt(path)` / `kit.resolvePrompt(path, options)` / `kit.validatePrompt(path)` @@ -528,7 +530,7 @@ Prompt files use YAML front matter with these fields: |-------|------|-------------| | `id` | `string` | Unique prompt identifier (required) | | `schema_version` | `number` | Schema version, currently `1` | -| `provider` | `string` | `openai`, `anthropic`, `gemini` (or `google`), `openrouter`, `any` | +| `provider` | `string` | `openai`, `openai-responses`, `anthropic`, `gemini` (or `google`), `openrouter`, `any` | | `model` | `string` | Model name | | `fallback_models` | `string[]` | Fallback model list | | `reasoning` | `object` | `{ effort, budget_tokens }` | diff --git a/docs/api-reference.md b/docs/api-reference.md index 163123f..57ae963 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -63,7 +63,7 @@ const result = await kit.renderPrompt({ |--------|------|-------------| | `path` | `string` | Prompt path (no extension), e.g. `'support/reply'` | | `source` | `string` | Inline prompt source (alternative to `path`) | -| `provider` | `string` | `'openai'`, `'anthropic'`, `'gemini'`, `'openrouter'` (required) | +| `provider` | `string` | `'openai'`, `'openai-responses'`, `'anthropic'`, `'gemini'`, `'openrouter'` (required) | | `variables` | `Record` | Template variables | | `onContextOverflow` | `(info) => string` | Optional callback to transform an oversized context value before rendering | | `environment` | `string` | Environment override name | @@ -71,6 +71,7 @@ const result = await kit.renderPrompt({ | `history` | `Array<{ role, content }>` | Conversation history | | `toolRegistry` | `Record` | Tool definitions for resolving string tool references | | `strict` | `boolean` | Fail on missing variables (default `false`) | +| `openaiResponses` | `object` | Optional Responses API extras (`previous_response_id`, `conversation`, `instructions`, `parallel_tool_calls`, `max_tool_calls`, `store`, `metadata`, `include`, `background`) | Either `path` or `source` must be provided. diff --git a/docs/providers.md b/docs/providers.md index f5f1d1a..dd70693 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -1,16 +1,53 @@ # Provider Adapters -PromptOpsKit ships four provider adapters. Direct `render()` calls always produce a `{ body, provider, model }` object shaped for the target API. Async `renderPrompt()` helpers may instead return `{ provider, model, returnMessage }` when context validation is configured to short-circuit before request shaping. You handle the HTTP call — no auth, no headers, no HTTP client opinions. +PromptOpsKit ships five provider adapters. Direct `render()` calls always produce a `{ body, provider, model }` object shaped for the target API. Async `renderPrompt()` helpers may instead return `{ provider, model, returnMessage }` when context validation is configured to short-circuit before request shaping. You handle the HTTP call — no auth, no headers, no HTTP client opinions. ## Supported providers | Provider | Front matter value | Adapter | |----------|-------------------|---------| -| OpenAI | `openai` | `openaiAdapter` | +| OpenAI (Chat Completions) | `openai` | `openaiAdapter` | +| OpenAI (Responses API) | `openai-responses` | `openaiResponsesAdapter` | | Anthropic | `anthropic` | `anthropicAdapter` | | Google Gemini | `gemini` or `google` | `geminiAdapter` | | OpenRouter | `openrouter` | `openrouterAdapter` | + +## Normalized front matter vs provider-specific options + +PromptOpsKit already normalizes common settings across providers via front matter fields like `sampling`, `reasoning`, `response`, and `tools`. + +When a provider has extra knobs with no clean cross-provider equivalent, use `provider_options`: + +```yaml +provider_options: + anthropic: + top_k: 50 + tool_choice: + type: auto + gemini: + candidate_count: 2 + top_k: 20 + seed: 42 + response_modalities: ["TEXT"] + thinking_budget_tokens: 2048 +``` + +This keeps portable settings in normalized fields, while still exposing advanced provider-specific controls. + + +## Streaming support + +`response.stream` support differs by provider: + +| Provider | `response.stream` behavior | +|----------|----------------------------| +| `openai` | Mapped to body `stream` | +| `openai-responses` | Mapped to body `stream` | +| `anthropic` | Mapped to body `stream` | +| `openrouter` | Mapped to body `stream` (same as OpenAI) | +| `gemini` | **Not body-mapped**; Gemini streaming is endpoint-based (`streamGenerateContent`) | + ## Usage via `renderPrompt` ```typescript @@ -41,6 +78,7 @@ The provider passed to `renderPrompt` determines which adapter shapes the body. ```typescript import { openaiAdapter } from 'promptopskit/openai'; +import { openaiResponsesAdapter } from 'promptopskit/openai-responses'; import { anthropicAdapter } from 'promptopskit/anthropic'; import { geminiAdapter } from 'promptopskit/gemini'; import { openrouterAdapter } from 'promptopskit/openrouter'; @@ -62,7 +100,7 @@ interface ProviderAdapter { } ``` -Direct adapter rendering accepts the same `environment` and `tier` selectors as `kit.renderPrompt()`. Use the synchronous `validate()` and `render()` methods when you already have a compiled `ResolvedPromptAsset`, and use the async `validatePrompt()` and `renderPrompt()` helpers when you want the adapter to resolve either markdown source or a compiled artifact from disk. Context input validation runs through the same shared prompt-input wrapper for OpenAI, Anthropic, Gemini, and OpenRouter, so `allow_regex`, `deny_regex`, `non_empty`, `reject_secrets`, and `return_message` behave consistently across all four. +Direct adapter rendering accepts the same `environment` and `tier` selectors as `kit.renderPrompt()`. Use the synchronous `validate()` and `render()` methods when you already have a compiled `ResolvedPromptAsset`, and use the async `validatePrompt()` and `renderPrompt()` helpers when you want the adapter to resolve either markdown source or a compiled artifact from disk. Context input validation runs through the same shared prompt-input wrapper for OpenAI, OpenAI Responses, Anthropic, Gemini, and OpenRouter, so `allow_regex`, `deny_regex`, `non_empty`, `reject_secrets`, and `return_message` behave consistently across all five. Server-side example: @@ -178,7 +216,7 @@ If you want UsageTap begin/end tracking around a provider call, use the optional See [UsageTap](./usagetap.md) for setup, lifecycle helpers, entitlement behavior, tool gating, standalone usage extractors, and provider examples. -## OpenAI +## OpenAI (`openai`) Body shape: [Chat Completions API](https://platform.openai.com/docs/api-reference/chat) @@ -194,6 +232,44 @@ Body shape: [Chat Completions API](https://platform.openai.com/docs/api-referenc } ``` +## OpenAI Responses (`openai-responses`) + +Body shape: [Responses API](https://platform.openai.com/docs/api-reference/responses) + +```json +{ + "model": "gpt-5.4", + "instructions": "...", + "input": [ + { "role": "user", "content": "..." } + ], + "temperature": 0.7, + "reasoning": { "effort": "medium" } +} +``` + +Field mapping (differences from `openai`): + +| Front matter | Body field (`openai-responses`) | +|-------------|----------------------------------| +| `sampling.max_output_tokens` | `max_output_tokens` | +| `reasoning.effort` | `reasoning: { effort }` | +| `response.format: json` | `text: { format: { type: "json_object" } }` | +| `response.schema` | `text: { format: { type: "json_schema", name, schema, strict } }` | +| `sections.system_instructions` | `instructions` (top-level) | +| `history + prompt_template` | `input` items instead of `messages` | +| `tools` | Responses function tools (`{ type, name, description, parameters }`) | + +Warnings: +- `reasoning.budget_tokens` is ignored (Responses uses `reasoning.effort`). + +Extra supported options via `renderPrompt(..., { openaiResponses: { ... } })` or direct adapter runtime: +- `previous_response_id` (conversation chaining) +- `conversation` (mutually exclusive with `previous_response_id`) +- `parallel_tool_calls`, `max_tool_calls` +- `store`, `metadata`, `include`, `background` +- `instructions` override (runtime override for top-level instructions) + Field mapping: | Front matter | Body field | @@ -207,6 +283,7 @@ Field mapping: | `sampling.max_output_tokens` | `max_tokens` | | `reasoning.effort` | `reasoning_effort` | | `response.format: json` | `response_format: { type: "json_object" }` | +| `response.schema` | `response_format: { type: "json_schema", json_schema: { name, schema, strict } }` | | `response.stream` | `stream` | Warnings: @@ -233,6 +310,8 @@ Key differences from OpenAI: - `max_tokens` is **required** — defaults to `4096` if `sampling.max_output_tokens` is not set. - `sampling.stop` maps to `stop_sequences`. - `reasoning.budget_tokens` maps to `thinking: { type: "enabled", budget_tokens }`. +- `provider_options.anthropic.top_k` maps to `top_k`. +- `provider_options.anthropic.tool_choice` maps to `tool_choice`. Warnings: - `frequency_penalty` and `presence_penalty` are not supported — ignored with a warning. @@ -265,7 +344,15 @@ Key differences: - Sampling parameters are nested under `generationConfig`. - `top_p` maps to `topP`, `max_output_tokens` maps to `maxOutputTokens`, `stop` maps to `stopSequences`. - `response.format: json` maps to `generationConfig.responseMimeType: "application/json"`. +- `response.schema` maps to `generationConfig.responseSchema` (portable normalized schema shape). +- `response.stream` is not body-mapped for Gemini; use the streaming endpoint (`streamGenerateContent`). - `reasoning.effort` maps to `thinkingConfig.thinkingBudget` (high=8192, medium=4096, low=1024). +- `provider_options.gemini.candidate_count` maps to `generationConfig.candidateCount`. +- `provider_options.gemini.top_k` maps to `generationConfig.topK`. +- `provider_options.gemini.seed` maps to `generationConfig.seed`. +- `provider_options.gemini.response_schema` maps to `generationConfig.responseSchema`. +- `provider_options.gemini.response_modalities` maps to `generationConfig.responseModalities`. +- `provider_options.gemini.thinking_budget_tokens` overrides effort-derived thinking budget. Warnings: - `frequency_penalty` and `presence_penalty` are not supported — ignored with a warning. diff --git a/package.json b/package.json index 449f91d..a596d26 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,16 @@ "types": "./dist/providers/openrouter.d.cts", "default": "./dist/providers/openrouter.cjs" } + }, + "./openai-responses": { + "import": { + "types": "./dist/providers/openai-responses.d.ts", + "default": "./dist/providers/openai-responses.js" + }, + "require": { + "types": "./dist/providers/openai-responses.d.cts", + "default": "./dist/providers/openai-responses.cjs" + } } }, "files": [ diff --git a/src/index.ts b/src/index.ts index 99e883a..a669cb6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -63,7 +63,7 @@ export { interpolate, extractVariables } from './renderer/index.js'; export { resolveIncludes } from './composition/index.js'; export { applyOverrides } from './overrides/index.js'; export { validateAsset, validateAssetWithIncludes } from './validation/index.js'; -export { getAdapter, openaiAdapter } from './providers/index.js'; +export { getAdapter, openaiAdapter, openaiResponsesAdapter } from './providers/index.js'; export { anthropicAdapter } from './providers/anthropic.js'; export { geminiAdapter } from './providers/gemini.js'; export { openrouterAdapter } from './providers/openrouter.js'; @@ -121,6 +121,8 @@ export interface RenderPromptOptions { toolRegistry?: Record; /** Strict mode — fail on missing variables */ strict?: boolean; + /** OpenAI Responses API-specific request options */ + openaiResponses?: RuntimeRenderOptions['openaiResponses']; } // --- Result --- @@ -263,6 +265,7 @@ export class PromptOpsKit { history: options.history, toolRegistry: options.toolRegistry, strict: options.strict, + openaiResponses: options.openaiResponses, }); return { diff --git a/src/overrides/apply-overrides.ts b/src/overrides/apply-overrides.ts index 159ab4c..bec7534 100644 --- a/src/overrides/apply-overrides.ts +++ b/src/overrides/apply-overrides.ts @@ -56,5 +56,20 @@ function mergeOverride( result.response = { ...result.response, ...override.response }; } + if (override.provider_options !== undefined) { + result.provider_options = { + ...result.provider_options, + ...override.provider_options, + anthropic: { + ...result.provider_options?.anthropic, + ...override.provider_options.anthropic, + }, + gemini: { + ...result.provider_options?.gemini, + ...override.provider_options.gemini, + }, + }; + } + return result; } diff --git a/src/providers/anthropic.ts b/src/providers/anthropic.ts index 13f4652..51355b4 100644 --- a/src/providers/anthropic.ts +++ b/src/providers/anthropic.ts @@ -34,6 +34,13 @@ export const anthropicAdapter: ProviderAdapter = withPromptInputSupport({ if (resolvedAsset.reasoning?.effort !== undefined) { warnings.push('Anthropic uses budget_tokens for thinking, not effort. effort will be mapped approximately.'); } + if (resolvedAsset.response?.schema !== undefined) { + warnings.push('Anthropic does not support response.schema structured output in this adapter. It will be ignored.'); + } + + if (resolvedAsset.provider_options?.anthropic?.top_k !== undefined && resolvedAsset.provider_options.anthropic.top_k < 0) { + errors.push('Anthropic provider_options.top_k must be >= 0.'); + } return { valid: errors.length === 0, errors, warnings }; }, @@ -88,6 +95,11 @@ export const anthropicAdapter: ProviderAdapter = withPromptInputSupport({ }; } + // Provider-specific options + if (resolvedAsset.provider_options?.anthropic?.top_k !== undefined) { + body.top_k = resolvedAsset.provider_options.anthropic.top_k; + } + // Streaming if (resolvedAsset.response?.stream !== undefined) { body.stream = resolvedAsset.response.stream; @@ -109,6 +121,10 @@ export const anthropicAdapter: ProviderAdapter = withPromptInputSupport({ }); } + if (resolvedAsset.provider_options?.anthropic?.tool_choice !== undefined) { + body.tool_choice = resolvedAsset.provider_options.anthropic.tool_choice; + } + return { body, provider: 'anthropic', diff --git a/src/providers/gemini.ts b/src/providers/gemini.ts index e26518c..19fa10f 100644 --- a/src/providers/gemini.ts +++ b/src/providers/gemini.ts @@ -31,6 +31,9 @@ export const geminiAdapter: ProviderAdapter = withPromptInputSupport({ if (resolvedAsset.sampling?.presence_penalty !== undefined) { warnings.push('Gemini does not support presence_penalty. It will be ignored.'); } + if (resolvedAsset.response?.stream !== undefined) { + warnings.push('Gemini streaming is endpoint-based (streamGenerateContent), not body-based. response.stream will be ignored.'); + } return { valid: errors.length === 0, errors, warnings }; }, @@ -76,17 +79,31 @@ export const geminiAdapter: ProviderAdapter = withPromptInputSupport({ // Generation config const generationConfig: Record = {}; + const geminiOptions = resolvedAsset.provider_options?.gemini; + if (resolvedAsset.sampling?.temperature !== undefined) generationConfig.temperature = resolvedAsset.sampling.temperature; if (resolvedAsset.sampling?.top_p !== undefined) generationConfig.topP = resolvedAsset.sampling.top_p; if (resolvedAsset.sampling?.max_output_tokens !== undefined) generationConfig.maxOutputTokens = resolvedAsset.sampling.max_output_tokens; if (resolvedAsset.sampling?.stop !== undefined) generationConfig.stopSequences = resolvedAsset.sampling.stop; + if (geminiOptions?.candidate_count !== undefined) generationConfig.candidateCount = geminiOptions.candidate_count; + if (geminiOptions?.top_k !== undefined) generationConfig.topK = geminiOptions.top_k; + if (geminiOptions?.seed !== undefined) generationConfig.seed = geminiOptions.seed; + if (geminiOptions?.response_schema !== undefined) generationConfig.responseSchema = geminiOptions.response_schema; + if (geminiOptions?.response_modalities !== undefined) generationConfig.responseModalities = geminiOptions.response_modalities; + + if (resolvedAsset.response?.schema !== undefined) generationConfig.responseSchema = resolvedAsset.response.schema; + if (resolvedAsset.response?.format === 'json') { generationConfig.responseMimeType = 'application/json'; } // Thinking config - if (resolvedAsset.reasoning?.effort) { + if (geminiOptions?.thinking_budget_tokens !== undefined) { + body.thinkingConfig = { + thinkingBudget: geminiOptions.thinking_budget_tokens, + }; + } else if (resolvedAsset.reasoning?.effort) { body.thinkingConfig = { thinkingBudget: resolvedAsset.reasoning.effort === 'high' ? 8192 : resolvedAsset.reasoning.effort === 'medium' ? 4096 : 1024, }; diff --git a/src/providers/index.ts b/src/providers/index.ts index 2bfd9a2..b2106a4 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -8,18 +8,21 @@ export type { RuntimeRenderOptions, } from './types.js'; export { openaiAdapter } from './openai.js'; +export { openaiResponsesAdapter } from './openai-responses.js'; export { anthropicAdapter } from './anthropic.js'; export { geminiAdapter } from './gemini.js'; export { openrouterAdapter } from './openrouter.js'; import type { ProviderAdapter } from './types.js'; import { openaiAdapter } from './openai.js'; +import { openaiResponsesAdapter } from './openai-responses.js'; import { anthropicAdapter } from './anthropic.js'; import { geminiAdapter } from './gemini.js'; import { openrouterAdapter } from './openrouter.js'; const adapters: Record = { openai: openaiAdapter, + 'openai-responses': openaiResponsesAdapter, anthropic: anthropicAdapter, google: geminiAdapter, gemini: geminiAdapter, diff --git a/src/providers/openai-responses.ts b/src/providers/openai-responses.ts new file mode 100644 index 0000000..9c5ed28 --- /dev/null +++ b/src/providers/openai-responses.ts @@ -0,0 +1,138 @@ +import type { ResolvedPromptAsset } from '../schema/index.js'; +import type { + ProviderAdapter, + ProviderRequest, + ValidationResult, + RuntimeRenderOptions, +} from './types.js'; +import { renderSections } from '../renderer/index.js'; +import { resolveAssetForProvider } from './resolve-asset.js'; +import { withPromptInputSupport } from './prompt-input.js'; + +/** + * OpenAI Responses provider adapter. + * Produces request bodies compatible with the OpenAI Responses API. + */ +export const openaiResponsesAdapter: ProviderAdapter = withPromptInputSupport({ + name: 'openai-responses', + + validate(asset: ResolvedPromptAsset, runtime?: RuntimeRenderOptions): ValidationResult { + const resolvedAsset = resolveAssetForProvider(asset, runtime); + const errors: string[] = []; + const warnings: string[] = []; + + if (!resolvedAsset.model) { + errors.push('OpenAI Responses adapter requires a model to be specified.'); + } + + if (resolvedAsset.reasoning?.budget_tokens !== undefined) { + warnings.push('OpenAI Responses uses reasoning.effort, not budget_tokens. budget_tokens will be ignored.'); + } + + if (resolvedAsset.response?.schema !== undefined && resolvedAsset.response?.format !== 'json') { + warnings.push('OpenAI Responses response.schema requires response.format: json. schema will still be applied as JSON schema output.'); + } + + if (runtime?.openaiResponses?.conversation !== undefined && runtime?.openaiResponses?.previous_response_id !== undefined) { + errors.push('OpenAI Responses options "conversation" and "previous_response_id" cannot both be set.'); + } + + return { valid: errors.length === 0, errors, warnings }; + }, + + render(asset: ResolvedPromptAsset, runtime: RuntimeRenderOptions): ProviderRequest { + const resolvedAsset = resolveAssetForProvider(asset, runtime); + const sections = renderSections(resolvedAsset, { + variables: runtime.variables, + strict: runtime.strict, + }); + const responseOptions = runtime.openaiResponses; + + const input: Array> = []; + + if (runtime.history) { + for (const msg of runtime.history) { + input.push({ role: msg.role, content: msg.content }); + } + } + + if (sections.prompt_template) { + input.push({ role: 'user', content: sections.prompt_template }); + } + + const body: Record = { + model: resolvedAsset.model, + input, + }; + + if (responseOptions?.instructions !== undefined) { + body.instructions = responseOptions.instructions; + } else if (sections.system_instructions) { + body.instructions = sections.system_instructions; + } + + // Sampling params + if (resolvedAsset.sampling?.temperature !== undefined) body.temperature = resolvedAsset.sampling.temperature; + if (resolvedAsset.sampling?.top_p !== undefined) body.top_p = resolvedAsset.sampling.top_p; + if (resolvedAsset.sampling?.frequency_penalty !== undefined) body.frequency_penalty = resolvedAsset.sampling.frequency_penalty; + if (resolvedAsset.sampling?.presence_penalty !== undefined) body.presence_penalty = resolvedAsset.sampling.presence_penalty; + if (resolvedAsset.sampling?.stop !== undefined) body.stop = resolvedAsset.sampling.stop; + if (resolvedAsset.sampling?.max_output_tokens !== undefined) body.max_output_tokens = resolvedAsset.sampling.max_output_tokens; + + // Reasoning + if (resolvedAsset.reasoning?.effort) { + body.reasoning = { effort: resolvedAsset.reasoning.effort }; + } + + // Structured output / response format + if (resolvedAsset.response?.schema) { + body.text = { + format: { + type: 'json_schema', + name: resolvedAsset.response.schema_name ?? `${resolvedAsset.id}_response`, + schema: resolvedAsset.response.schema, + strict: resolvedAsset.response.schema_strict ?? true, + }, + }; + } else if (resolvedAsset.response?.format === 'json') { + body.text = { format: { type: 'json_object' } }; + } + + // Streaming + if (resolvedAsset.response?.stream !== undefined) { + body.stream = resolvedAsset.response.stream; + } + + // Tools + if (resolvedAsset.tools && resolvedAsset.tools.length > 0) { + body.tools = resolvedAsset.tools.map((tool) => { + if (typeof tool === 'string') { + const def = runtime.toolRegistry?.[tool]; + if (def) return def; + return { type: 'function', name: tool }; + } + return { + type: 'function', + name: tool.name, + description: tool.description, + parameters: tool.input_schema, + }; + }); + } + + if (responseOptions?.previous_response_id !== undefined) body.previous_response_id = responseOptions.previous_response_id; + if (responseOptions?.conversation !== undefined) body.conversation = responseOptions.conversation; + if (responseOptions?.parallel_tool_calls !== undefined) body.parallel_tool_calls = responseOptions.parallel_tool_calls; + if (responseOptions?.max_tool_calls !== undefined) body.max_tool_calls = responseOptions.max_tool_calls; + if (responseOptions?.store !== undefined) body.store = responseOptions.store; + if (responseOptions?.metadata !== undefined) body.metadata = responseOptions.metadata; + if (responseOptions?.include !== undefined) body.include = responseOptions.include; + if (responseOptions?.background !== undefined) body.background = responseOptions.background; + + return { + body, + provider: 'openai-responses', + model: resolvedAsset.model ?? 'unknown', + }; + }, +}); diff --git a/src/providers/openai.ts b/src/providers/openai.ts index cbcd211..9f45b05 100644 --- a/src/providers/openai.ts +++ b/src/providers/openai.ts @@ -29,6 +29,10 @@ export const openaiAdapter: ProviderAdapter = withPromptInputSupport({ warnings.push('OpenAI uses reasoning_effort, not budget_tokens. budget_tokens will be ignored.'); } + if (resolvedAsset.response?.schema !== undefined && resolvedAsset.response?.format !== 'json') { + warnings.push('OpenAI response.schema requires response.format: json. schema will still be applied as JSON schema output.'); + } + return { valid: errors.length === 0, errors, warnings }; }, @@ -77,7 +81,16 @@ export const openaiAdapter: ProviderAdapter = withPromptInputSupport({ } // Response format - if (resolvedAsset.response?.format === 'json') { + if (resolvedAsset.response?.schema) { + body.response_format = { + type: 'json_schema', + json_schema: { + name: resolvedAsset.response.schema_name ?? `${resolvedAsset.id}_response`, + schema: resolvedAsset.response.schema, + strict: resolvedAsset.response.schema_strict ?? true, + }, + }; + } else if (resolvedAsset.response?.format === 'json') { body.response_format = { type: 'json_object' }; } diff --git a/src/providers/types.ts b/src/providers/types.ts index bfd978b..e4519d7 100644 --- a/src/providers/types.ts +++ b/src/providers/types.ts @@ -28,6 +28,19 @@ export interface ValidationResult { warnings: string[]; } + +export interface OpenAIResponsesRuntimeOptions { + previous_response_id?: string; + conversation?: string; + instructions?: string; + parallel_tool_calls?: boolean; + max_tool_calls?: number; + include?: string[]; + metadata?: Record; + store?: boolean; + background?: boolean; +} + /** * Options passed at render time. */ @@ -46,6 +59,7 @@ export interface RuntimeRenderOptions { history?: Array<{ role: string; content: string }>; toolRegistry?: Record; strict?: boolean; + openaiResponses?: OpenAIResponsesRuntimeOptions; } export interface ProviderPromptLookup { diff --git a/src/schema/schema.ts b/src/schema/schema.ts index 702c47d..6d1c914 100644 --- a/src/schema/schema.ts +++ b/src/schema/schema.ts @@ -47,6 +47,31 @@ export const SamplingSchema = z.object({ export const ResponseSchema = z.object({ format: z.enum(['text', 'json', 'markdown']).optional(), stream: z.boolean().optional(), + schema: z.record(z.unknown()).optional(), + schema_name: z.string().optional(), + schema_strict: z.boolean().optional(), +}); + + +// --- Provider-specific options --- + +export const AnthropicProviderOptionsSchema = z.object({ + top_k: z.number().int().min(0).optional(), + tool_choice: z.record(z.unknown()).optional(), +}); + +export const GeminiProviderOptionsSchema = z.object({ + candidate_count: z.number().int().positive().optional(), + top_k: z.number().int().min(0).optional(), + seed: z.number().int().optional(), + response_schema: z.record(z.unknown()).optional(), + response_modalities: z.array(z.string()).optional(), + thinking_budget_tokens: z.number().int().positive().optional(), +}); + +export const ProviderOptionsSchema = z.object({ + anthropic: AnthropicProviderOptionsSchema.optional(), + gemini: GeminiProviderOptionsSchema.optional(), }); // --- Context --- @@ -119,6 +144,7 @@ export const PromptAssetOverridesSchema = z.object({ sampling: SamplingSchema.optional(), response: ResponseSchema.optional(), tools: z.array(ToolRefSchema).optional(), + provider_options: ProviderOptionsSchema.optional(), }); export type PromptAssetOverrides = z.infer; @@ -141,7 +167,7 @@ export const SectionsSchema = z.object({ // --- Defaults files (folder-level inheritance) --- export const PromptDefaultsSchema = z.object({ - provider: z.enum(['openai', 'anthropic', 'google', 'gemini', 'openrouter', 'any']).optional(), + provider: z.enum(['openai', 'openai-responses', 'anthropic', 'google', 'gemini', 'openrouter', 'any']).optional(), model: z.string().optional(), metadata: MetadataSchema.optional(), sections: z.object({ @@ -158,7 +184,7 @@ export const PromptAssetSchema = z.object({ schema_version: z.number().int().positive().default(1), description: z.string().optional(), - provider: z.enum(['openai', 'anthropic', 'google', 'gemini', 'openrouter', 'any']).optional(), + provider: z.enum(['openai', 'openai-responses', 'anthropic', 'google', 'gemini', 'openrouter', 'any']).optional(), model: z.string().optional(), fallback_models: z.array(z.string()).optional(), @@ -167,6 +193,7 @@ export const PromptAssetSchema = z.object({ response: ResponseSchema.optional(), tools: z.array(ToolRefSchema).optional(), + provider_options: ProviderOptionsSchema.optional(), mcp: MCPSchema.optional(), context: ContextSchema.optional(), diff --git a/tests/providers.test.ts b/tests/providers.test.ts index e0c4552..1617ffb 100644 --- a/tests/providers.test.ts +++ b/tests/providers.test.ts @@ -3,6 +3,7 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { openaiAdapter } from '../src/providers/openai.js'; +import { openaiResponsesAdapter } from '../src/providers/openai-responses.js'; import { anthropicAdapter } from '../src/providers/anthropic.js'; import { geminiAdapter } from '../src/providers/gemini.js'; import { openrouterAdapter } from '../src/providers/openrouter.js'; @@ -11,7 +12,7 @@ import { PromptAssetSchema } from '../src/schema/index.js'; import type { ResolvedPromptAsset } from '../src/schema/index.js'; import { createPromptOpsKit } from '../src/index.js'; -const adaptersWithPromptInput = [openaiAdapter, anthropicAdapter, geminiAdapter, openrouterAdapter] as const; +const adaptersWithPromptInput = [openaiAdapter, openaiResponsesAdapter, anthropicAdapter, geminiAdapter, openrouterAdapter] as const; const baseAsset: ResolvedPromptAsset = { id: 'test', @@ -43,6 +44,41 @@ describe('OpenAI adapter', () => { expect(messages[1]).toEqual({ role: 'user', content: 'Hello World.' }); }); + it('maps response.schema to OpenAI json_schema response_format', () => { + const result = openaiAdapter.render( + { + ...baseAsset, + response: { + format: 'json', + schema_name: 'support_response', + schema: { + type: 'object', + properties: { + answer: { type: 'string' }, + }, + required: ['answer'], + }, + }, + }, + { variables: { name: 'World' } }, + ); + + expect(result.body.response_format).toEqual({ + type: 'json_schema', + json_schema: { + name: 'support_response', + schema: { + type: 'object', + properties: { + answer: { type: 'string' }, + }, + required: ['answer'], + }, + strict: true, + }, + }); + }); + it('applies environment overrides during direct adapter render', () => { const result = openaiAdapter.render( { @@ -249,6 +285,180 @@ Message: {{ user_message }}`, }); }); +describe('OpenAI Responses adapter', () => { + it('renders a valid Responses API request body', () => { + const result = openaiResponsesAdapter.render(baseAsset, { + variables: { name: 'World' }, + }); + + expect(result.provider).toBe('openai-responses'); + expect(result.model).toBe('gpt-5.4'); + expect(result.body.model).toBe('gpt-5.4'); + expect(result.body.temperature).toBe(0.7); + expect(result.body.max_output_tokens).toBe(1024); + + expect(result.body.instructions).toBe('You are a test assistant.'); + + const input = result.body.input as Array<{ role: string; content: string }>; + expect(input[0]).toEqual({ role: 'user', content: 'Hello World.' }); + }); + + it('maps reasoning, json response format, and tools to Responses API fields', () => { + const result = openaiResponsesAdapter.render( + { + ...baseAsset, + reasoning: { effort: 'high' }, + response: { format: 'json', stream: true }, + tools: [ + 'lookup_customer', + { + name: 'search_orders', + description: 'Search orders', + input_schema: { + type: 'object', + properties: { + query: { type: 'string' }, + }, + }, + }, + ], + }, + { + variables: { name: 'World' }, + toolRegistry: { + lookup_customer: { + type: 'function', + name: 'lookup_customer', + description: 'Find customer', + parameters: { + type: 'object', + properties: { + id: { type: 'string' }, + }, + }, + }, + }, + }, + ); + + expect(result.body.reasoning).toEqual({ effort: 'high' }); + expect(result.body.text).toEqual({ format: { type: 'json_object' } }); + expect(result.body.stream).toBe(true); + + const tools = result.body.tools as Array>; + expect(tools[0]).toEqual({ + type: 'function', + name: 'lookup_customer', + description: 'Find customer', + parameters: { + type: 'object', + properties: { + id: { type: 'string' }, + }, + }, + }); + expect(tools[1]).toEqual({ + type: 'function', + name: 'search_orders', + description: 'Search orders', + parameters: { + type: 'object', + properties: { + query: { type: 'string' }, + }, + }, + }); + }); + + + it('maps response.schema to Responses text.format json_schema', () => { + const result = openaiResponsesAdapter.render( + { + ...baseAsset, + response: { + format: 'json', + schema_name: 'reply_schema', + schema: { + type: 'object', + properties: { + answer: { type: 'string' }, + }, + }, + }, + }, + { variables: { name: 'World' } }, + ); + + expect(result.body.text).toEqual({ + format: { + type: 'json_schema', + name: 'reply_schema', + schema: { + type: 'object', + properties: { + answer: { type: 'string' }, + }, + }, + strict: true, + }, + }); + }); + + it('supports Responses API conversation-state and execution options', () => { + const result = openaiResponsesAdapter.render(baseAsset, { + variables: { name: 'World' }, + openaiResponses: { + instructions: 'Runtime instructions', + previous_response_id: 'resp_123', + parallel_tool_calls: false, + max_tool_calls: 2, + include: ['reasoning.encrypted_content'], + metadata: { ticket: 'INC-42' }, + store: true, + background: true, + }, + }); + + expect(result.body.instructions).toBe('Runtime instructions'); + expect(result.body.previous_response_id).toBe('resp_123'); + expect(result.body.parallel_tool_calls).toBe(false); + expect(result.body.max_tool_calls).toBe(2); + expect(result.body.include).toEqual(['reasoning.encrypted_content']); + expect(result.body.metadata).toEqual({ ticket: 'INC-42' }); + expect(result.body.store).toBe(true); + expect(result.body.background).toBe(true); + }); + + it('reports invalid runtime option combinations during validation', () => { + const validation = openaiResponsesAdapter.validate(baseAsset, { + openaiResponses: { + previous_response_id: 'resp_123', + conversation: 'conv_456', + }, + }); + + expect(validation.valid).toBe(false); + expect(validation.errors).toContain( + 'OpenAI Responses options "conversation" and "previous_response_id" cannot both be set.', + ); + }); + + it('includes history messages as input items', () => { + const result = openaiResponsesAdapter.render(baseAsset, { + variables: { name: 'World' }, + history: [ + { role: 'user', content: 'Hi' }, + { role: 'assistant', content: 'Hello!' }, + ], + }); + + const input = result.body.input as Array<{ role: string; content: string }>; + expect(input).toHaveLength(3); + expect(input[0]).toEqual({ role: 'user', content: 'Hi' }); + expect(input[1]).toEqual({ role: 'assistant', content: 'Hello!' }); + }); +}); + describe('Anthropic adapter', () => { it('renders with system as top-level field', () => { const result = anthropicAdapter.render( @@ -265,6 +475,41 @@ describe('Anthropic adapter', () => { expect(messages[0]).toEqual({ role: 'user', content: 'Hello World.' }); }); + it('maps response.schema to OpenAI json_schema response_format', () => { + const result = openaiAdapter.render( + { + ...baseAsset, + response: { + format: 'json', + schema_name: 'support_response', + schema: { + type: 'object', + properties: { + answer: { type: 'string' }, + }, + required: ['answer'], + }, + }, + }, + { variables: { name: 'World' } }, + ); + + expect(result.body.response_format).toEqual({ + type: 'json_schema', + json_schema: { + name: 'support_response', + schema: { + type: 'object', + properties: { + answer: { type: 'string' }, + }, + required: ['answer'], + }, + strict: true, + }, + }); + }); + it('applies environment overrides during direct adapter render', () => { const result = anthropicAdapter.render( { @@ -294,6 +539,51 @@ describe('Anthropic adapter', () => { const result = anthropicAdapter.render(assetNoMax, { variables: { name: 'World' } }); expect(result.body.max_tokens).toBe(4096); }); + + + it('maps provider_options for top_k and tool_choice', () => { + const result = anthropicAdapter.render( + { + ...baseAsset, + provider: 'anthropic', + model: 'claude-sonnet-4-6', + provider_options: { + anthropic: { + top_k: 20, + tool_choice: { type: 'auto' }, + }, + }, + }, + { variables: { name: 'World' } }, + ); + + expect(result.body.top_k).toBe(20); + expect(result.body.tool_choice).toEqual({ type: 'auto' }); + }); + + it('merges provider_options through environment and runtime overrides', () => { + const result = anthropicAdapter.render( + { + ...baseAsset, + provider: 'anthropic', + model: 'claude-sonnet-4-6', + provider_options: { anthropic: { top_k: 10 } }, + environments: { + dev: { + provider_options: { anthropic: { tool_choice: { type: 'none' } } }, + }, + }, + }, + { + environment: 'dev', + runtime: { provider_options: { anthropic: { top_k: 25 } } }, + variables: { name: 'World' }, + }, + ); + + expect(result.body.top_k).toBe(25); + expect(result.body.tool_choice).toEqual({ type: 'none' }); + }); }); describe('shared prompt-input validation across providers', () => { @@ -349,6 +639,84 @@ describe('Gemini adapter', () => { }); }); + it('warns that response.stream is not body-mapped for Gemini', () => { + const validation = geminiAdapter.validate({ + ...baseAsset, + provider: 'gemini', + model: 'gemini-2.5-flash', + response: { stream: true }, + }); + + expect(validation.valid).toBe(true); + expect(validation.warnings).toContain( + 'Gemini streaming is endpoint-based (streamGenerateContent), not body-based. response.stream will be ignored.', + ); + }); + + it('maps gemini provider_options into generationConfig and thinkingConfig', () => { + const result = geminiAdapter.render( + { + ...baseAsset, + provider: 'gemini', + model: 'gemini-2.5-flash', + provider_options: { + gemini: { + candidate_count: 2, + top_k: 40, + seed: 7, + response_schema: { + type: 'object', + properties: { answer: { type: 'string' } }, + }, + response_modalities: ['TEXT'], + thinking_budget_tokens: 2500, + }, + }, + }, + { variables: { name: 'World' } }, + ); + + const generationConfig = result.body.generationConfig as Record; + expect(generationConfig.candidateCount).toBe(2); + expect(generationConfig.topK).toBe(40); + expect(generationConfig.seed).toBe(7); + expect(generationConfig.responseSchema).toEqual({ + type: 'object', + properties: { answer: { type: 'string' } }, + }); + expect(generationConfig.responseModalities).toEqual(['TEXT']); + expect(result.body.thinkingConfig).toEqual({ thinkingBudget: 2500 }); + }); + + it('maps normalized response.schema to Gemini responseSchema', () => { + const result = geminiAdapter.render( + { + ...baseAsset, + provider: 'gemini', + model: 'gemini-2.5-flash', + response: { + format: 'json', + schema: { + type: 'object', + properties: { + answer: { type: 'string' }, + }, + }, + }, + }, + { variables: { name: 'World' } }, + ); + + const generationConfig = result.body.generationConfig as Record; + expect(generationConfig.responseSchema).toEqual({ + type: 'object', + properties: { + answer: { type: 'string' }, + }, + }); + expect(generationConfig.responseMimeType).toBe('application/json'); + }); + it('applies tier and runtime overrides during direct adapter render', () => { const result = geminiAdapter.render( { @@ -378,6 +746,41 @@ describe('Gemini adapter', () => { }); describe('OpenRouter adapter', () => { + it('maps response.schema to OpenAI json_schema response_format', () => { + const result = openaiAdapter.render( + { + ...baseAsset, + response: { + format: 'json', + schema_name: 'support_response', + schema: { + type: 'object', + properties: { + answer: { type: 'string' }, + }, + required: ['answer'], + }, + }, + }, + { variables: { name: 'World' } }, + ); + + expect(result.body.response_format).toEqual({ + type: 'json_schema', + json_schema: { + name: 'support_response', + schema: { + type: 'object', + properties: { + answer: { type: 'string' }, + }, + required: ['answer'], + }, + strict: true, + }, + }); + }); + it('applies environment overrides during direct adapter render', () => { const result = getAdapter('openrouter').render( { @@ -402,6 +805,15 @@ describe('OpenRouter adapter', () => { }); describe('Provider naming', () => { + it('schema accepts openai-responses as a provider value', () => { + const result = PromptAssetSchema.safeParse({ + id: 'test', + schema_version: 1, + provider: 'openai-responses', + }); + expect(result.success).toBe(true); + }); + it('schema accepts gemini as a provider value', () => { const result = PromptAssetSchema.safeParse({ id: 'test', @@ -420,7 +832,8 @@ describe('Provider naming', () => { expect(result.success).toBe(true); }); - it('getAdapter resolves both gemini and google', () => { + it('getAdapter resolves openai-responses, gemini, and google', () => { + expect(getAdapter('openai-responses').name).toBe('openai-responses'); expect(getAdapter('gemini').name).toBe('gemini'); expect(getAdapter('google').name).toBe('gemini'); }); diff --git a/tsup.config.ts b/tsup.config.ts index ed88137..7206239 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -8,6 +8,7 @@ export default defineConfig([ testing: 'src/testing.ts', 'usagetap/index': 'src/usagetap/index.ts', 'providers/openai': 'src/providers/openai.ts', + 'providers/openai-responses': 'src/providers/openai-responses.ts', 'providers/anthropic': 'src/providers/anthropic.ts', 'providers/gemini': 'src/providers/gemini.ts', 'providers/openrouter': 'src/providers/openrouter.ts',