Skip to content

feat(llm): expose the Responses API as a config-selectable provider (#383) - #385

Merged
initializ-mk merged 3 commits into
mainfrom
feat/openai-responses-provider
Aug 8, 2026
Merged

feat(llm): expose the Responses API as a config-selectable provider (#383)#385
initializ-mk merged 3 commits into
mainfrom
feat/openai-responses-provider

Conversation

@initializ-mk

Copy link
Copy Markdown
Contributor

Closes #383.

What

Makes forge's OpenAI Responses API (/v1/responses) selectable through the normal provider config + API-key/gateway auth — the same way openai/anthropic/gemini/ollama are. Previously ResponsesClient was fully implemented but reachable only through the ChatGPT OAuth path; nothing config-driven could POST /responses.

Changes

Factory (llm/providers/factory.go)

  • New provider string openai-responsesNewResponsesClient(cfg).
  • Provider constant llm.ProviderOpenAIResponses (in llm/client.go) so the factory and the runtime config resolver share one spelling.

Responses client (llm/providers/responses.go) — composes with auth_scheme, mirroring the openai client (issue item 5):

  • aws_sigv4 wraps the transport with the Bedrock SigV4 signer and skips the native Authorization: Bearer.
  • apikey_header / apikey_header_only send the key in a gateway header (Kong etc); _only suppresses the native Bearer.
  • Empty scheme (the ChatGPT OAuth path) is byte-for-byte unchanged.
  • store is not forced here — store=false stays OAuth-only (issue item 3), so the API-key path uses the API's default store behavior.

Config (runtime/config.go) — ResolveModelConfig treats openai-responses as OpenAI-family (isOpenAIFamily): resolves OPENAI_API_KEY / LLM_API_KEY, OPENAI_BASE_URL, OPENAI_ORG_ID / organization_id, and the gpt-5.4 default model — for both the primary and fallback paths.

Validate (validate/forge_config.go) — openai-responses honors auth_scheme and organization_id, so it's excluded from the "provider ignores it" warnings.

Scope decisions (from the issue)

  1. Provider identity — a distinct string openai-responses (mirrors the factory shape), not a mode flag.
  2. Forced streamingResponsesClient.Chat still sets stream=true internally; works against the public Responses API. Left unchanged.
  3. store flag — left unset on the API-key path (API default), not forced false.
  4. Auth schemes — composes with bearer / apikey_header / apikey_header_only / aws_sigv4, same as the openai client.

Tests

  • Factory returns *ResponsesClient for openai-responses.
  • Auth-scheme header composition: default bearer / apikey_header additive / apikey_header_only suppresses bearer / custom header name.
  • Config resolution: API key, base URL, org, default model, LLM_API_KEY fallback.
  • Validate: updated warning wording + no false warning when auth_scheme is set on openai-responses.

gofmt, golangci-lint (0 issues), and go test ./... pass for all three modules.

Downstream (not this repo)

The AIP agent-builder llmconfig mapping needs openai-responses added to its ForgeProvider set so the platform can select it — tracked separately, noted so they land together.

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 initializ-mk left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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)).
  • setHeaders Bearer-suppression condition — identical (apiKey != "" && != aws_sigv4 && != apikey_header_only).
  • setGatewayAPIKeyHeader(req, authScheme, authHeaderName, apiKey) call — identical, and that shared helper carries the authorization/x-api-key collision 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_KEYLLM_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)

  1. Data retention — no store=false opt-out on the API-key path (worth surfacing). ClientConfig has no Store field; the request only sets store:false when the unexported disableStore is true (responses.go:294), which only the OAuth path sets. So a config-selected openai-responses provider leaves store at 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 future store / disable_store config flag would let privacy-sensitive operators force it off.
  2. Forced streaming now exposed to arbitrary gateways (compatibility caveat). ResponsesClient.Chat always sets stream=true (item 2, unchanged). Fine when the only reachable endpoint was the ChatGPT backend, but it's now reachable through any OPENAI_BASE_URL / gateway — including the apikey_header (Kong etc.) path this PR targets. A gateway that buffers or doesn't proxy SSE would break this provider. The public OpenAI /responses endpoint 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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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 initializ-mk left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.DisableStoreClientConfig.DisableStoreNewResponsesClient (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 = true after construction, so it forces store=false regardless of the flag — matching the docs ("always forces store=false regardless of this flag").
  • Default preserved (issue item 3): unset → store omitted → 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_store is set where it's ignored.
  • Strong test: TestResponsesClient_StoreFlag drives Chat against an httptest SSE server and inspects the actual JSON request body for the store key — covers both default omits store and disable_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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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 → NewResponsesClientstore: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.

@initializ-mk
initializ-mk merged commit 456ad97 into main Aug 8, 2026
10 checks passed
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.

feat(llm): expose the Responses API as a config-selectable provider (API-key / gateway), not just ChatGPT OAuth

1 participant