This is an automated email from the ASF dual-hosted git repository.

hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git

commit a39a88962d9eccd944a198876ea356746b7a2e9d
Author: Yang Luo <[email protected]>
AuthorDate: Sun Sep 6 01:20:36 2026 +0800

    feat(proxy): speak the Responses API to a provider, not just to a client
---
 README.md                                   |   2 +
 README_zh.md                                |   2 +
 controllers/agent_provider.go               |   4 +-
 controllers/proxy_record.go                 |  20 +-
 object/provider.go                          |  32 ++-
 object/provider_probe.go                    |   2 +-
 object/provider_probe_answer.go             |   2 +-
 object/provider_probe_checks.go             |   6 +-
 object/provider_probe_run.go                |   4 +-
 protocol/protocol.go                        |  10 +-
 protocol/responses.go                       | 380 +++++++++++++++++++++++++++-
 web/src/components/shared/brand-options.tsx |  11 +
 web/src/lib/providers.ts                    |  31 ++-
 web/src/locales/en/data.json                |   3 +
 web/src/locales/zh/data.json                |   3 +
 web/src/pages/ProviderEditPage.tsx          |  25 +-
 web/src/pages/ProviderListPage.tsx          |   9 +-
 web/src/types.ts                            |   2 +
 18 files changed, 505 insertions(+), 43 deletions(-)

diff --git a/README.md b/README.md
index e1b22ff..73756cb 100644
--- a/README.md
+++ b/README.md
@@ -262,6 +262,8 @@ The token is Gateway's own relay token, not a vendor key: 
the agent refuses to s
 
 One base URL answers whichever API the agent speaks: `/chat/completions` for 
an OpenAI client, `/v1/messages` for an Anthropic one, `/responses` for Codex, 
which speaks nothing else since it dropped the chat completions wire format, 
and `/v1beta/models/<model>:generateContent` for the Gemini CLI, which speaks 
only Google's own API. The API the provider serves need not be the same one: 
Gateway translates between all four, in both directions and for streamed 
answers too, so Codex runs on D [...]
 
+Upstream, which API Gateway calls is the provider's own setting: **Upstream 
API** defaults to the one the provider type implies, and can be set to 
Responses for OpenAI itself or for a relay that accounts cached input on 
`/responses` more accurately than on `/chat/completions`. A Codex request then 
meets a Responses provider on both sides and passes through byte for byte, 
cache counters included.
+
 ### No API key: keep the sign-in the agent already has
 
 An agent signed in with a ChatGPT or Claude subscription has no API key to 
paste. Set the provider's **Authentication** to **the caller's own login** and 
it needs none: the base URL points at the vendor, and every request is 
forwarded with the credentials the agent itself sent, so it keeps its own 
sign-in. Leave **Models** empty and the provider accepts any model name.
diff --git a/README_zh.md b/README_zh.md
index 8b988de..bb4db03 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -259,6 +259,8 @@ export ANTHROPIC_AUTH_TOKEN="cg-..."
 
 同一个 base URL 会按 Agent 说的那套 API 应答:OpenAI 客户端走 `/chat/completions`,Anthropic 
客户端走 `/v1/messages`,Codex 走 `/responses` —— 它去掉了 chat completions 
这套线格式,只剩这一种,Gemini CLI 走 `/v1beta/models/<model>:generateContent` —— 它只会说 
Google 自己那套 API。Provider 用的是哪套 API 不必与之相同:Gateway 会在这四套之间双向转换,流式回复也一样,所以 Codex 
能跑在 DeepSeek、Kimi、Qwen 上,Claude Code 同样能跑在它们上面。如果 Provider 正好就说请求进来的那套 
API,就原样转发,一个字节都不改。唯一由 Gateway 自己应答的是 Anthropic 和 Gemini 客户端每轮之前问的 token 数:绑定的 
Provider 没有对应接口时,由 Gateway 估算。
 
+往上游发哪套接口由Provider自己定:**上游接口**默认按Provider类型推断,也可以设成Responses——OpenAI自己就服务这套,一部分中转站在`/responses`上算缓存也比在`/chat/completions`上准。这时Codex的请求两头都是Responses,原样透传,缓存计数一并保留。
+
 ### 没有 API Key:沿用 Agent 已有的登录
 
 用 ChatGPT 或 Claude 订阅登录的 Agent 根本没有 API Key 可填。把 Provider 
的**认证方式**设成**调用方自己的登录**,它就不需要 Key:base URL 指向厂商,每个请求都带着 Agent 自己发来的凭据转发上游,Agent 
继续用它已有的登录。**Models** 留空,这个 Provider 就接受任何模型名。
diff --git a/controllers/agent_provider.go b/controllers/agent_provider.go
index db46ec4..95d3cd9 100644
--- a/controllers/agent_provider.go
+++ b/controllers/agent_provider.go
@@ -129,7 +129,7 @@ func checkAgentProtocol(agentId string, mode string, 
providerIds []string) error
                if provider == nil {
                        continue
                }
-               if spoken := object.ProviderProtocol(provider); spoken != 
spokenByAgent {
+               if spoken := object.ProviderApiFamily(provider); spoken != 
spokenByAgent {
                        return fmt.Errorf("%s speaks the %s API, but provider 
%s speaks %s: bind a provider that speaks %s, or route this agent through the 
gateway",
                                agentId, spokenByAgent, id, spoken, 
spokenByAgent)
                }
@@ -157,7 +157,7 @@ func agentEndpoint(agentId string) (agentprovider.Endpoint, 
error) {
        }
 
        endpoint.Provider = provider.GetId()
-       endpoint.Protocol = object.ProviderProtocol(provider)
+       endpoint.Protocol = object.ProviderApiFamily(provider)
        if len(provider.Models) > 0 {
                endpoint.Model = provider.Models[0]
                endpoint.Models = provider.Models
diff --git a/controllers/proxy_record.go b/controllers/proxy_record.go
index 0774d2c..7c89eda 100644
--- a/controllers/proxy_record.go
+++ b/controllers/proxy_record.go
@@ -106,11 +106,12 @@ func (route *proxyRoute) recordUsage(tail []byte) {
        record.CacheWriteTokens = usage.CacheWriteTokens
        record.CacheReadTokens = higher(usage.CacheReadInputTokens, 
usage.CachedTokens)
 
-       // OpenAI counts cached input inside prompt_tokens, Anthropic reports it
-       // beside input_tokens. Taking it out where it is included leaves the 
four
-       // counters disjoint, so each can be priced at its own rate.
+       // OpenAI counts cached input inside its prompt total, Anthropic 
reports it
+       // beside one. Only the OpenAI spellings fill CachedTokens, so taking 
it out
+       // where that is set leaves the four counters disjoint, each priced at 
its
+       // own rate.
        record.PromptTokens = higher(usage.PromptTokens, usage.InputTokens)
-       if usage.PromptTokens > 0 && usage.CachedTokens > 0 {
+       if usage.CachedTokens > 0 {
                record.PromptTokens = 
higher(record.PromptTokens-usage.CachedTokens, 0)
        }
 
@@ -171,6 +172,13 @@ type llmUsage struct {
        CompletionTokensDetails struct {
                ReasoningTokens int `json:"reasoning_tokens"`
        } `json:"completion_tokens_details"`
+       // The Responses API spells the same two details.
+       InputTokensDetails struct {
+               CachedTokens int `json:"cached_tokens"`
+       } `json:"input_tokens_details"`
+       OutputTokensDetails struct {
+               ReasoningTokens int `json:"reasoning_tokens"`
+       } `json:"output_tokens_details"`
 
        // Flattened out of the detail objects above by normalize.
        CachedTokens    int `json:"-"`
@@ -179,8 +187,8 @@ type llmUsage struct {
 
 // normalize lifts the nested counters up onto one flat set of fields.
 func (usage *llmUsage) normalize() {
-       usage.CachedTokens = usage.PromptTokensDetails.CachedTokens
-       usage.ReasoningTokens = usage.CompletionTokensDetails.ReasoningTokens
+       usage.CachedTokens = higher(usage.PromptTokensDetails.CachedTokens, 
usage.InputTokensDetails.CachedTokens)
+       usage.ReasoningTokens = 
higher(usage.CompletionTokensDetails.ReasoningTokens, 
usage.OutputTokensDetails.ReasoningTokens)
        if usage.CacheWriteTokens == 0 {
                usage.CacheWriteTokens = usage.CacheCreation.Ephemeral5m + 
usage.CacheCreation.Ephemeral1h
        }
diff --git a/object/provider.go b/object/provider.go
index 27e1081..b870393 100644
--- a/object/provider.go
+++ b/object/provider.go
@@ -203,6 +203,7 @@ func UsesClientAuth(provider *Provider) bool {
 const (
        ProtocolOpenAi    = protocol.OpenAi
        ProtocolAnthropic = protocol.Anthropic
+       ProtocolResponses = protocol.Responses
 )
 
 // IsProviderTypeSupported reports whether the gateway can talk to the 
provider's
@@ -211,21 +212,34 @@ func IsProviderTypeSupported(provider *Provider) bool {
        return containsString(providerTypes, provider.Type)
 }
 
-// ProviderProtocol is the wire format a provider's upstream speaks. Everything
-// that is not Anthropic is reached with an OpenAI-formatted request.
+// ProviderProtocol is the wire format a provider's upstream is talked to in.
+// The provider names it when its upstream serves more than its type implies;
+// otherwise everything that is not Anthropic is sent an OpenAI request.
 func ProviderProtocol(provider *Provider) string {
+       if protocol.IsUpstream(provider.Protocol) {
+               return provider.Protocol
+       }
        if provider.Type == "anthropic" {
                return ProtocolAnthropic
        }
        return ProtocolOpenAi
 }
 
+// ProviderApiFamily is the family a provider's base URL belongs to, which is
+// what an agent configuration is written against: an agent pointed straight at
+// a Responses provider still writes an OpenAI base URL.
+func ProviderApiFamily(provider *Provider) string {
+       if spoken := ProviderProtocol(provider); spoken != ProtocolResponses {
+               return spoken
+       }
+       return ProtocolOpenAi
+}
+
 // ServesResponsesApi reports whether the provider's own upstream answers on 
the
-// OpenAI Responses API. Only OpenAI itself does; the OpenAI-compatible vendors
-// stop at chat completions, which the gateway translates for the clients that
-// speak nothing else.
+// OpenAI Responses API. The other vendors stop at chat completions, which the
+// gateway translates for the clients that speak nothing else.
 func ServesResponsesApi(provider *Provider) bool {
-       return provider.Type == "openai"
+       return provider.Type == "openai" || ProviderProtocol(provider) == 
ProtocolResponses
 }
 
 func containsString(values []string, value string) bool {
@@ -247,6 +261,9 @@ type Provider struct {
        DisplayName string `xorm:"varchar(100)" json:"displayName"`
        Type        string `xorm:"varchar(100)" json:"type"`
        BaseUrl     string `xorm:"varchar(255)" json:"baseUrl"`
+       // Protocol is the wire format the upstream is talked to in, when it is 
not
+       // the one Type implies.
+       Protocol string `xorm:"varchar(100)" json:"protocol"`
        // ApiKey holds base64 ciphertext, not the bare key, when
        // "apiKeyEncryptionKey" is set in app.conf, hence the wider column.
        ApiKey string `xorm:"varchar(1000)" json:"apiKey"`
@@ -358,6 +375,9 @@ func validateProvider(provider *Provider) error {
        if !containsString(providerTypes, provider.Type) {
                return fmt.Errorf("invalid provider type: %s", provider.Type)
        }
+       if provider.Protocol != "" && !protocol.IsUpstream(provider.Protocol) {
+               return fmt.Errorf("the gateway cannot speak the %s API to a 
provider", provider.Protocol)
+       }
        if !containsString(providerStatuses, provider.Status) {
                return fmt.Errorf("invalid provider status: %s", 
provider.Status)
        }
diff --git a/object/provider_probe.go b/object/provider_probe.go
index fd3bca1..0d23a58 100644
--- a/object/provider_probe.go
+++ b/object/provider_probe.go
@@ -447,7 +447,7 @@ func newProviderProbe(provider *Provider, trigger string) 
*ProviderProbe {
                Provider:      provider.GetId(),
                CreatedTime:   util.GetCurrentTime(),
                Trigger:       trigger,
-               Protocol:      ProviderProtocol(provider),
+               Protocol:      ProviderApiFamily(provider),
                VendorHeaders: []string{},
                Checks:        []ProbeCheck{},
        }
diff --git a/object/provider_probe_answer.go b/object/provider_probe_answer.go
index 54b6693..5a08186 100644
--- a/object/provider_probe_answer.go
+++ b/object/provider_probe_answer.go
@@ -60,7 +60,7 @@ type probeReply struct {
 // finding here: the engines below decide what an unanswered question means,
 // which is not the same for all of them.
 func probeAsk(provider *Provider, model string, probe *ProviderProbe, 
probeCase *ProbeCase) *probeReply {
-       upstream := ProviderProtocol(provider)
+       upstream := ProviderApiFamily(provider)
        answer, err := sendProbe(provider, probeAskBody(upstream, model, 
probeCase), false)
        if err != nil {
                return &probeReply{failure: auditutil.BoundString(err.Error(), 
probeErrorChars)}
diff --git a/object/provider_probe_checks.go b/object/provider_probe_checks.go
index cb0fb04..cd397ba 100644
--- a/object/provider_probe_checks.go
+++ b/object/provider_probe_checks.go
@@ -297,7 +297,7 @@ func probeToolCase(
        probeCase *ProbeCase,
        opening bool,
 ) (*probeBody, http.Header) {
-       upstream := ProviderProtocol(provider)
+       upstream := ProviderApiFamily(provider)
        answer, err := sendProbe(provider, probeToolBody(upstream, model, 
probeCase), false)
        if err != nil || !answer.ok() {
                reason := ""
@@ -626,7 +626,7 @@ func probeStreamBody(upstream string, model string, 
probeCase *ProbeCase) map[st
 }
 
 func probeStreamShape(provider *Provider, model string, probe *ProviderProbe, 
probeCase *ProbeCase) {
-       upstream := ProviderProtocol(provider)
+       upstream := ProviderApiFamily(provider)
        answer, err := sendProbe(provider, probeStreamBody(upstream, model, 
probeCase), true)
        if err != nil || !answer.ok() {
                probe.addCheck(checkOf(probeCase, LlmAuditUnknown))
@@ -861,7 +861,7 @@ func probeCachePairOf(
        probe *ProviderProbe,
        probeCase *ProbeCase,
 ) probeCachePair {
-       upstream := ProviderProtocol(provider)
+       upstream := ProviderApiFamily(provider)
        filler := probeFiller(probeCase.Params.FillerChars)
        prompt := firstString(probeCase.Params.Prompt, probeCachePrompt)
        limit := firstInt(probeCase.Params.MaxTokens, probeCacheMaxTokens)
diff --git a/object/provider_probe_run.go b/object/provider_probe_run.go
index 515bd4a..2ee2d53 100644
--- a/object/provider_probe_run.go
+++ b/object/provider_probe_run.go
@@ -72,7 +72,7 @@ func runProviderProbe(provider *Provider, trigger string) 
*ProviderProbe {
        }
        probe.Model = model
 
-       cases := probeCasesFor(ProviderProtocol(provider), model)
+       cases := probeCasesFor(ProviderApiFamily(provider), model)
        if len(cases) == 0 {
                probe.Error = "no test case is enabled for this upstream API"
                return probe
@@ -171,7 +171,7 @@ func sendProbe(provider *Provider, body map[string]any, 
stream bool) (*probeAnsw
 }
 
 func postProbe(provider *Provider, body map[string]any, stream bool) 
(*probeAnswer, error) {
-       upstream := ProviderProtocol(provider)
+       upstream := ProviderApiFamily(provider)
        endpoint := "/chat/completions"
        if upstream == ProtocolAnthropic {
                endpoint = "/v1/messages"
diff --git a/protocol/protocol.go b/protocol/protocol.go
index 9ad725e..2b049df 100644
--- a/protocol/protocol.go
+++ b/protocol/protocol.go
@@ -29,8 +29,8 @@ import (
 const (
        OpenAi    = "openai"
        Anthropic = "anthropic"
-       // Responses is the OpenAI Responses API, which recent Codex versions 
speak.
-       // No provider type serves it, so it is a client format only.
+       // Responses is the OpenAI Responses API, which recent Codex versions 
speak
+       // and which OpenAI itself and some relays serve upstream.
        Responses = "responses"
        // Gemini is Google's generateContent API, which the Gemini CLI speaks. 
Like
        // Responses it is a client format only: a Gemini provider is reached 
through
@@ -94,6 +94,12 @@ func Of(name string) Codec {
        return codecs[name]
 }
 
+// IsUpstream reports whether a provider can be talked to in this format.
+func IsUpstream(name string) bool {
+       _, ok := upstreams[name]
+       return ok
+}
+
 // UpstreamOf is the codec for a provider format.
 func UpstreamOf(name string) (Upstream, error) {
        upstream, ok := upstreams[name]
diff --git a/protocol/responses.go b/protocol/responses.go
index 4c76100..0520a5a 100644
--- a/protocol/responses.go
+++ b/protocol/responses.go
@@ -13,9 +13,8 @@
 // limitations under the License.
 
 // This file speaks the OpenAI Responses API, the only wire format recent Codex
-// versions know. It is a client format only: the gateway reads requests in it
-// and writes answers back in it, but no provider type serves it, so a request
-// that arrives here is always translated for whichever upstream answers it.
+// versions know. It works both ways: as a client format, and as the upstream 
of
+// a provider that answers on /responses rather than on chat completions.
 
 package protocol
 
@@ -36,6 +35,8 @@ func init() {
 
 func (responsesCodec) Name() string { return Responses }
 
+func (responsesCodec) Endpoint() string { return "/responses" }
+
 // ---------------------------------------------------------------------------
 // Request
 // ---------------------------------------------------------------------------
@@ -430,11 +431,7 @@ func (answer *responsesAnswer) response(status string, 
output []any) map[string]
                "status": status, "model": answer.model, "output": output,
        }
        if !answer.usage.IsZero() {
-               response["usage"] = map[string]any{
-                       "input_tokens":  answer.usage.InputTokens,
-                       "output_tokens": answer.usage.OutputTokens,
-                       "total_tokens":  answer.usage.InputTokens + 
answer.usage.OutputTokens,
-               }
+               response["usage"] = responsesUsageOf(answer.usage)
        }
        if answer.failure != nil {
                response["error"] = map[string]any{
@@ -619,3 +616,370 @@ func (writer *responsesWriter) Close() {
 func (responsesCodec) EncodeError(kind string, message string) []byte {
        return openAiCodec{}.EncodeError(kind, message)
 }
+
+// ---------------------------------------------------------------------------
+// Upstream
+// ---------------------------------------------------------------------------
+
+// responsesBody is the request written for an upstream serving this API. store
+// is false because the gateway never sends a previous_response_id, so a copy
+// kept upstream would never be read.
+type responsesBody struct {
+       Model             string          `json:"model"`
+       Stream            bool            `json:"stream,omitempty"`
+       Store             bool            `json:"store"`
+       Instructions      string          `json:"instructions,omitempty"`
+       Input             []any           `json:"input"`
+       Tools             []responsesTool `json:"tools,omitempty"`
+       ToolChoice        json.RawMessage `json:"tool_choice,omitempty"`
+       ParallelToolCalls *bool           `json:"parallel_tool_calls,omitempty"`
+       Temperature       *float64        `json:"temperature,omitempty"`
+       TopP              *float64        `json:"top_p,omitempty"`
+       MaxOutputTokens   *int            `json:"max_output_tokens,omitempty"`
+       Text              json.RawMessage `json:"text,omitempty"`
+       Reasoning         json.RawMessage `json:"reasoning,omitempty"`
+}
+
+func (responsesCodec) EncodeRequest(request *Request) ([]byte, error) {
+       body := responsesBody{
+               Model:             request.Model,
+               Stream:            request.Stream,
+               Instructions:      strings.Join(request.System, "\n\n"),
+               Input:             []any{},
+               ParallelToolCalls: request.ParallelToolCalls,
+               Temperature:       request.Temperature,
+               TopP:              request.TopP,
+               MaxOutputTokens:   request.MaxTokens,
+       }
+       for _, message := range request.Messages {
+               body.Input = append(body.Input, responsesInputOf(message)...)
+       }
+       if len(body.Input) == 0 {
+               return nil, errors.New("the request carries no message")
+       }
+
+       for _, tool := range request.Tools {
+               parameters := tool.Parameters
+               if len(parameters) == 0 {
+                       parameters = rawValue(map[string]any{"type": "object", 
"properties": map[string]any{}})
+               }
+               body.Tools = append(body.Tools, responsesTool{
+                       Type: "function", Name: tool.Name, Description: 
tool.Description, Parameters: parameters,
+               })
+       }
+       if choice := request.ToolChoice; choice != nil {
+               switch choice.Mode {
+               case ChoiceTool:
+                       body.ToolChoice = rawValue(map[string]any{"type": 
"function", "name": choice.Name})
+               case ChoiceAuto, ChoiceNone, ChoiceRequired:
+                       body.ToolChoice = rawString(choice.Mode)
+               }
+       }
+       if format := request.Format; format != nil && format.Kind != "" {
+               // This API keeps the name and the schema beside the type 
rather than
+               // one level down, as chat completions does.
+               written := map[string]any{"type": format.Kind}
+               if format.Kind == "json_schema" {
+                       written["name"] = emptyAs(format.Name, "response")
+                       written["schema"] = format.Schema
+                       if format.Strict != nil {
+                               written["strict"] = *format.Strict
+                       }
+               }
+               body.Text = rawValue(map[string]any{"format": written})
+       }
+       if effort := request.Reasoning.EffortOf(); effort != "" {
+               body.Reasoning = rawValue(map[string]any{"effort": effort})
+       }
+       return json.Marshal(body)
+}
+
+// responsesInputOf writes one canonical message out. A tool call and a tool
+// result are items of their own here rather than content of a message, so a
+// message carrying either turns into several.
+func responsesInputOf(message Message) []any {
+       items := []any{}
+       parts := []any{}
+       partType := "input_text"
+       if message.Role == RoleAssistant {
+               partType = "output_text"
+       }
+
+       flush := func() {
+               if len(parts) == 0 {
+                       return
+               }
+               items = append(items, map[string]any{
+                       "type": "message", "role": message.Role, "content": 
parts,
+               })
+               parts = []any{}
+       }
+
+       for _, content := range message.Content {
+               switch content.Kind {
+               case KindText:
+                       if content.Text == "" {
+                               continue
+                       }
+                       parts = append(parts, map[string]any{"type": partType, 
"text": content.Text})
+               case KindImage:
+                       if content.Image == nil {
+                               continue
+                       }
+                       parts = append(parts, map[string]any{"type": 
"input_image", "image_url": content.Image.Link()})
+               case KindToolUse:
+                       if content.ToolUse == nil {
+                               continue
+                       }
+                       flush()
+                       items = append(items, map[string]any{
+                               "type": "function_call", "call_id": 
content.ToolUse.Id,
+                               "name": content.ToolUse.Name, "arguments": 
emptyAsObject(content.ToolUse.Arguments),
+                       })
+               case KindToolResult:
+                       if content.ToolResult == nil {
+                               continue
+                       }
+                       flush()
+                       items = append(items, map[string]any{
+                               "type": "function_call_output", "call_id": 
content.ToolResult.Id,
+                               "output": content.ToolResult.Text,
+                       })
+               }
+               // A thinking block is left out: this API only takes back 
reasoning
+               // items of its own, which carry an id no other upstream signed.
+       }
+
+       flush()
+       return items
+}
+
+type responsesResult struct {
+       Id                string                `json:"id"`
+       Model             string                `json:"model"`
+       Status            string                `json:"status"`
+       Output            []responsesOutputItem `json:"output"`
+       Usage             *responsesUsage       `json:"usage"`
+       IncompleteDetails *struct {
+               Reason string `json:"reason"`
+       } `json:"incomplete_details"`
+       Error *responsesFailure `json:"error"`
+}
+
+type responsesFailure struct {
+       Code    string `json:"code"`
+       Message string `json:"message"`
+}
+
+func (failure *responsesFailure) canonical() *Failure {
+       if failure == nil || failure.Message == "" {
+               return nil
+       }
+       return &Failure{Kind: emptyAs(failure.Code, "server_error"), Message: 
failure.Message}
+}
+
+// responsesOutputItem is one item of the output list: the assistant message,
+// the reasoning, or one function call.
+type responsesOutputItem struct {
+       Type      string                `json:"type"`
+       Role      string                `json:"role"`
+       Content   []responsesOutputPart `json:"content"`
+       Summary   []responsesOutputPart `json:"summary"`
+       Id        string                `json:"id"`
+       CallId    string                `json:"call_id"`
+       Name      string                `json:"name"`
+       Arguments string                `json:"arguments"`
+}
+
+type responsesOutputPart struct {
+       Type string `json:"type"`
+       Text string `json:"text"`
+}
+
+type responsesUsage struct {
+       InputTokens        int `json:"input_tokens"`
+       OutputTokens       int `json:"output_tokens"`
+       TotalTokens        int `json:"total_tokens"`
+       InputTokensDetails *struct {
+               CachedTokens int `json:"cached_tokens"`
+       } `json:"input_tokens_details"`
+       OutputTokensDetails *struct {
+               ReasoningTokens int `json:"reasoning_tokens"`
+       } `json:"output_tokens_details"`
+}
+
+func (usage *responsesUsage) canonical() Usage {
+       canonical := Usage{InputTokens: usage.InputTokens, OutputTokens: 
usage.OutputTokens}
+       if usage.InputTokensDetails != nil {
+               canonical.CacheReadTokens = 
usage.InputTokensDetails.CachedTokens
+       }
+       if usage.OutputTokensDetails != nil {
+               canonical.ReasoningTokens = 
usage.OutputTokensDetails.ReasoningTokens
+       }
+       return canonical
+}
+
+// responsesUsageOf writes the counters back out. This format counts the cached
+// part inside the input total, which is where the canonical form keeps it too.
+func responsesUsageOf(usage Usage) map[string]any {
+       input := usage.InputTokens + usage.CacheWriteTokens
+       written := map[string]any{
+               "input_tokens":  input,
+               "output_tokens": usage.OutputTokens,
+               "total_tokens":  input + usage.OutputTokens,
+       }
+       if usage.CacheReadTokens > 0 {
+               written["input_tokens_details"] = 
map[string]any{"cached_tokens": usage.CacheReadTokens}
+       }
+       if usage.ReasoningTokens > 0 {
+               written["output_tokens_details"] = 
map[string]any{"reasoning_tokens": usage.ReasoningTokens}
+       }
+       return written
+}
+
+func (responsesCodec) DecodeResponse(raw []byte) (*Response, error) {
+       var body responsesResult
+       if err := json.Unmarshal(raw, &body); err != nil {
+               return nil, errors.New("the upstream answered with an 
unreadable body")
+       }
+
+       response := &Response{Id: body.Id, Model: body.Model, StopReason: 
StopEnd}
+       if body.Usage != nil {
+               response.Usage = body.Usage.canonical()
+       }
+       response.Failure = body.Error.canonical()
+
+       for _, item := range body.Output {
+               switch item.Type {
+               case "message":
+                       for _, part := range item.Content {
+                               if part.Text != "" {
+                                       response.Content = 
append(response.Content, Content{Kind: KindText, Text: part.Text})
+                               }
+                       }
+               case "reasoning":
+                       // A model that hides its reasoning sends only the 
summary.
+                       for _, part := range 
append(append([]responsesOutputPart{}, item.Content...), item.Summary...) {
+                               if part.Text != "" {
+                                       response.Content = 
append(response.Content, Content{Kind: KindThinking, Text: part.Text})
+                               }
+                       }
+               case "function_call":
+                       response.StopReason = StopToolUse
+                       response.Content = append(response.Content, 
Content{Kind: KindToolUse, ToolUse: &ToolUse{
+                               Id: emptyAs(item.CallId, item.Id), Name: 
item.Name, Arguments: emptyAsObject(item.Arguments),
+                       }})
+               }
+       }
+       if body.Status == "incomplete" && body.IncompleteDetails != nil &&
+               body.IncompleteDetails.Reason == "max_output_tokens" {
+               response.StopReason = StopMaxTokens
+       }
+       return response, nil
+}
+
+// responsesEvent is one event of this API's stream, which names itself in its
+// own payload.
+type responsesEvent struct {
+       Type        string               `json:"type"`
+       Delta       string               `json:"delta"`
+       OutputIndex int                  `json:"output_index"`
+       Item        *responsesOutputItem `json:"item"`
+       Response    *responsesResult     `json:"response"`
+       Error       *responsesFailure    `json:"error"`
+}
+
+func (responsesCodec) DecodeStream(reader io.Reader, fn func(Event) bool) 
error {
+       stop := ""
+       usage := Usage{}
+       model := ""
+       running := true
+       // A call's output index is what its argument deltas name it by, so it 
is
+       // the call's index too.
+       opened := map[int]bool{}
+
+       err := forEachEvent(reader, func(data []byte) bool {
+               var event responsesEvent
+               if err := json.Unmarshal(data, &event); err != nil {
+                       return true
+               }
+               if event.Response != nil && event.Response.Model != "" {
+                       model = event.Response.Model
+               }
+
+               switch event.Type {
+               case "response.output_text.delta":
+                       if event.Delta != "" {
+                               running = fn(Event{Kind: EventText, Text: 
event.Delta, Model: model})
+                       }
+
+               case "response.reasoning_summary_text.delta", 
"response.reasoning_text.delta":
+                       if event.Delta != "" {
+                               running = fn(Event{Kind: EventThinking, Text: 
event.Delta, Model: model})
+                       }
+
+               case "response.output_item.added":
+                       // A call's name and id arrive here once; only its 
arguments stream.
+                       if event.Item == nil || event.Item.Type != 
"function_call" {
+                               return true
+                       }
+                       opened[event.OutputIndex] = true
+                       stop = StopToolUse
+                       running = fn(Event{Kind: EventToolUse, Model: model, 
Tool: &ToolDelta{
+                               Index: event.OutputIndex,
+                               Id:    emptyAs(event.Item.CallId, 
event.Item.Id),
+                               Name:  event.Item.Name,
+                       }})
+
+               case "response.function_call_arguments.delta":
+                       if event.Delta == "" {
+                               return true
+                       }
+                       running = fn(Event{Kind: EventToolUse, Model: model, 
Tool: &ToolDelta{
+                               Index: event.OutputIndex, Arguments: 
event.Delta,
+                       }})
+
+               case "response.output_item.done":
+                       // An upstream that sent no delta reports the call only 
here.
+                       if event.Item == nil || event.Item.Type != 
"function_call" || opened[event.OutputIndex] {
+                               return true
+                       }
+                       opened[event.OutputIndex] = true
+                       stop = StopToolUse
+                       running = fn(Event{Kind: EventToolUse, Model: model, 
Tool: &ToolDelta{
+                               Index:     event.OutputIndex,
+                               Id:        emptyAs(event.Item.CallId, 
event.Item.Id),
+                               Name:      event.Item.Name,
+                               Arguments: event.Item.Arguments,
+                       }})
+
+               case "response.completed", "response.incomplete":
+                       if event.Response == nil {
+                               return true
+                       }
+                       if event.Response.Usage != nil {
+                               usage = event.Response.Usage.canonical()
+                       }
+                       if event.Response.IncompleteDetails != nil &&
+                               event.Response.IncompleteDetails.Reason == 
"max_output_tokens" {
+                               stop = StopMaxTokens
+                       }
+
+               case "response.failed", "error":
+                       failure := event.Error.canonical()
+                       if failure == nil && event.Response != nil {
+                               failure = event.Response.Error.canonical()
+                       }
+                       if failure == nil {
+                               failure = &Failure{Kind: "server_error", 
Message: "the upstream stream failed"}
+                       }
+                       running = fn(Event{Kind: EventFailure, Failure: 
failure})
+               }
+               return running
+       })
+
+       if running {
+               fn(Event{Kind: EventDone, StopReason: emptyAs(stop, StopEnd), 
Usage: &usage})
+       }
+       return err
+}
diff --git a/web/src/components/shared/brand-options.tsx 
b/web/src/components/shared/brand-options.tsx
index 906137e..3b94a29 100644
--- a/web/src/components/shared/brand-options.tsx
+++ b/web/src/components/shared/brand-options.tsx
@@ -54,6 +54,17 @@ export const protocolOptions: SelectOption[] = [
   vendorOption("anthropic", "Anthropic", "anthropic.com"),
 ];
 
+/** The Upstream API field of both provider forms, each API under the mark of
+ * the vendor that defined it. */
+export function wireApiOptions(fromType: string): SelectOption[] {
+  return [
+    brandOption("", fromType, <Settings2 className="size-4 opacity-50" />),
+    vendorOption("openai", "Chat Completions", "openai.com"),
+    vendorOption("responses", "Responses", "openai.com"),
+    vendorOption("anthropic", "Messages", "anthropic.com"),
+  ];
+}
+
 /** The Type field of both provider forms: two vendors and everything else. */
 export const providerTypeOptions: SelectOption[] = [
   ...protocolOptions,
diff --git a/web/src/lib/providers.ts b/web/src/lib/providers.ts
index 5cc96d7..69f88dc 100644
--- a/web/src/lib/providers.ts
+++ b/web/src/lib/providers.ts
@@ -20,18 +20,41 @@ export function providerIdOf(provider: Pick<Provider, 
"owner" | "name">) {
   return `${provider.owner}/${provider.name}`;
 }
 
-/** Mirrors object.ProviderProtocol on the server. */
+/** Mirrors object.ProviderApiFamily: the family a provider's base URL belongs 
to. */
 export function providerProtocol(type: string) {
   return type === "anthropic" ? "anthropic" : "openai";
 }
 
+/** The wire formats the gateway can talk to an upstream in. */
+export const wireApis = ["openai", "responses", "anthropic"] as const;
+
+/** Mirrors object.ProviderProtocol: the API the gateway posts to. */
+export function providerWireApi(provider: {type?: string; protocol?: string} | 
undefined) {
+  const named = provider?.protocol ?? "";
+  return wireApis.includes(named as (typeof wireApis)[number])
+    ? named
+    : providerProtocol(provider?.type ?? "");
+}
+
+/** The path one wire format is appended to the base URL as. */
+export function wireApiEndpoint(wireApi: string) {
+  switch (wireApi) {
+  case "anthropic":
+    return "/v1/messages";
+  case "responses":
+    return "/responses";
+  default:
+    return "/chat/completions";
+  }
+}
+
 /** Mirrors object.ProviderAuthProvider and object.ProviderAuthClient on the 
server. */
 export const authProvider = "provider";
 export const authClient = "client";
 
-/** Mirrors object.ServesResponsesApi: only OpenAI itself serves that API. */
-export function servesResponsesApi(provider: {type?: string} | undefined) {
-  return provider?.type === "openai";
+/** Mirrors object.ServesResponsesApi: OpenAI itself, and anything pointed at 
it. */
+export function servesResponsesApi(provider: {type?: string; protocol?: 
string} | undefined) {
+  return provider?.type === "openai" || providerWireApi(provider) === 
"responses";
 }
 
 /** Mirrors object.UsesClientAuth: whose credentials reach the upstream. */
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 8050e6b..bf0eb8d 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -1206,6 +1206,7 @@
     "Fetch models": "Fetch models",
     "Fetched {count} models": "Fetched {count} models",
     "Fill in from": "Fill in from a preset",
+    "From the type": "From the type",
     "Get a key from {vendor}": "Get a key from {vendor}",
     "Granted": "granted",
     "Granted path": "Granted path",
@@ -1275,6 +1276,8 @@
     "This link cannot be read": "This link cannot be read",
     "Type": "Type",
     "Unfinished draft": "Unfinished draft",
+    "Upstream API": "Upstream API",
+    "Upstream API hint": "Which API the gateway calls on this provider. Chat 
Completions suits nearly every vendor; Responses is what OpenAI and some relays 
serve as well, and it is the one a Codex client passes through untranslated, 
cache counters included.",
     "Usage": "Usage",
     "Usage hint": "Point any client that speaks this provider's API at Gateway 
with these variables.",
     "Used": "used",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 0f2238f..1b7af3b 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -1276,6 +1276,7 @@
     "Fetch models": "自动获取",
     "Fetched {count} models": "共获取到{count}个模型",
     "Fill in from": "从预设填入",
+    "From the type": "按类型推断",
     "Get a key from {vendor}": "去{vendor}获取密钥",
     "Granted": "总额",
     "Granted path": "总额字段路径",
@@ -1345,6 +1346,8 @@
     "This link cannot be read": "这个链接读不出来",
     "Type": "类型",
     "Unfinished draft": "有未完成的草稿",
+    "Upstream API": "上游接口",
+    "Upstream API hint": "网关调用该供应商时使用的接口。Chat 
Completions适用于几乎所有厂商;Responses是OpenAI和部分中转站同样提供的接口,Codex客户端走这条路时请求原样透传,缓存计数也一并保留。",
     "Usage": "使用方法",
     "Usage hint": "任何说这个供应商所用API的客户端,配上下面的变量就能走网关。",
     "Used": "已用",
diff --git a/web/src/pages/ProviderEditPage.tsx 
b/web/src/pages/ProviderEditPage.tsx
index e175e43..70bd297 100644
--- a/web/src/pages/ProviderEditPage.tsx
+++ b/web/src/pages/ProviderEditPage.tsx
@@ -30,7 +30,7 @@ import {CodeText, ResultScreen} from 
"@/components/shared/misc";
 import {NumberInput} from "@/components/shared/number-input";
 import {PageContainer, PageHeader, Section} from 
"@/components/shared/page-header";
 import {PasswordInput} from "@/components/shared/password-input";
-import {baseUrlOptions, providerTypeOptions} from 
"@/components/shared/brand-options";
+import {baseUrlOptions, providerTypeOptions, wireApiOptions} from 
"@/components/shared/brand-options";
 import {SearchSelect, SimpleSelect} from "@/components/shared/simple-select";
 import {Button} from "@/components/ui/button";
 import {Input} from "@/components/ui/input";
@@ -39,9 +39,11 @@ import {
   authClient,
   baseUrlPlaceholder,
   providerProtocol,
+  providerWireApi,
   gatewayBaseUrl,
   localShell,
   usesClientAuth,
+  wireApiEndpoint,
 } from "@/lib/providers";
 import type {Provider, QuotaConfig} from "@/types";
 
@@ -97,11 +99,13 @@ function buildAnthropicUrl(baseUrl: string, endpoint: 
string) {
   return url.toString();
 }
 
-/** The upstream URL requests to a provider of this type end up at. */
-function buildUpstreamUrl(baseUrl: string, type: string) {
-  return providerProtocol(type) === "anthropic"
-    ? buildAnthropicUrl(baseUrl, "/v1/messages")
-    : buildOpenAiUrl(baseUrl, "/chat/completions");
+/** The upstream URL requests to this provider end up at. */
+function buildUpstreamUrl(provider: Provider) {
+  const wireApi = providerWireApi(provider);
+  const endpoint = wireApiEndpoint(wireApi);
+  return wireApi === "anthropic"
+    ? buildAnthropicUrl(provider.baseUrl, endpoint)
+    : buildOpenAiUrl(provider.baseUrl, endpoint);
 }
 
 export default function ProviderEditPage() {
@@ -164,7 +168,7 @@ export default function ProviderEditPage() {
     );
   }
 
-  const upstreamUrl = buildUpstreamUrl(provider.baseUrl, provider.type);
+  const upstreamUrl = buildUpstreamUrl(provider);
 
   return (
     <PageContainer>
@@ -217,6 +221,13 @@ export default function ProviderEditPage() {
             onChange={value => setField("baseUrl", value)}
           />
         </Field>
+        <Field label={i18next.t("provider:Upstream API")} 
hint={i18next.t("provider:Upstream API hint")}>
+          <SimpleSelect
+            value={provider.protocol ?? ""}
+            onChange={value => setField("protocol", value)}
+            options={wireApiOptions(i18next.t("provider:From the type"))}
+          />
+        </Field>
         <Field
           label={i18next.t("provider:Authentication")}
           hint={usesClientAuth(provider) ? i18next.t("provider:Client auth 
hint") : undefined}
diff --git a/web/src/pages/ProviderListPage.tsx 
b/web/src/pages/ProviderListPage.tsx
index 2d6440f..419d824 100644
--- a/web/src/pages/ProviderListPage.tsx
+++ b/web/src/pages/ProviderListPage.tsx
@@ -39,7 +39,7 @@ import {ErrorState} from "@/components/shared/empty-state";
 import {Field, FormDialog} from "@/components/shared/form-dialog";
 import {PageContainer, PageHeader} from "@/components/shared/page-header";
 import {PasswordInput} from "@/components/shared/password-input";
-import {baseUrlOptions, providerTypeOptions} from 
"@/components/shared/brand-options";
+import {baseUrlOptions, providerTypeOptions, wireApiOptions} from 
"@/components/shared/brand-options";
 import {SearchSelect, SimpleSelect} from "@/components/shared/simple-select";
 import {MessageAlert} from "@/components/ui/alert";
 import {Badge} from "@/components/ui/badge";
@@ -685,6 +685,13 @@ export default function ProviderListPage({account}: 
{account: Account}) {
                   />
                 </Field>
               )}
+              <Field label={i18next.t("provider:Upstream API")} 
hint={i18next.t("provider:Upstream API hint")}>
+                <SimpleSelect
+                  value={form.protocol ?? ""}
+                  onChange={value => setFormField("protocol", value)}
+                  options={wireApiOptions(i18next.t("provider:From the type"))}
+                />
+              </Field>
               <Field
                 label={i18next.t("provider:Authentication")}
                 hint={usesClientAuth(form) ? i18next.t("provider:Client auth 
hint") : undefined}
diff --git a/web/src/types.ts b/web/src/types.ts
index bb2aae1..8514594 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -61,6 +61,8 @@ export interface Provider {
   models: string[];
   priority: number;
   baseUrl: string;
+  /** The wire format the upstream is talked to in, empty for the one the type 
implies. */
+  protocol?: string;
   apiKey: string;
   authMode: string;
   /** A site the vendor's icon is taken from, or an image URL. Empty derives it


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to