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
The following commit(s) were added to refs/heads/master by this push:
new cccee26 feat(proxy): serve the Responses API Codex now speaks
cccee26 is described below
commit cccee26c81b831902a4926dc2910d815b8c6895f
Author: Yang Luo <[email protected]>
AuthorDate: Sun Aug 23 16:59:31 2026 +0800
feat(proxy): serve the Responses API Codex now speaks
---
README.md | 4 +-
README_zh.md | 4 +-
agentprovider/codex.go | 30 +-
agentprovider/provider.go | 4 +
controllers/agent_provider.go | 2 +
controllers/proxy.go | 61 +++-
controllers/proxy_test.go | 4 +-
controllers/responses.go | 792 ++++++++++++++++++++++++++++++++++++++++++
object/provider.go | 8 +
routers/router.go | 6 +-
web/src/lib/agents.ts | 4 +-
11 files changed, 895 insertions(+), 24 deletions(-)
diff --git a/README.md b/README.md
index 28e7e9e..42d99c8 100644
--- a/README.md
+++ b/README.md
@@ -88,13 +88,15 @@ export ANTHROPIC_AUTH_TOKEN="cg-..."
The token is Gateway's own relay token, not a vendor key: the agent refuses to
start without something in that variable, and Gateway authenticates upstream
with the provider's key instead. The snippet on the page already has the real
value filled in.
+One base URL answers whichever API the agent speaks: `/chat/completions` for
an OpenAI client, `/v1/messages` for an Anthropic one, and `/responses` for
Codex, which speaks nothing else since it dropped the chat completions wire
format. Gateway translates that last one to the chat completions its providers
answer, which is what lets Codex run on DeepSeek, Kimi, Qwen and the rest.
+
### 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.
The environment snippet for such a provider sets the base URL and nothing else
— a token there would replace the sign-in the agent already has. Gateway
records and routes the traffic exactly as it does for a provider with a key; it
just never sees one.
-Codex is the exception: its ChatGPT sign-in talks to a different API than the
chat completions Gateway relays, so a Codex CLI still needs a provider with an
API key.
+Codex is the exception: its ChatGPT sign-in talks to an endpoint no provider
stands in for, so a Codex CLI still needs a provider with an API key.
### Stopping, upgrading, removing
diff --git a/README_zh.md b/README_zh.md
index 957d384..6dd64aa 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -86,13 +86,15 @@ export ANTHROPIC_AUTH_TOKEN="cg-..."
这个 token 是 Gateway 自己的中继令牌,不是厂商的 Key:Agent 没有它就拒绝启动,而 Gateway 会用 Provider 自己的
Key 去认证上游。页面上的片段里已经填好了真实的值。
+同一个 base URL 会按 Agent 说的那套 API 应答:OpenAI 客户端走 `/chat/completions`,Anthropic
客户端走 `/v1/messages`,Codex 走 `/responses` —— 它去掉了 chat completions
这套线格式,只剩这一种。Gateway 会把它翻译成 Provider 应答的 chat completions,所以 Codex 也能跑在
DeepSeek、Kimi、Qwen 这些模型上。
+
### 没有 API Key:沿用 Agent 已有的登录
用 ChatGPT 或 Claude 订阅登录的 Agent 根本没有 API Key 可填。把 Provider
的**认证方式**设成**调用方自己的登录**,它就不需要 Key:base URL 指向厂商,每个请求都带着 Agent 自己发来的凭据转发上游,Agent
继续用它已有的登录。**Models** 留空,这个 Provider 就接受任何模型名。
这种 Provider 的环境变量片段只设 base URL,不设别的 —— 在这里再设一个 token 会覆盖 Agent 已有的登录。记录和路由和有
Key 的 Provider 完全一样,只是 Gateway 从头到尾没见过任何 Key。
-Codex 是例外:它的 ChatGPT 登录走的是另一套 API,不是 Gateway 转发的 chat completions,所以 Codex CLI
仍然需要一个带 API Key 的 Provider。
+Codex 是例外:它的 ChatGPT 登录走的是没有任何 Provider 能替代的端点,所以 Codex CLI 仍然需要一个带 API Key 的
Provider。
### 停止、升级、卸载
diff --git a/agentprovider/codex.go b/agentprovider/codex.go
index 71bb186..cec8a7d 100644
--- a/agentprovider/codex.go
+++ b/agentprovider/codex.go
@@ -43,10 +43,15 @@ const (
var codexProviderPath = []string{"model_providers", codexProviderName}
// errCodexNoKey rejects a provider that forwards the caller's own credentials:
-// Codex reads its key from auth.json, and its own ChatGPT sign-in speaks a
-// different API than the chat completions this provider entry points at.
+// Codex reads its key from auth.json, so there is nothing for this provider
+// entry to forward.
var errCodexNoKey = errors.New("Codex needs an API key, so it cannot use a
provider that forwards the credentials of the caller")
+// errCodexResponsesApi rejects writing an upstream Codex cannot reach. Codex
+// dropped the chat completions wire format, so a provider that serves only
that
+// is reachable through the gateway, which translates, but not directly.
+var errCodexResponsesApi = errors.New("Codex only speaks the OpenAI Responses
API, which this provider does not serve, so switch this agent to gateway mode")
+
type codexWriter struct {
id string
}
@@ -65,8 +70,8 @@ func (w codexWriter) AgentId() string { return w.id }
func (codexWriter) Protocol() string { return "openai" }
func (w codexWriter) Plan(target Target, endpoint Endpoint) ([]File, error) {
- if endpoint.ApiKey == "" {
- return nil, errCodexNoKey
+ if err := w.check(endpoint); err != nil {
+ return nil, err
}
home, err := agentmonitor.ResolveCodexHome(target.Path, target.Owner)
@@ -91,8 +96,8 @@ func (w codexWriter) Plan(target Target, endpoint Endpoint)
([]File, error) {
}
func (w codexWriter) Apply(target Target, endpoint Endpoint)
(map[string]string, error) {
- if endpoint.ApiKey == "" {
- return nil, errCodexNoKey
+ if err := w.check(endpoint); err != nil {
+ return nil, err
}
configPath, authPath, err := w.paths(target)
@@ -226,6 +231,17 @@ func (w codexWriter) Current(target Target) (string,
error) {
return selected, nil
}
+// check reports why Codex cannot be pointed at endpoint.
+func (codexWriter) check(endpoint Endpoint) error {
+ if endpoint.ApiKey == "" {
+ return errCodexNoKey
+ }
+ if !endpoint.ServesResponsesApi {
+ return errCodexResponsesApi
+ }
+ return nil
+}
+
// providerTable is the [model_providers.casbin-gateway] block. The key lives
in
// auth.json under env_key, so config.toml can be read without exposing it.
func (codexWriter) providerTable(endpoint Endpoint) string {
@@ -234,7 +250,7 @@ func (codexWriter) providerTable(endpoint Endpoint) string {
map[string]string{
"name": "Casbin Gateway",
"base_url": endpoint.BaseUrl,
- "wire_api": "chat",
+ "wire_api": "responses",
"env_key": codexAuthKey,
})
}
diff --git a/agentprovider/provider.go b/agentprovider/provider.go
index dc06524..9859c01 100644
--- a/agentprovider/provider.go
+++ b/agentprovider/provider.go
@@ -53,6 +53,10 @@ type Endpoint struct {
ApiKey string `json:"apiKey"`
Model string `json:"model"`
Mode string `json:"mode"`
+ // ServesResponsesApi reports whether BaseUrl answers on the OpenAI
Responses
+ // API, which is all Codex speaks. The gateway always does, since it
+ // translates; a provider's own upstream usually stops at chat
completions.
+ ServesResponsesApi bool `json:"servesResponsesApi"`
}
// File is one configuration file a switch writes, with the section it will
diff --git a/controllers/agent_provider.go b/controllers/agent_provider.go
index 09155af..87aa0c9 100644
--- a/controllers/agent_provider.go
+++ b/controllers/agent_provider.go
@@ -128,10 +128,12 @@ func agentEndpoint(agentId string)
(agentprovider.Endpoint, error) {
if endpoint.Mode == object.ModeDirect {
endpoint.BaseUrl = provider.BaseUrl
endpoint.ApiKey = provider.ApiKey
+ endpoint.ServesResponsesApi =
object.ServesResponsesApi(provider)
return endpoint, nil
}
endpoint.BaseUrl = gatewayAgentUrl(agentId)
+ endpoint.ServesResponsesApi = true
// A client-auth provider forwards whatever the agent sends, so it must
keep
// sending its own credentials: a placeholder token written into the
agent's
// configuration would replace the sign-in it already has.
diff --git a/controllers/proxy.go b/controllers/proxy.go
index 157612c..0050dcc 100644
--- a/controllers/proxy.go
+++ b/controllers/proxy.go
@@ -62,12 +62,21 @@ var hopByHopHeaders = []string{
type proxyTarget struct {
protocol string
endpoint string
+ // clientEndpoint names the API the client called when it is not the
one the
+ // upstream serves, which means both bodies are translated on the way.
+ clientEndpoint string
}
+// responsesEndpoint is the Responses API front end. Recent Codex versions
speak
+// nothing else, while almost every provider behind the gateway serves chat
+// completions, so requests and responses are translated in both directions.
+const responsesEndpoint = "/responses"
+
var (
- openAiChat = proxyTarget{object.ProtocolOpenAi,
"/chat/completions"}
- anthropicMessages = proxyTarget{object.ProtocolAnthropic,
"/v1/messages"}
- anthropicCountTokens = proxyTarget{object.ProtocolAnthropic,
"/v1/messages/count_tokens"}
+ openAiChat = proxyTarget{protocol: object.ProtocolOpenAi,
endpoint: "/chat/completions"}
+ openAiResponses = proxyTarget{protocol: object.ProtocolOpenAi,
endpoint: "/chat/completions", clientEndpoint: responsesEndpoint}
+ anthropicMessages = proxyTarget{protocol: object.ProtocolAnthropic,
endpoint: "/v1/messages"}
+ anthropicCountTokens = proxyTarget{protocol: object.ProtocolAnthropic,
endpoint: "/v1/messages/count_tokens"}
)
// proxyRoute is one client request being relayed. Everything except model and
@@ -141,6 +150,13 @@ func (c *ApiController) ChatCompletions() {
c.proxyByModel(openAiChat)
}
+// Responses is the OpenAI Responses API entry point. The request is translated
+// to chat completions on the way out and the answer back on the way in, so an
+// upstream that never served the Responses API can still answer it.
+func (c *ApiController) Responses() {
+ c.proxyByModel(openAiResponses)
+}
+
// Messages is the Anthropic-compatible counterpart of ChatCompletions, for the
// agents that speak that API rather than OpenAI's.
func (c *ApiController) Messages() {
@@ -160,6 +176,12 @@ func (c *ApiController) AgentChatCompletions() {
c.proxyByAgent(openAiChat)
}
+// AgentResponses is the per-agent entry point of the Responses API, which is
+// the one Codex reaches the gateway on.
+func (c *ApiController) AgentResponses() {
+ c.proxyByAgent(openAiResponses)
+}
+
// AgentMessages is the per-agent entry point for Anthropic clients. One base
URL
// serves both APIs: an OpenAI client appends /chat/completions to it, while an
// Anthropic one appends /v1/messages.
@@ -250,11 +272,21 @@ func (c *ApiController) readProxyRoute(target
proxyTarget) (*proxyRoute, bool) {
return nil, false
}
- route := &proxyRoute{target: target, body: rawBody, model:
fields.Model, stream: fields.Stream, start: time.Now()}
+ body := rawBody
+ if target.clientEndpoint == responsesEndpoint {
+ converted, err := responsesToChat(rawBody)
+ if err != nil {
+ c.writeProxyError(target.protocol,
http.StatusBadRequest, "invalid_request_error", err.Error())
+ return nil, false
+ }
+ body = converted
+ }
+
+ route := &proxyRoute{target: target, body: body, model: fields.Model,
stream: fields.Stream, start: time.Now()}
if object.IsLlmRecording() {
route.record = &object.LlmRecord{
Protocol: target.protocol,
- Endpoint: target.endpoint,
+ Endpoint: emptyAsValue(target.clientEndpoint,
target.endpoint),
Model: fields.Model,
ClientIp: util.GetClientIp(c.Ctx.Request),
Stream: fields.Stream,
@@ -381,12 +413,12 @@ func (c *ApiController) forwardToProvider(provider
*object.Provider, route *prox
route.recordOutcome(upstreamResp.StatusCode, "")
if route.record == nil {
- c.relayResponse(upstreamResp, body, route.stream &&
isEventStream(upstreamResp))
+ c.relayResponse(route, upstreamResp, body, route.stream &&
isEventStream(upstreamResp))
return 0, "", true
}
tap := &usageTap{reader: body}
- c.relayResponse(upstreamResp, tap, route.stream &&
isEventStream(upstreamResp))
+ c.relayResponse(route, upstreamResp, tap, route.stream &&
isEventStream(upstreamResp))
route.recordUsage(tap.tail)
return 0, "", true
}
@@ -483,8 +515,19 @@ func (c *ApiController) copyAnthropicHeaders(dst
http.Header) {
// relayResponse copies the upstream status code, headers and body to the
client
// without any transformation (pass-through mode). When flush is true, every
// chunk is written out as soon as it arrives so that SSE clients receive the
-// events in real time.
-func (c *ApiController) relayResponse(upstreamResp *http.Response, body
io.Reader, flush bool) {
+// events in real time. A client that called an endpoint the upstream does not
+// serve gets the answer translated back into the API it asked in; an upstream
+// error is passed through either way, since both APIs report errors alike.
+func (c *ApiController) relayResponse(route *proxyRoute, upstreamResp
*http.Response, body io.Reader, flush bool) {
+ if route.target.clientEndpoint == responsesEndpoint &&
upstreamResp.StatusCode == http.StatusOK {
+ if flush {
+ c.relayResponsesStream(route, body)
+ } else {
+ c.relayResponsesBody(route, body)
+ }
+ return
+ }
+
copyResponseHeaders(c.Ctx.ResponseWriter.Header(), upstreamResp.Header)
if flush {
c.Ctx.ResponseWriter.Header().Set("Cache-Control", "no-cache")
diff --git a/controllers/proxy_test.go b/controllers/proxy_test.go
index abcf92f..b12d614 100644
--- a/controllers/proxy_test.go
+++ b/controllers/proxy_test.go
@@ -129,7 +129,7 @@ func TestRelayResponse(t *testing.T) {
"X-Ratelimit-Remaining": {"0"},
},
}
- c.relayResponse(upstreamResp,
strings.NewReader(`{"error":{"message":"slow down"}}`), false)
+ c.relayResponse(&proxyRoute{target: openAiChat}, upstreamResp,
strings.NewReader(`{"error":{"message":"slow down"}}`), false)
if recorder.Code != 429 {
t.Errorf("status code = %d, expected 429", recorder.Code)
@@ -158,7 +158,7 @@ func TestRelayResponseStream(t *testing.T) {
"Connection": {"keep-alive"},
},
}
- c.relayResponse(upstreamResp, strings.NewReader("data: a\n\ndata:
[DONE]\n\n"), true)
+ c.relayResponse(&proxyRoute{target: openAiChat}, upstreamResp,
strings.NewReader("data: a\n\ndata: [DONE]\n\n"), true)
if header := recorder.Header().Get("Content-Type"); header !=
"text/event-stream" {
t.Errorf("Content-Type = %s, expected text/event-stream",
header)
diff --git a/controllers/responses.go b/controllers/responses.go
new file mode 100644
index 0000000..0e65aa2
--- /dev/null
+++ b/controllers/responses.go
@@ -0,0 +1,792 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// This file translates between the OpenAI Responses API, the only wire format
+// recent Codex versions speak, and the chat completions API, which is what
+// nearly every provider reachable through the gateway serves.
+
+package controllers
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/beego/beego"
+)
+
+// ---------------------------------------------------------------------------
+// Request: Responses -> chat completions
+// ---------------------------------------------------------------------------
+
+// responsesRequest is the part of a Responses body that has a chat completions
+// counterpart. The rest (store, include, reasoning, previous_response_id) is
+// dropped: an upstream serving chat completions has nothing to do with it.
+type responsesRequest struct {
+ Model string `json:"model"`
+ Stream bool `json:"stream"`
+ Instructions string `json:"instructions"`
+ Input json.RawMessage `json:"input"`
+ Tools []responsesTool `json:"tools"`
+ ToolChoice json.RawMessage `json:"tool_choice"`
+ ParallelToolCalls *bool `json:"parallel_tool_calls"`
+ Temperature *float64 `json:"temperature"`
+ TopP *float64 `json:"top_p"`
+ MaxOutputTokens *int `json:"max_output_tokens"`
+ Text *struct {
+ Format json.RawMessage `json:"format"`
+ } `json:"text"`
+}
+
+type responsesTool struct {
+ Type string `json:"type"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Parameters json.RawMessage `json:"parameters"`
+}
+
+// responsesItem is one element of the input array. Its fields are a union over
+// the item types, which is how the API itself is shaped.
+type responsesItem struct {
+ Type string `json:"type"`
+ Role string `json:"role"`
+ Content json.RawMessage `json:"content"`
+ Name string `json:"name"`
+ Arguments string `json:"arguments"`
+ CallId string `json:"call_id"`
+ Output json.RawMessage `json:"output"`
+}
+
+type responsesPart struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+ ImageUrl string `json:"image_url"`
+}
+
+type chatRequest struct {
+ Model string `json:"model"`
+ Messages []chatMessage `json:"messages"`
+ Stream bool `json:"stream,omitempty"`
+ StreamOptions *chatStreamOpts `json:"stream_options,omitempty"`
+ Tools []chatTool `json:"tools,omitempty"`
+ ToolChoice any `json:"tool_choice,omitempty"`
+ ParallelToolCalls *bool `json:"parallel_tool_calls,omitempty"`
+ Temperature *float64 `json:"temperature,omitempty"`
+ TopP *float64 `json:"top_p,omitempty"`
+ MaxTokens *int `json:"max_tokens,omitempty"`
+ ResponseFormat any `json:"response_format,omitempty"`
+}
+
+type chatStreamOpts struct {
+ IncludeUsage bool `json:"include_usage"`
+}
+
+type chatMessage struct {
+ Role string `json:"role"`
+ Content any `json:"content,omitempty"`
+ ToolCalls []chatToolCall `json:"tool_calls,omitempty"`
+ ToolCallId string `json:"tool_call_id,omitempty"`
+}
+
+type chatToolCall struct {
+ Id string `json:"id"`
+ Type string `json:"type"`
+ Function chatToolFunction `json:"function"`
+}
+
+type chatToolFunction struct {
+ Name string `json:"name"`
+ Arguments string `json:"arguments"`
+}
+
+type chatTool struct {
+ Type string `json:"type"`
+ Function chatToolDef `json:"function"`
+}
+
+type chatToolDef struct {
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ Parameters json.RawMessage `json:"parameters,omitempty"`
+}
+
+// responsesToChat rewrites a Responses request as the chat completions request
+// that asks the upstream for the same thing.
+func responsesToChat(raw []byte) ([]byte, error) {
+ var request responsesRequest
+ if err := json.Unmarshal(raw, &request); err != nil {
+ return nil, errors.New("invalid request body")
+ }
+
+ items, err := responsesInputItems(request.Input)
+ if err != nil {
+ return nil, errors.New("invalid input")
+ }
+
+ messages := []chatMessage{}
+ if request.Instructions != "" {
+ messages = append(messages, chatMessage{Role: "system",
Content: request.Instructions})
+ }
+ for _, item := range items {
+ messages = appendResponsesItem(messages, item)
+ }
+ if len(messages) == 0 {
+ return nil, errors.New("input is required")
+ }
+
+ chat := chatRequest{
+ Model: request.Model,
+ Messages: messages,
+ Stream: request.Stream,
+ ToolChoice: responsesToolChoice(request.ToolChoice),
+ ParallelToolCalls: request.ParallelToolCalls,
+ Temperature: request.Temperature,
+ TopP: request.TopP,
+ MaxTokens: request.MaxOutputTokens,
+ }
+ if request.Stream {
+ // The token counts only reach a streaming client when asked
for.
+ chat.StreamOptions = &chatStreamOpts{IncludeUsage: true}
+ }
+ for _, tool := range request.Tools {
+ // Only plain function tools cross over: the hosted ones
(web_search,
+ // local_shell) have no chat completions form to send.
+ if tool.Type != "function" || tool.Name == "" {
+ continue
+ }
+ chat.Tools = append(chat.Tools, chatTool{
+ Type: "function",
+ Function: chatToolDef{Name: tool.Name, Description:
tool.Description, Parameters: tool.Parameters},
+ })
+ }
+ if request.Text != nil {
+ chat.ResponseFormat = responsesFormat(request.Text.Format)
+ }
+ return json.Marshal(chat)
+}
+
+// responsesInputItems reads the input field, which is either a bare prompt or
+// the conversation so far.
+func responsesInputItems(raw json.RawMessage) ([]responsesItem, error) {
+ if len(raw) == 0 {
+ return nil, nil
+ }
+
+ var prompt string
+ if err := json.Unmarshal(raw, &prompt); err == nil {
+ content, _ := json.Marshal(prompt)
+ return []responsesItem{{Type: "message", Role: "user", Content:
content}}, nil
+ }
+
+ var items []responsesItem
+ if err := json.Unmarshal(raw, &items); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
+func appendResponsesItem(messages []chatMessage, item responsesItem)
[]chatMessage {
+ switch {
+ case item.Type == "function_call":
+ call := chatToolCall{
+ Id: item.CallId,
+ Type: "function",
+ Function: chatToolFunction{Name: item.Name, Arguments:
emptyAsObject(item.Arguments)},
+ }
+ // Calls the model made in one turn belong to one assistant
message,
+ // which is how they were sent to the client in the first place.
+ if last := len(messages) - 1; last >= 0 &&
+ messages[last].Role == "assistant" &&
messages[last].Content == nil {
+ messages[last].ToolCalls =
append(messages[last].ToolCalls, call)
+ return messages
+ }
+ return append(messages, chatMessage{Role: "assistant",
ToolCalls: []chatToolCall{call}})
+
+ case item.Type == "function_call_output":
+ return append(messages, chatMessage{
+ Role: "tool",
+ ToolCallId: item.CallId,
+ Content: responsesOutputText(item.Output),
+ })
+
+ case item.Type == "message" || (item.Type == "" && item.Role != ""):
+ role := item.Role
+ if role == "developer" {
+ role = "system"
+ }
+ return append(messages, chatMessage{Role: role, Content:
responsesContent(item.Content)})
+ }
+
+ // Reasoning and the hosted tool calls have no chat completions form,
and
+ // replaying them would only confuse the upstream.
+ return messages
+}
+
+// responsesContent flattens message content. Text stays a plain string, which
+// every OpenAI-compatible upstream accepts; an image forces the array form.
+func responsesContent(raw json.RawMessage) any {
+ if len(raw) == 0 {
+ return ""
+ }
+
+ var text string
+ if err := json.Unmarshal(raw, &text); err == nil {
+ return text
+ }
+
+ var parts []responsesPart
+ if err := json.Unmarshal(raw, &parts); err != nil {
+ return ""
+ }
+
+ chatParts := []any{}
+ texts := []string{}
+ hasImage := false
+ for _, part := range parts {
+ if part.Type == "input_image" || part.Type == "image_url" {
+ if part.ImageUrl == "" {
+ continue
+ }
+ hasImage = true
+ chatParts = append(chatParts, map[string]any{
+ "type": "image_url",
+ "image_url": map[string]any{"url":
part.ImageUrl},
+ })
+ continue
+ }
+ if part.Text != "" {
+ texts = append(texts, part.Text)
+ chatParts = append(chatParts, map[string]any{"type":
"text", "text": part.Text})
+ }
+ }
+ if hasImage {
+ return chatParts
+ }
+ return strings.Join(texts, "")
+}
+
+// responsesOutputText is the text of a function_call_output, which clients
send
+// either as a string or as the object their tool runner produced.
+func responsesOutputText(raw json.RawMessage) string {
+ if len(raw) == 0 {
+ return ""
+ }
+
+ var text string
+ if err := json.Unmarshal(raw, &text); err == nil {
+ return text
+ }
+
+ var object struct {
+ Content json.RawMessage `json:"content"`
+ Output string `json:"output"`
+ }
+ if err := json.Unmarshal(raw, &object); err == nil {
+ if value, ok := responsesContent(object.Content).(string); ok
&& value != "" {
+ return value
+ }
+ if object.Output != "" {
+ return object.Output
+ }
+ }
+ return string(raw)
+}
+
+func responsesToolChoice(raw json.RawMessage) any {
+ if len(raw) == 0 {
+ return nil
+ }
+
+ var name string
+ if err := json.Unmarshal(raw, &name); err == nil {
+ return name
+ }
+
+ var choice struct {
+ Type string `json:"type"`
+ Name string `json:"name"`
+ }
+ if err := json.Unmarshal(raw, &choice); err == nil && choice.Type ==
"function" && choice.Name != "" {
+ return map[string]any{"type": "function", "function":
map[string]any{"name": choice.Name}}
+ }
+ return nil
+}
+
+// responsesFormat maps text.format onto response_format: the same schema, one
+// level deeper.
+func responsesFormat(raw json.RawMessage) any {
+ if len(raw) == 0 {
+ return nil
+ }
+
+ var format struct {
+ Type string `json:"type"`
+ Name string `json:"name"`
+ Schema json.RawMessage `json:"schema"`
+ Strict *bool `json:"strict"`
+ }
+ if err := json.Unmarshal(raw, &format); err != nil {
+ return nil
+ }
+ switch format.Type {
+ case "json_object":
+ return map[string]any{"type": "json_object"}
+ case "json_schema":
+ schema := map[string]any{"name": emptyAsValue(format.Name,
"response"), "schema": format.Schema}
+ if format.Strict != nil {
+ schema["strict"] = *format.Strict
+ }
+ return map[string]any{"type": "json_schema", "json_schema":
schema}
+ }
+ return nil
+}
+
+func emptyAsObject(arguments string) string {
+ if strings.TrimSpace(arguments) == "" {
+ return "{}"
+ }
+ return arguments
+}
+
+func emptyAsValue(value string, fallback string) string {
+ if value == "" {
+ return fallback
+ }
+ return value
+}
+
+// ---------------------------------------------------------------------------
+// Response: chat completions -> Responses
+// ---------------------------------------------------------------------------
+
+type chatUsage struct {
+ PromptTokens int `json:"prompt_tokens"`
+ CompletionTokens int `json:"completion_tokens"`
+ TotalTokens int `json:"total_tokens"`
+}
+
+type chatDeltaFunction struct {
+ Name string `json:"name"`
+ Arguments string `json:"arguments"`
+}
+
+type chatDeltaToolCall struct {
+ Index int `json:"index"`
+ Id string `json:"id"`
+ Function chatDeltaFunction `json:"function"`
+}
+
+type chatStreamChunk struct {
+ Model string `json:"model"`
+ Choices []struct {
+ Delta struct {
+ Content string `json:"content"`
+ ReasoningContent string
`json:"reasoning_content"`
+ ToolCalls []chatDeltaToolCall `json:"tool_calls"`
+ } `json:"delta"`
+ } `json:"choices"`
+ Usage *chatUsage `json:"usage"`
+ Error *struct {
+ Message string `json:"message"`
+ } `json:"error"`
+}
+
+type chatCompletion struct {
+ Model string `json:"model"`
+ Choices []struct {
+ Message struct {
+ Content string `json:"content"`
+ ToolCalls []chatToolCall `json:"tool_calls"`
+ } `json:"message"`
+ } `json:"choices"`
+ Usage *chatUsage `json:"usage"`
+}
+
+// responsesCall is one function call being assembled out of the deltas the
+// upstream sends it in.
+type responsesCall struct {
+ itemId string
+ callId string
+ name string
+ arguments strings.Builder
+ outputIndex int
+}
+
+// responsesStream turns a chat completions response into the Responses events
a
+// client such as Codex expects.
+type responsesStream struct {
+ writer io.Writer
+ flush func()
+ // token makes the item ids of this response unique without a counter of
+ // their own: they are the response id plus what the item is.
+ token string
+ id string
+ model string
+ created int64
+ sequence int
+ // broken records that the client stopped reading, which is the end of
the
+ // response whatever the upstream still has to say.
+ broken bool
+
+ nextOutputIndex int
+ messageId string
+ messageIndex int
+ messageStarted bool
+ text strings.Builder
+
+ reasoningId string
+
+ calls map[int]*responsesCall
+ callOrder []int
+
+ usage *chatUsage
+ failure string
+}
+
+func newResponsesStream(writer io.Writer, flush func(), model string)
*responsesStream {
+ now := time.Now()
+ token := fmt.Sprintf("%d", now.UnixNano())
+ return &responsesStream{
+ writer: writer,
+ flush: flush,
+ token: token,
+ id: "resp_" + token,
+ model: model,
+ created: now.Unix(),
+ calls: map[int]*responsesCall{},
+ }
+}
+
+func (s *responsesStream) emit(name string, payload map[string]any) {
+ payload["type"] = name
+ payload["sequence_number"] = s.sequence
+ s.sequence++
+
+ data, err := json.Marshal(payload)
+ if err != nil {
+ return
+ }
+ if _, err := fmt.Fprintf(s.writer, "event: %s\ndata: %s\n\n", name,
data); err != nil {
+ return
+ }
+ if s.flush != nil {
+ s.flush()
+ }
+}
+
+func (s *responsesStream) emitCreated() {
+ s.emit("response.created", map[string]any{"response":
s.response("in_progress", []any{})})
+}
+
+// appendText records one piece of the answer and sends it on.
+func (s *responsesStream) appendText(delta string) {
+ s.text.WriteString(delta)
+ s.emit("response.output_text.delta", map[string]any{
+ "item_id": s.messageId,
+ "output_index": s.messageIndex,
+ "content_index": 0,
+ "delta": delta,
+ })
+}
+
+func (s *responsesStream) consume(data []byte) {
+ var chunk chatStreamChunk
+ if err := json.Unmarshal(data, &chunk); err != nil {
+ return
+ }
+ if chunk.Error != nil && chunk.Error.Message != "" {
+ s.failure = chunk.Error.Message
+ return
+ }
+ if chunk.Model != "" {
+ s.model = chunk.Model
+ }
+ if chunk.Usage != nil {
+ s.usage = chunk.Usage
+ }
+
+ for _, choice := range chunk.Choices {
+ if delta := choice.Delta.ReasoningContent; delta != "" {
+ s.emit("response.reasoning_summary_text.delta",
map[string]any{
+ "item_id": s.reasoningItemId(),
+ "output_index": 0,
+ "summary_index": 0,
+ "delta": delta,
+ })
+ }
+ if delta := choice.Delta.Content; delta != "" {
+ s.startMessage()
+ s.appendText(delta)
+ }
+ for _, call := range choice.Delta.ToolCalls {
+ s.accumulateCall(call)
+ }
+ }
+}
+
+func (s *responsesStream) reasoningItemId() string {
+ if s.reasoningId == "" {
+ s.reasoningId = "rs_" + s.token
+ }
+ return s.reasoningId
+}
+
+func (s *responsesStream) startMessage() {
+ if s.messageStarted {
+ return
+ }
+ s.messageStarted = true
+ s.messageId = fmt.Sprintf("msg_%s_%d", s.token, s.nextOutputIndex)
+ s.messageIndex = s.nextOutputIndex
+ s.nextOutputIndex++
+ s.emit("response.output_item.added", map[string]any{
+ "output_index": s.messageIndex,
+ "item": map[string]any{
+ "type": "message", "id": s.messageId, "status":
"in_progress",
+ "role": "assistant", "content": []any{},
+ },
+ })
+}
+
+func (s *responsesStream) accumulateCall(delta chatDeltaToolCall) {
+ call, ok := s.calls[delta.Index]
+ if !ok {
+ call = &responsesCall{
+ itemId: fmt.Sprintf("fc_%s_%d", s.token,
s.nextOutputIndex),
+ outputIndex: s.nextOutputIndex,
+ }
+ s.nextOutputIndex++
+ s.calls[delta.Index] = call
+ s.callOrder = append(s.callOrder, delta.Index)
+ }
+ if delta.Id != "" {
+ call.callId = delta.Id
+ }
+ if delta.Function.Name != "" {
+ call.name += delta.Function.Name
+ }
+ if delta.Function.Arguments != "" {
+ call.arguments.WriteString(delta.Function.Arguments)
+ s.emit("response.function_call_arguments.delta", map[string]any{
+ "item_id": call.itemId,
+ "output_index": call.outputIndex,
+ "delta": delta.Function.Arguments,
+ })
+ }
+}
+
+// output is the item list a finished response carries, in the order the items
+// were started.
+func (s *responsesStream) output() []any {
+ slots := make([]any, s.nextOutputIndex)
+ if s.messageStarted {
+ slots[s.messageIndex] = s.messageItem()
+ }
+ for _, index := range s.callOrder {
+ call := s.calls[index]
+ slots[call.outputIndex] = callItem(call)
+ }
+
+ items := []any{}
+ for _, item := range slots {
+ if item != nil {
+ items = append(items, item)
+ }
+ }
+ return items
+}
+
+func (s *responsesStream) messageItem() map[string]any {
+ return map[string]any{
+ "type": "message", "id": s.messageId, "status": "completed",
"role": "assistant",
+ "content": []any{map[string]any{"type": "output_text", "text":
s.text.String(), "annotations": []any{}}},
+ }
+}
+
+func callItem(call *responsesCall) map[string]any {
+ callId := call.callId
+ if callId == "" {
+ callId = call.itemId
+ }
+ return map[string]any{
+ "type": "function_call", "id": call.itemId, "status":
"completed",
+ "call_id": callId, "name": call.name, "arguments":
emptyAsObject(call.arguments.String()),
+ }
+}
+
+func (s *responsesStream) response(status string, output []any) map[string]any
{
+ response := map[string]any{
+ "id": s.id, "object": "response", "created_at": s.created,
+ "status": status, "model": s.model, "output": output,
+ }
+ if s.usage != nil {
+ response["usage"] = map[string]any{
+ "input_tokens": s.usage.PromptTokens,
+ "output_tokens": s.usage.CompletionTokens,
+ "total_tokens": s.usage.TotalTokens,
+ }
+ }
+ return response
+}
+
+func (s *responsesStream) finish() {
+ if s.messageStarted {
+ s.emit("response.output_text.done", map[string]any{
+ "item_id": s.messageId, "output_index": s.messageIndex,
+ "content_index": 0, "text": s.text.String(),
+ })
+ s.emit("response.output_item.done", map[string]any{
+ "output_index": s.messageIndex, "item": s.messageItem(),
+ })
+ }
+ for _, index := range s.callOrder {
+ call := s.calls[index]
+ s.emit("response.function_call_arguments.done", map[string]any{
+ "item_id": call.itemId, "output_index":
call.outputIndex,
+ "arguments": emptyAsObject(call.arguments.String()),
+ })
+ s.emit("response.output_item.done", map[string]any{
+ "output_index": call.outputIndex, "item":
callItem(call),
+ })
+ }
+
+ if s.failure != "" {
+ response := s.response("failed", s.output())
+ response["error"] = map[string]any{"code": "server_error",
"message": s.failure}
+ s.emit("response.failed", map[string]any{"response": response})
+ return
+ }
+ s.emit("response.completed", map[string]any{"response":
s.response("completed", s.output())})
+}
+
+// relayResponsesStream rewrites the upstream SSE stream as Responses events.
+func (c *ApiController) relayResponsesStream(route *proxyRoute, body
io.Reader) {
+ stream := newResponsesStream(c.startEventStream(),
c.Ctx.ResponseWriter.Flush, route.model)
+ stream.emitCreated()
+
+ err := forEachSseData(body, func(data []byte) bool {
+ if !bytes.Equal(bytes.TrimSpace(data), []byte("[DONE]")) {
+ stream.consume(data)
+ }
+ return !stream.broken
+ })
+ if err != nil && stream.failure == "" {
+ stream.failure = err.Error()
+ }
+ stream.finish()
+}
+
+// relayResponsesBody rewrites a whole chat completion as a Responses answer. A
+// client that asked for a stream still gets one, of a single turn: the
upstream
+// answering in one piece is no reason to hand it a body it cannot read.
+func (c *ApiController) relayResponsesBody(route *proxyRoute, body io.Reader) {
+ data, err := io.ReadAll(body)
+ if err != nil {
+ c.writeProxyError(route.target.protocol, http.StatusBadGateway,
"server_error", "upstream read failed")
+ return
+ }
+
+ var completion chatCompletion
+ if err := json.Unmarshal(data, &completion); err != nil {
+ c.writeProxyError(route.target.protocol, http.StatusBadGateway,
"server_error", "upstream returned an unreadable body")
+ return
+ }
+
+ stream := newResponsesStream(io.Discard, nil, route.model)
+ if route.stream {
+ stream.writer = c.startEventStream()
+ stream.flush = c.Ctx.ResponseWriter.Flush
+ stream.emitCreated()
+ }
+
+ if completion.Model != "" {
+ stream.model = completion.Model
+ }
+ stream.usage = completion.Usage
+ if len(completion.Choices) > 0 {
+ message := completion.Choices[0].Message
+ if message.Content != "" {
+ stream.startMessage()
+ stream.appendText(message.Content)
+ }
+ for index, call := range message.ToolCalls {
+ stream.accumulateCall(chatDeltaToolCall{
+ Index: index,
+ Id: call.Id,
+ Function: chatDeltaFunction{Name:
call.Function.Name, Arguments: call.Function.Arguments},
+ })
+ }
+ }
+
+ if route.stream {
+ stream.finish()
+ return
+ }
+
+ response, err := json.Marshal(stream.response("completed",
stream.output()))
+ if err != nil {
+ c.writeProxyError(route.target.protocol, http.StatusBadGateway,
"server_error", "upstream returned an unreadable body")
+ return
+ }
+ c.Ctx.ResponseWriter.Header().Set("Content-Type", "application/json")
+ c.Ctx.ResponseWriter.WriteHeader(http.StatusOK)
+ if _, err := c.Ctx.ResponseWriter.Write(response); err != nil {
+ beego.Error("responses body write failed:", err)
+ }
+}
+
+// startEventStream begins an SSE response of this gateway's own making, which
+// carries none of the upstream headers: the body relayed under them is gone.
+func (c *ApiController) startEventStream() io.Writer {
+ header := c.Ctx.ResponseWriter.Header()
+ header.Set("Content-Type", "text/event-stream")
+ header.Set("Cache-Control", "no-cache")
+ c.Ctx.ResponseWriter.WriteHeader(http.StatusOK)
+ return c.Ctx.ResponseWriter
+}
+
+// forEachSseData calls fn with the data of every event in an SSE stream, until
+// fn asks it to stop.
+func forEachSseData(reader io.Reader, fn func([]byte) bool) error {
+ buffered := bufio.NewReaderSize(reader, 64*1024)
+ data := []byte{}
+ for {
+ line, err := buffered.ReadString('\n')
+ trimmed := strings.TrimRight(line, "\r\n")
+ switch {
+ case strings.HasPrefix(trimmed, "data:"):
+ data = append(data,
strings.TrimPrefix(strings.TrimPrefix(trimmed, "data:"), " ")...)
+ case trimmed == "" && len(data) > 0:
+ if !fn(data) {
+ return nil
+ }
+ data = data[:0]
+ }
+ if err != nil {
+ if len(data) > 0 {
+ fn(data)
+ }
+ if err == io.EOF {
+ return nil
+ }
+ return err
+ }
+ }
+}
diff --git a/object/provider.go b/object/provider.go
index 40542b3..5dd9023 100644
--- a/object/provider.go
+++ b/object/provider.go
@@ -210,6 +210,14 @@ func ProviderProtocol(provider *Provider) string {
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.
+func ServesResponsesApi(provider *Provider) bool {
+ return provider.Type == "openai"
+}
+
func containsString(values []string, value string) bool {
for _, v := range values {
if v == value {
diff --git a/routers/router.go b/routers/router.go
index 925c395..7f24ccd 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -127,12 +127,14 @@ func initAPI() {
beego.Router("/api/delete-llm-record", &controllers.ApiController{},
"POST:DeleteLlmRecord")
beego.Router("/api/clear-llm-records", &controllers.ApiController{},
"POST:ClearLlmRecords")
- // The LLM gateway, in both wire formats. The agent routes carry the two
- // endpoint shapes an OpenAI and an Anthropic client append to one base
URL.
+ // The LLM gateway, in every wire format a client speaks. The agent
routes
+ // carry the endpoint shapes those clients append to one base URL.
beego.Router("/v1/chat/completions", &controllers.ApiController{},
"POST:ChatCompletions")
+ beego.Router("/v1/responses", &controllers.ApiController{},
"POST:Responses")
beego.Router("/v1/messages", &controllers.ApiController{},
"POST:Messages")
beego.Router("/v1/messages/count_tokens", &controllers.ApiController{},
"POST:CountTokens")
beego.Router("/v1/agents/:agentId/chat/completions",
&controllers.ApiController{}, "POST:AgentChatCompletions")
+ beego.Router("/v1/agents/:agentId/responses",
&controllers.ApiController{}, "POST:AgentResponses")
beego.Router("/v1/agents/:agentId/v1/messages",
&controllers.ApiController{}, "POST:AgentMessages")
beego.Router("/v1/agents/:agentId/v1/messages/count_tokens",
&controllers.ApiController{}, "POST:AgentCountTokens")
}
diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index a7c9fbf..15acbb2 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -43,8 +43,8 @@ export function agentKey(agent: Pick<Agent, "owner" |
"path">) {
/**
* The base URL an agent is pointed at to reach its own provider. One URL
serves
- * both wire formats: an OpenAI client appends /chat/completions to it, an
- * Anthropic one appends /v1/messages.
+ * every wire format: an OpenAI client appends /chat/completions to it, Codex
+ * appends /responses, and an Anthropic one appends /v1/messages.
*/
export function agentProxyBaseUrl(agentId: string) {
return `${Setting.ServerUrl ||
window.location.origin}/v1/agents/${encodeURIComponent(agentId)}`;
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]