feat(llm): expose the Responses API as a config-selectable provider (#383) - #385
Conversation
Expose the OpenAI Responses API (/v1/responses) as a normal config-selectable provider with API-key / gateway auth — the same way openai/anthropic/gemini/ollama are — instead of it being reachable only through the ChatGPT OAuth path. - factory: new "openai-responses" provider string returns a ResponsesClient. Provider constant lives in llm.ProviderOpenAIResponses so the factory and the runtime config resolver share one spelling. - responses client: compose with auth_scheme like the openai client — aws_sigv4 wraps the transport with the Bedrock SigV4 signer and skips the native Bearer; apikey_header / apikey_header_only send the key in a gateway header (Kong etc). Empty scheme (the OAuth path) is byte-for-byte unchanged. store=false is NOT forced here — that stays OAuth-only, so the API-key path uses the API's default store behavior. - config: ResolveModelConfig treats openai-responses as OpenAI-family — resolves OPENAI_API_KEY / LLM_API_KEY, OPENAI_BASE_URL, OPENAI_ORG_ID / organization_id, and the gpt-5.4 default model (isOpenAIFamily helper), for both the primary and fallback paths. - validate: openai-responses honors auth_scheme and organization_id, so it's excluded from the "provider ignores it" warnings. Tests: factory returns *ResponsesClient; auth-scheme header composition (bearer / apikey_header additive / apikey_header_only suppresses bearer / custom header name); config resolution (key, base URL, org, default model, LLM_API_KEY fallback); validate warning wording + no false warning on openai-responses. Note: forced internal streaming (ResponsesClient.Chat always stream=true) is unchanged and works against the public Responses API. The downstream agent-builder llmconfig mapping for the new provider string is tracked separately (per the issue).
Sync docs for the new config-selectable Responses API provider: - forge-yaml-schema.md: add openai-responses to the model.provider enum and the custom-URL wire-format notes; explain the /responses endpoint, shared OpenAI credential/base-URL/org resolution, auth_scheme composition, and the OAuth-only store=false distinction. - runtime-engine.md: add the provider to the LLM Providers table and the custom-URL wire-format table; note the chat-completions vs responses endpoint split and that the SigV4 signer is symmetric across it. - .claude/skills/forge.md: add the provider row and fold openai-responses into the aws_sigv4 / apikey_header applicability notes.
initializ-mk
left a comment
There was a problem hiding this comment.
Reviewed against the branch source — clean, correctly scoped, and the auth composition is a faithful mirror of the openai client. LGTM. I traced the two things that could have gone wrong — the shared OAuth path and the auth mirroring — and both hold.
The OAuth path is provably unchanged
disableStore is never set inside NewResponsesClient (nor was it before this PR). The OAuth path sets it externally — oauth_client.go:26-28: inner := NewResponsesClient(cfg); inner.disableStore = true. And the OAuth cfg carries an empty AuthScheme, so through the new code: no SigV4 transport wrap, setHeaders keeps the Bearer (empty scheme passes both != guards), and setGatewayAPIKeyHeader early-returns (no-op unless apikey_header[_only]). ChatGPT OAuth is byte-for-byte identical, as claimed.
Auth mirroring is exact
Diffed ResponsesClient against OpenAIClient directly:
- Constructor SigV4 wrap — identical (
newBedrockSigningTransport(cfg.AWSRegion, http.DefaultTransport)). setHeadersBearer-suppression condition — identical (apiKey != "" && != aws_sigv4 && != apikey_header_only).setGatewayAPIKeyHeader(req, authScheme, authHeaderName, apiKey)call — identical, and that shared helper carries theauthorization/x-api-keycollision guard.
So openai-responses composes with bearer / apikey_header / apikey_header_only / aws_sigv4 the same way openai does. Issue item 5 delivered.
Config + validate are consistent
isOpenAIFamily is applied everywhere the old == "openai" checks lived — org header (primary + fallback), OPENAI_BASE_URL, API key (OPENAI_API_KEY → LLM_API_KEY), and the gpt-5.4 default — for both primary and fallback resolution. Validate excludes openai-responses from the org-id and auth-scheme "ignored" warnings with updated wording. Default baseURL api.openai.com/v1 (correct for the API-key path; OAuth overrides via cfg.BaseURL). CI fully green including the Doc link check.
Observations (non-blocking)
- Data retention — no
store=falseopt-out on the API-key path (worth surfacing).ClientConfighas noStorefield; the request only setsstore:falsewhen the unexporteddisableStoreis true (responses.go:294), which only the OAuth path sets. So a config-selectedopenai-responsesprovider leavesstoreat the OpenAI default (true) — responses retained ~30 days on OpenAI's side — with no config knob to disable it. This is the deliberate item-3 decision, but the inability to opt out is the part worth documenting; a futurestore/disable_storeconfig flag would let privacy-sensitive operators force it off. - Forced streaming now exposed to arbitrary gateways (compatibility caveat).
ResponsesClient.Chatalways setsstream=true(item 2, unchanged). Fine when the only reachable endpoint was the ChatGPT backend, but it's now reachable through anyOPENAI_BASE_URL/ gateway — including theapikey_header(Kong etc.) path this PR targets. A gateway that buffers or doesn't proxy SSE would break this provider. The public OpenAI/responsesendpoint supports it, so the default case is fine.
| if c.orgID != "" { | ||
| req.Header.Set("OpenAI-Organization", c.orgID) | ||
| } | ||
| setGatewayAPIKeyHeader(req, c.authScheme, c.authHeaderName, c.apiKey) |
There was a problem hiding this comment.
Verified this setHeaders is a byte-for-byte mirror of OpenAIClient.setHeaders — same Bearer-suppression guard (apiKey != "" && != aws_sigv4 && != apikey_header_only) and the same setGatewayAPIKeyHeader call, which carries the authorization/x-api-key collision guard. Combined with the constructor's SigV4 transport wrap, openai-responses composes with every auth_scheme the openai client does. The empty-scheme (OAuth) path keeps the plain Bearer and no-ops the gateway header, so it's unchanged.
| // OpenAI Responses API (/v1/responses) via plain API-key / gateway | ||
| // auth — the same client the ChatGPT OAuth path uses, minus the | ||
| // OAuth-only store=false forcing (#383). Composes with auth_scheme. | ||
| return NewResponsesClient(cfg), nil |
There was a problem hiding this comment.
One data-governance note on this new API-key path: disableStore (which sets store:false in the request at responses.go:294) is set only by the OAuth wrapper — never by NewResponsesClient and unreachable from ClientConfig (no Store field). So a config-selected openai-responses provider leaves store at the OpenAI default (true), meaning responses are retained ~30 days on OpenAI's side with no way to opt out via config. Deliberate per issue item 3, but worth a docs mention (or a future store/disable_store flag) so privacy-sensitive operators aren't surprised — the OAuth path forces it false, this one can't.
…view) Review flagged that the config-selected openai-responses provider left `store` at OpenAI's default (responses retained ~30 days) with no way to opt out — only the OAuth path could force store=false. Add a real knob: - ClientConfig.DisableStore → ResponsesClient sends store=false. Wired in NewResponsesClient; the OAuth path still forces it true externally. - model.disable_store (forge.yaml) → carried onto the client by ResolveModelConfig. Default unset leaves store at the API default (issue #383 item 3 preserved); this is an opt-in override. - validate warns when disable_store is set on a provider that ignores it. Docs: document disable_store and the forced-internal-SSE-streaming caveat (review observation 2 — a gateway that doesn't proxy SSE breaks the provider) in forge-yaml-schema.md, runtime-engine.md, and forge.md. Tests: store=false sent when DisableStore, omitted by default; config wiring; validate warn/no-warn by provider.
initializ-mk
left a comment
There was a problem hiding this comment.
Closing re-review — the disable_store follow-up (b69c41746) resolves the retention finding cleanly, and the streaming caveat is now documented. CI fully green (Test, Integration, all 6 builds, Lint, Doc link check). Ready to merge.
Observation 1 (data retention) — resolved with a real knob, traced end-to-end
model.disable_store (types/config.go ModelRef.DisableStore) → ResolveModelConfig sets mc.Client.DisableStore → ClientConfig.DisableStore → NewResponsesClient (disableStore: cfg.DisableStore) → the existing if c.disableStore { r.Store = &false } puts store: false on the wire. Complete chain.
- No OAuth regression: the OAuth path still sets
inner.disableStore = trueafter construction, so it forcesstore=falseregardless of the flag — matching the docs ("always forces store=false regardless of this flag"). - Default preserved (issue item 3): unset →
storeomitted → API default. - Carried unconditionally + validate guard: the field is set on the client for any provider but only the Responses client acts on it, and validate now warns when
disable_storeis set where it's ignored. - Strong test:
TestResponsesClient_StoreFlagdrivesChatagainst an httptest SSE server and inspects the actual JSON request body for thestorekey — covers bothdefault omits storeanddisable_store sends false. Config-wiring and validate warn/no-warn tests added too.
Observation 2 (forced streaming) — documented
The internal-SSE-streaming caveat (a gateway that doesn't proxy SSE breaks the provider) and the retention/disable_store behavior are now in forge-yaml-schema.md, runtime-engine.md, and forge.md. Accurate.
One minor note (non-blocking, by-design)
disable_store is a primary-model field only — ModelFallback doesn't carry it and resolveFallbacks doesn't wire it, so an openai-responses provider used as a fallback would retain at the API default. This is consistent with the existing, explicitly-documented decision that auth_scheme/auth_header_name are primary-only (the ModelFallback NOTE already tracks per-fallback scheme fields as a follow-up). Worth folding disable_store into that same follow-up if per-fallback knobs ever land — nothing to change here.
Nice, thorough turnaround. LGTM.
| // (store=false). Only the openai-responses client honors it; carried | ||
| // unconditionally since every other client ignores the field. | ||
| if cfg.Model.DisableStore { | ||
| mc.Client.DisableStore = true |
There was a problem hiding this comment.
This is the config→client wiring that closes the retention finding, and it's correct: carrying DisableStore unconditionally is the right call since only the Responses client reads it and validate warns on misuse elsewhere. Traced the full chain — model.disable_store → here → NewResponsesClient → store:false on the wire — and confirmed the OAuth path still force-sets disableStore = true after construction, so it's unaffected by the flag. Default-unset still omits store (API default), preserving issue item 3.
Closes #383.
What
Makes forge's OpenAI Responses API (
/v1/responses) selectable through the normal provider config + API-key/gateway auth — the same wayopenai/anthropic/gemini/ollamaare. PreviouslyResponsesClientwas fully implemented but reachable only through the ChatGPT OAuth path; nothing config-driven could POST/responses.Changes
Factory (
llm/providers/factory.go)openai-responses→NewResponsesClient(cfg).llm.ProviderOpenAIResponses(inllm/client.go) so the factory and the runtime config resolver share one spelling.Responses client (
llm/providers/responses.go) — composes withauth_scheme, mirroring the openai client (issue item 5):aws_sigv4wraps the transport with the Bedrock SigV4 signer and skips the nativeAuthorization: Bearer.apikey_header/apikey_header_onlysend the key in a gateway header (Kong etc);_onlysuppresses the native Bearer.storeis not forced here —store=falsestays OAuth-only (issue item 3), so the API-key path uses the API's default store behavior.Config (
runtime/config.go) —ResolveModelConfigtreatsopenai-responsesas OpenAI-family (isOpenAIFamily): resolvesOPENAI_API_KEY/LLM_API_KEY,OPENAI_BASE_URL,OPENAI_ORG_ID/organization_id, and thegpt-5.4default model — for both the primary and fallback paths.Validate (
validate/forge_config.go) —openai-responseshonorsauth_schemeandorganization_id, so it's excluded from the "provider ignores it" warnings.Scope decisions (from the issue)
openai-responses(mirrors the factory shape), not a mode flag.ResponsesClient.Chatstill setsstream=trueinternally; works against the public Responses API. Left unchanged.storeflag — left unset on the API-key path (API default), not forced false.bearer/apikey_header/apikey_header_only/aws_sigv4, same as the openai client.Tests
*ResponsesClientforopenai-responses.apikey_headeradditive /apikey_header_onlysuppresses bearer / custom header name.LLM_API_KEYfallback.auth_schemeis set onopenai-responses.gofmt,golangci-lint(0 issues), andgo test ./...pass for all three modules.Downstream (not this repo)
The AIP agent-builder
llmconfigmapping needsopenai-responsesadded to itsForgeProviderset so the platform can select it — tracked separately, noted so they land together.