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 e773b78 feat(channel): speak the Anthropic API as well as OpenAI's
e773b78 is described below
commit e773b783edb8203ca917296f1a264301a633546a
Author: Yang Luo <[email protected]>
AuthorDate: Thu Aug 20 23:32:19 2026 +0800
feat(channel): speak the Anthropic API as well as OpenAI's
---
controllers/proxy.go | 202 +++++++++++++++++++++++++++-----------
controllers/proxy_test.go | 91 +++++++++++++++--
object/channel.go | 73 +++++++++++++-
object/channel_url_test.go | 77 +++++++++++++++
routers/router.go | 7 +-
web/src/components/EnvSnippet.tsx | 79 +++++++++++++++
web/src/lib/agents.ts | 21 +++-
web/src/lib/channels.ts | 70 +++++++++++++
web/src/locales/en/data.json | 14 ++-
web/src/locales/zh/data.json | 14 ++-
web/src/pages/AgentDetailPage.tsx | 40 ++++----
web/src/pages/ChannelEditPage.tsx | 54 +++++++++-
12 files changed, 640 insertions(+), 102 deletions(-)
diff --git a/controllers/proxy.go b/controllers/proxy.go
index 715ded7..d11d258 100644
--- a/controllers/proxy.go
+++ b/controllers/proxy.go
@@ -55,21 +55,54 @@ var hopByHopHeaders = []string{
"Upgrade",
}
-// chatCompletionsRequest contains only the fields needed for routing.
-// All other fields (messages, temperature, etc.) are forwarded as-is to the
upstream.
-type chatCompletionsRequest struct {
+// proxyTarget is one upstream API this proxy relays to: the wire format it
+// speaks and the endpoint the request lands on.
+type proxyTarget struct {
+ protocol string
+ endpoint string
+}
+
+var (
+ openAiChat = proxyTarget{object.ProtocolOpenAi,
"/chat/completions"}
+ anthropicMessages = proxyTarget{object.ProtocolAnthropic,
"/v1/messages"}
+ anthropicCountTokens = proxyTarget{object.ProtocolAnthropic,
"/v1/messages/count_tokens"}
+)
+
+// proxyRoute is one client request being relayed. Everything except model and
+// stream (messages, temperature, ...) is forwarded as-is to the upstream.
+type proxyRoute struct {
+ target proxyTarget
+ body []byte
+ model string
+ stream bool
+ // source describes how the channels were chosen, for the error a client
+ // sees when none of them can be used.
+ source string
+}
+
+// routingFields are the only fields read out of the request body. Both the
+// OpenAI and the Anthropic body carry them under the same names.
+type routingFields struct {
Model string `json:"model"`
Stream bool `json:"stream"`
}
+// proxyErrorDetail is the {message, type} object both APIs report errors in.
+type proxyErrorDetail struct {
+ Message string `json:"message"`
+ Type string `json:"type"`
+}
+
// openaiErrorResponse follows the OpenAI API error format.
type openaiErrorResponse struct {
- Error openaiErrorDetail `json:"error"`
+ Error proxyErrorDetail `json:"error"`
}
-type openaiErrorDetail struct {
- Message string `json:"message"`
- Type string `json:"type"`
+// anthropicErrorResponse follows the Anthropic API error format, which wraps
+// the same detail in a typed envelope.
+type anthropicErrorResponse struct {
+ Type string `json:"type"`
+ Error proxyErrorDetail `json:"error"`
}
// proxyClient is a shared HTTP client for upstream requests.
@@ -99,77 +132,110 @@ var proxyClient = &http.Client{
// request body.
// This endpoint does NOT require Casdoor authentication (auth deferred to
milestone 1.3).
func (c *ApiController) ChatCompletions() {
- rawBody := c.Ctx.Input.RequestBody
- req, ok := c.readChatCompletionsRequest(rawBody)
+ c.proxyByModel(openAiChat)
+}
+
+// Messages is the Anthropic-compatible counterpart of ChatCompletions, for the
+// agents that speak that API rather than OpenAI's.
+func (c *ApiController) Messages() {
+ c.proxyByModel(anthropicMessages)
+}
+
+// CountTokens relays the Anthropic token-counting endpoint, which clients call
+// alongside Messages to size their context.
+func (c *ApiController) CountTokens() {
+ c.proxyByModel(anthropicCountTokens)
+}
+
+// AgentChatCompletions is the per-agent entry point of the same proxy: an
agent
+// pointed at ".../v1/agents/<agentId>" reaches the channel bound to it rather
+// than one chosen by model name.
+func (c *ApiController) AgentChatCompletions() {
+ c.proxyByAgent(openAiChat)
+}
+
+// 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.
+func (c *ApiController) AgentMessages() {
+ c.proxyByAgent(anthropicMessages)
+}
+
+// AgentCountTokens is the per-agent Anthropic token-counting endpoint.
+func (c *ApiController) AgentCountTokens() {
+ c.proxyByAgent(anthropicCountTokens)
+}
+
+// proxyByModel forwards to the channels that serve the model the request
names.
+func (c *ApiController) proxyByModel(target proxyTarget) {
+ route, ok := c.readProxyRoute(target)
if !ok {
return
}
+ route.source = "model: " + route.model
// Match the channels globally, without an owner filter.
- channels, err := object.GetChannelsByModel(req.Model)
+ channels, err := object.GetChannelsByModel(route.model)
if err != nil {
if errors.Is(err, object.ErrNoChannelAvailable) {
- c.writeOpenAIError(http.StatusBadRequest,
"invalid_request_error", err.Error())
+ c.writeProxyError(target.protocol,
http.StatusBadRequest, "invalid_request_error", err.Error())
} else {
beego.Error("channel lookup failed:", err)
- c.writeOpenAIError(http.StatusBadGateway,
"server_error", "channel lookup failed")
+ c.writeProxyError(target.protocol,
http.StatusBadGateway, "server_error", "channel lookup failed")
}
return
}
- c.forwardChatCompletions(channels, rawBody, req.Stream, "model:
"+req.Model)
+ c.forwardToChannels(channels, route)
}
-// AgentChatCompletions is the per-agent entry point of the same proxy: an
agent
-// pointed at ".../v1/agents/<agentId>" reaches the channel bound to it rather
-// than one chosen by model name.
-func (c *ApiController) AgentChatCompletions() {
- agentId := c.Ctx.Input.Param(":agentId")
- rawBody := c.Ctx.Input.RequestBody
- req, ok := c.readChatCompletionsRequest(rawBody)
+// proxyByAgent forwards to the single channel bound to the agent in the path.
+func (c *ApiController) proxyByAgent(target proxyTarget) {
+ route, ok := c.readProxyRoute(target)
if !ok {
return
}
+ agentId := c.Ctx.Input.Param(":agentId")
+ route.source = "agent: " + agentId
channel, err := object.GetChannelByAgent(agentId)
if err != nil {
if errors.Is(err, object.ErrAgentNoChannel) {
- c.writeOpenAIError(http.StatusBadRequest,
"invalid_request_error", err.Error())
+ c.writeProxyError(target.protocol,
http.StatusBadRequest, "invalid_request_error", err.Error())
} else {
beego.Error("agent channel lookup failed:", err)
- c.writeOpenAIError(http.StatusBadGateway,
"server_error", err.Error())
+ c.writeProxyError(target.protocol,
http.StatusBadGateway, "server_error", err.Error())
}
return
}
- c.forwardChatCompletions([]*object.Channel{channel}, rawBody,
req.Stream, "agent: "+agentId)
+ c.forwardToChannels([]*object.Channel{channel}, route)
}
-// readChatCompletionsRequest parses only model and stream; everything else
-// (messages, temperature, ...) is forwarded as-is.
-func (c *ApiController) readChatCompletionsRequest(rawBody []byte)
(chatCompletionsRequest, bool) {
- var req chatCompletionsRequest
- if err := json.Unmarshal(rawBody, &req); err != nil {
- c.writeOpenAIError(http.StatusBadRequest,
"invalid_request_error", "invalid request body")
- return req, false
+func (c *ApiController) readProxyRoute(target proxyTarget) (*proxyRoute, bool)
{
+ rawBody := c.Ctx.Input.RequestBody
+
+ var fields routingFields
+ if err := json.Unmarshal(rawBody, &fields); err != nil {
+ c.writeProxyError(target.protocol, http.StatusBadRequest,
"invalid_request_error", "invalid request body")
+ return nil, false
}
- if req.Model == "" {
- c.writeOpenAIError(http.StatusBadRequest,
"invalid_request_error", "model is required")
- return req, false
+ if fields.Model == "" {
+ c.writeProxyError(target.protocol, http.StatusBadRequest,
"invalid_request_error", "model is required")
+ return nil, false
}
- return req, true
+
+ return &proxyRoute{target: target, body: rawBody, model: fields.Model,
stream: fields.Stream}, true
}
-// forwardChatCompletions relays the request to the first channel that answers.
-// route describes how the channels were chosen, for the error a caller sees
-// when none of them can be used.
-func (c *ApiController) forwardChatCompletions(channels []*object.Channel,
rawBody []byte, stream bool, route string) {
+// forwardToChannels relays the request to the first channel that answers.
+func (c *ApiController) forwardToChannels(channels []*object.Channel, route
*proxyRoute) {
// Drop the channels this proxy cannot talk to before forwarding, so
that
// the last usable channel is known and its response can be relayed
as-is.
usableChannels := []*object.Channel{}
skipReason := ""
for _, channel := range channels {
- if reason := channelUnusableReason(channel); reason != "" {
+ if reason := channelUnusableReason(channel,
route.target.protocol); reason != "" {
beego.Error("skipped channel", channel.GetId()+":",
reason)
skipReason = reason
continue
@@ -177,11 +243,11 @@ func (c *ApiController) forwardChatCompletions(channels
[]*object.Channel, rawBo
usableChannels = append(usableChannels, channel)
}
if len(usableChannels) == 0 {
- message := fmt.Sprintf("no usable channel for %s", route)
+ message := fmt.Sprintf("no usable channel for %s", route.source)
if skipReason != "" {
message = skipReason
}
- c.writeOpenAIError(http.StatusBadGateway, "server_error",
message)
+ c.writeProxyError(route.target.protocol, http.StatusBadGateway,
"server_error", message)
return
}
@@ -195,14 +261,14 @@ func (c *ApiController) forwardChatCompletions(channels
[]*object.Channel, rawBo
return
}
- status, message, written := c.forwardToChannel(channel,
rawBody, stream, i == len(usableChannels)-1)
+ status, message, written := c.forwardToChannel(channel, route,
i == len(usableChannels)-1)
if written {
return
}
lastStatus, lastMessage = status, message
}
- c.writeOpenAIError(lastStatus, "server_error", lastMessage)
+ c.writeProxyError(route.target.protocol, lastStatus, "server_error",
lastMessage)
}
// forwardToChannel sends the request to a single channel's upstream. It
reports
@@ -210,8 +276,8 @@ func (c *ApiController) forwardChatCompletions(channels
[]*object.Channel, rawBo
// status and message describing the failure so that the caller can fail over
to
// the next channel. The response of the last channel is always relayed, even
// when its status would otherwise be retried.
-func (c *ApiController) forwardToChannel(channel *object.Channel, rawBody
[]byte, stream bool, isLast bool) (int, string, bool) {
- upstreamUrl, err := object.BuildOpenAiUrl(channel.BaseUrl,
"/chat/completions")
+func (c *ApiController) forwardToChannel(channel *object.Channel, route
*proxyRoute, isLast bool) (int, string, bool) {
+ upstreamUrl, err := object.BuildChannelUrl(channel.BaseUrl,
route.target.protocol, route.target.endpoint)
if err != nil {
return http.StatusBadGateway, err.Error(), false
}
@@ -221,12 +287,15 @@ func (c *ApiController) forwardToChannel(channel
*object.Channel, rawBody []byte
ctx, cancel := context.WithCancel(c.Ctx.Request.Context())
defer cancel()
- upstreamReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
upstreamUrl, bytes.NewReader(rawBody))
+ upstreamReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
upstreamUrl, bytes.NewReader(route.body))
if err != nil {
return http.StatusBadGateway, "upstream connection failed",
false
}
upstreamReq.Header.Set("Content-Type", "application/json")
- upstreamReq.Header.Set("Authorization", "Bearer "+channel.ApiKey)
+ object.SetChannelAuth(upstreamReq.Header, channel)
+ if route.target.protocol == object.ProtocolAnthropic {
+ c.copyAnthropicHeaders(upstreamReq.Header)
+ }
upstreamResp, err := proxyClient.Do(upstreamReq)
if err != nil {
@@ -256,10 +325,25 @@ func (c *ApiController) forwardToChannel(channel
*object.Channel, rawBody []byte
body := newIdleTimeoutReader(upstreamResp.Body, proxyIdleTimeout,
cancel)
defer body.Stop()
- c.relayResponse(upstreamResp, body, stream &&
isEventStream(upstreamResp))
+ c.relayResponse(upstreamResp, body, route.stream &&
isEventStream(upstreamResp))
return 0, "", true
}
+// copyAnthropicHeaders passes the client's API version and beta opt-ins on to
+// the upstream: they select response features, so dropping them would answer a
+// different request than the one that was made.
+func (c *ApiController) copyAnthropicHeaders(dst http.Header) {
+ version := c.Ctx.Request.Header.Get("Anthropic-Version")
+ if version == "" {
+ version = object.AnthropicVersion
+ }
+ dst.Set("Anthropic-Version", version)
+
+ for _, beta := range c.Ctx.Request.Header.Values("Anthropic-Beta") {
+ dst.Add("Anthropic-Beta", beta)
+ }
+}
+
// 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
@@ -340,10 +424,13 @@ func isRetryableStatus(statusCode int) bool {
// channelUnusableReason reports why the proxy cannot forward to a channel, or
// an empty string when it can.
-func channelUnusableReason(channel *object.Channel) string {
+func channelUnusableReason(channel *object.Channel, protocol string) string {
if !object.IsChannelTypeSupported(channel) {
return fmt.Sprintf("the %s channel type is not supported",
channel.Type)
}
+ if object.ChannelProtocol(channel) != protocol {
+ return fmt.Sprintf("channel %s does not speak the %s API",
channel.GetId(), protocol)
+ }
if channel.BaseUrl == "" {
return "channel base URL is not configured"
}
@@ -379,15 +466,16 @@ func (r *idleTimeoutReader) Stop() {
r.timer.Stop()
}
-// writeOpenAIError writes an OpenAI-compatible JSON error response
-// with the given HTTP status code, error type, and message.
-func (c *ApiController) writeOpenAIError(statusCode int, errType, message
string) {
- resp := openaiErrorResponse{
- Error: openaiErrorDetail{
- Message: message,
- Type: errType,
- },
+// writeProxyError writes a JSON error response in the format the client that
+// made the request expects.
+func (c *ApiController) writeProxyError(protocol string, statusCode int,
errType, message string) {
+ detail := proxyErrorDetail{Message: message, Type: errType}
+
+ var resp any = openaiErrorResponse{Error: detail}
+ if protocol == object.ProtocolAnthropic {
+ resp = anthropicErrorResponse{Type: "error", Error: detail}
}
+
c.Ctx.ResponseWriter.Header().Set("Content-Type", "application/json")
c.Ctx.ResponseWriter.WriteHeader(statusCode)
if err := json.NewEncoder(c.Ctx.ResponseWriter).Encode(resp); err !=
nil {
diff --git a/controllers/proxy_test.go b/controllers/proxy_test.go
index ca2c34e..99d40fc 100644
--- a/controllers/proxy_test.go
+++ b/controllers/proxy_test.go
@@ -73,15 +73,31 @@ func TestIsRetryableStatus(t *testing.T) {
}
func TestChannelUnusableReason(t *testing.T) {
- if reason := channelUnusableReason(&object.Channel{Type: "claude",
BaseUrl: "https://example.com"}); !strings.Contains(reason, "not supported") {
+ openAi := object.ProtocolOpenAi
+ anthropic := object.ProtocolAnthropic
+
+ if reason := channelUnusableReason(&object.Channel{Type: "claude",
BaseUrl: "https://example.com"}, openAi); !strings.Contains(reason, "not
supported") {
t.Errorf("the claude channel type should be rejected, got: %s",
reason)
}
- if reason := channelUnusableReason(&object.Channel{Type: "openai",
BaseUrl: ""}); !strings.Contains(reason, "base URL") {
+ if reason := channelUnusableReason(&object.Channel{Type: "openai",
BaseUrl: ""}, openAi); !strings.Contains(reason, "base URL") {
t.Errorf("an empty base URL should be rejected, got: %s",
reason)
}
- if reason := channelUnusableReason(&object.Channel{Type: "custom",
BaseUrl: "https://example.com"}); reason != "" {
+ if reason := channelUnusableReason(&object.Channel{Type: "custom",
BaseUrl: "https://example.com"}, openAi); reason != "" {
t.Errorf("the custom channel type should be usable, got: %s",
reason)
}
+
+ // An OpenAI body cannot be forwarded to an Anthropic upstream, or the
other
+ // way around, however healthy the channel is.
+ mismatched := &object.Channel{Owner: "admin", Name: "claude", Type:
"anthropic", BaseUrl: "https://api.anthropic.com"}
+ if reason := channelUnusableReason(mismatched, openAi);
!strings.Contains(reason, "does not speak") {
+ t.Errorf("an anthropic channel should be rejected for an openai
request, got: %s", reason)
+ }
+ if reason := channelUnusableReason(mismatched, anthropic); reason != ""
{
+ t.Errorf("an anthropic channel should be usable for an
anthropic request, got: %s", reason)
+ }
+ if reason := channelUnusableReason(&object.Channel{Owner: "admin",
Name: "gpt", Type: "openai", BaseUrl: "https://api.openai.com/v1"}, anthropic);
!strings.Contains(reason, "does not speak") {
+ t.Errorf("an openai channel should be rejected for an anthropic
request, got: %s", reason)
+ }
}
func TestRelayResponse(t *testing.T) {
@@ -222,9 +238,11 @@ func TestForwardToChannel(t *testing.T) {
healthyChannel := &object.Channel{Owner: "admin", Name: "healthy",
Type: "openai", BaseUrl: healthyServer.URL + "/", ApiKey: "sk-good"}
rawBody := []byte(`{"model":"gpt-4","messages":[]}`)
+ route := &proxyRoute{target: openAiChat, body: rawBody}
+
// A retryable status fails over instead of reaching the client.
c, recorder := newTestApiController()
- statusCode, message, written := c.forwardToChannel(overloadedChannel,
rawBody, false, false)
+ statusCode, message, written := c.forwardToChannel(overloadedChannel,
route, false)
if written {
t.Fatal("a retryable status was relayed instead of failing
over")
}
@@ -238,14 +256,14 @@ func TestForwardToChannel(t *testing.T) {
// The last channel is relayed as-is, even with a retryable status, so
that
// the client sees the real upstream answer.
c, recorder = newTestApiController()
- _, _, written = c.forwardToChannel(overloadedChannel, rawBody, false,
true)
+ _, _, written = c.forwardToChannel(overloadedChannel, route, true)
if !written || recorder.Code != http.StatusServiceUnavailable ||
!strings.Contains(recorder.Body.String(), "overloaded") {
t.Errorf("the last channel was not relayed: written = %v,
statusCode = %d, body = %s", written, recorder.Code, recorder.Body.String())
}
// A healthy channel, with a trailing slash in its base URL.
c, recorder = newTestApiController()
- _, _, written = c.forwardToChannel(healthyChannel, rawBody, false, true)
+ _, _, written = c.forwardToChannel(healthyChannel, route, true)
if !written || recorder.Code != http.StatusOK ||
!strings.Contains(recorder.Body.String(), "choices") {
t.Errorf("the healthy channel failed: written = %v, statusCode
= %d, body = %s", written, recorder.Code, recorder.Body.String())
}
@@ -253,8 +271,67 @@ func TestForwardToChannel(t *testing.T) {
// stream=true, but the upstream rejected the request: the JSON error
must
// not be dressed up as an SSE stream.
c, recorder = newTestApiController()
- c.forwardToChannel(overloadedChannel, rawBody, true, true)
+ c.forwardToChannel(overloadedChannel, &proxyRoute{target: openAiChat,
body: rawBody, stream: true}, true)
if header := recorder.Header().Get("Content-Type"); header !=
"application/json" {
t.Errorf("Content-Type = %s, expected application/json", header)
}
}
+
+func TestForwardToChannelAnthropic(t *testing.T) {
+ var gotPath, gotKey, gotVersion, gotAuth string
+ var gotBeta []string
+
+ server := httptest.NewServer(http.HandlerFunc(func(w
http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.Path
+ gotKey = r.Header.Get("X-Api-Key")
+ gotVersion = r.Header.Get("Anthropic-Version")
+ gotBeta = r.Header.Values("Anthropic-Beta")
+ gotAuth = r.Header.Get("Authorization")
+
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"content":[]}`))
+ }))
+ defer server.Close()
+
+ channel := &object.Channel{Owner: "admin", Name: "claude", Type:
"anthropic", BaseUrl: server.URL, ApiKey: "sk-ant-test"}
+ route := &proxyRoute{target: anthropicMessages, body:
[]byte(`{"model":"claude-opus-5","messages":[]}`)}
+
+ c, recorder := newTestApiController()
+ c.Ctx.Request.Header.Add("Anthropic-Beta",
"fine-grained-tool-streaming-2025-05-14")
+ if _, _, written := c.forwardToChannel(channel, route, true); !written {
+ t.Fatal("the anthropic channel was not relayed")
+ }
+
+ if recorder.Code != http.StatusOK ||
!strings.Contains(recorder.Body.String(), "content") {
+ t.Errorf("statusCode = %d, body = %s", recorder.Code,
recorder.Body.String())
+ }
+ if gotPath != "/v1/messages" {
+ t.Errorf("upstream path = %s, expected /v1/messages", gotPath)
+ }
+ if gotKey != "sk-ant-test" {
+ t.Errorf("X-Api-Key = %s", gotKey)
+ }
+ if gotAuth != "" {
+ t.Errorf("the OpenAI Authorization header was sent to an
anthropic upstream: %s", gotAuth)
+ }
+ if gotVersion != object.AnthropicVersion {
+ t.Errorf("Anthropic-Version = %s, expected %s", gotVersion,
object.AnthropicVersion)
+ }
+ if len(gotBeta) != 1 || gotBeta[0] !=
"fine-grained-tool-streaming-2025-05-14" {
+ t.Errorf("Anthropic-Beta = %v, expected the client value to be
passed on", gotBeta)
+ }
+}
+
+func TestWriteProxyError(t *testing.T) {
+ c, recorder := newTestApiController()
+ c.writeProxyError(object.ProtocolOpenAi, http.StatusBadRequest,
"invalid_request_error", "nope")
+ if body := recorder.Body.String(); !strings.Contains(body,
`"error":{"message":"nope"`) || strings.Contains(body, `"type":"error"`) {
+ t.Errorf("openai error body = %s", body)
+ }
+
+ c, recorder = newTestApiController()
+ c.writeProxyError(object.ProtocolAnthropic, http.StatusBadRequest,
"invalid_request_error", "nope")
+ if body := recorder.Body.String(); !strings.Contains(body,
`"type":"error"`) {
+ t.Errorf("anthropic error body = %s", body)
+ }
+}
diff --git a/object/channel.go b/object/channel.go
index 73862fd..e678078 100644
--- a/object/channel.go
+++ b/object/channel.go
@@ -41,6 +41,10 @@ var ErrNoChannelAvailable = errors.New("no available
channel")
// (including an empty string) overwrites the stored key.
const ApiKeyMask = "***"
+// AnthropicVersion is the API version sent upstream when the client did not
+// pick one. The Anthropic API rejects a request without it.
+const AnthropicVersion = "2023-06-01"
+
// apiKeyEncryptionSecret is empty when encryption is off, which keeps keys
// stored as plaintext like before.
func apiKeyEncryptionSecret() string {
@@ -126,18 +130,32 @@ const (
)
var (
- // The OpenAI HTTP API is the only wire format the gateway can talk, so
- // every supported type is reached with an OpenAI-formatted request.
- channelTypes = []string{"openai", "custom"}
+ channelTypes = []string{"openai", "custom", "anthropic"}
channelStatuses = []string{"enabled", "disabled"}
)
+// The wire formats the gateway can speak. A request in one of them can only be
+// forwarded to a channel whose upstream speaks the same one.
+const (
+ ProtocolOpenAi = "openai"
+ ProtocolAnthropic = "anthropic"
+)
+
// IsChannelTypeSupported reports whether the gateway can talk to the channel's
// upstream.
func IsChannelTypeSupported(channel *Channel) bool {
return containsString(channelTypes, channel.Type)
}
+// ChannelProtocol is the wire format a channel's upstream speaks. Everything
+// that is not Anthropic is reached with an OpenAI-formatted request.
+func ChannelProtocol(channel *Channel) string {
+ if channel.Type == "anthropic" {
+ return ProtocolAnthropic
+ }
+ return ProtocolOpenAi
+}
+
func containsString(values []string, value string) bool {
for _, v := range values {
if v == value {
@@ -305,6 +323,42 @@ func BuildOpenAiUrl(baseUrl string, endpoint string)
(string, error) {
return u.String(), nil
}
+// BuildAnthropicUrl joins an Anthropic endpoint onto a channel base URL.
Unlike
+// an OpenAI base URL, an Anthropic one is bare and the endpoint carries the
/v1
+// prefix; a base URL that already has one is not doubled.
+func BuildAnthropicUrl(baseUrl string, endpoint string) (string, error) {
+ u, err := url.Parse(baseUrl)
+ if err != nil {
+ return "", fmt.Errorf("invalid base URL: %s", err.Error())
+ }
+
+ path := strings.TrimSuffix(strings.TrimRight(u.Path, "/"), endpoint)
+ path = strings.TrimSuffix(strings.TrimRight(path, "/"), "/v1")
+
+ u.Path = path + endpoint
+ u.RawPath = ""
+ return u.String(), nil
+}
+
+// BuildChannelUrl is the upstream URL a request in the given protocol is sent
+// to. The endpoint is the protocol's own, not a shared one.
+func BuildChannelUrl(baseUrl string, protocol string, endpoint string)
(string, error) {
+ if protocol == ProtocolAnthropic {
+ return BuildAnthropicUrl(baseUrl, endpoint)
+ }
+ return BuildOpenAiUrl(baseUrl, endpoint)
+}
+
+// SetChannelAuth puts the channel's credentials on an upstream request, in the
+// header the channel's protocol authenticates with.
+func SetChannelAuth(header http.Header, channel *Channel) {
+ if ChannelProtocol(channel) == ProtocolAnthropic {
+ header.Set("X-Api-Key", channel.ApiKey)
+ return
+ }
+ header.Set("Authorization", "Bearer "+channel.ApiKey)
+}
+
func AddChannel(channel *Channel) (bool, error) {
if err := validateChannel(channel); err != nil {
return false, err
@@ -382,7 +436,13 @@ func TestChannelConnectivity(channel *Channel) (bool, int,
string) {
return false, 0, err.Error()
}
- probeUrl, err := BuildOpenAiUrl(stored.BaseUrl, "/models")
+ protocol := ChannelProtocol(stored)
+ probeEndpoint := "/models"
+ if protocol == ProtocolAnthropic {
+ probeEndpoint = "/v1/models"
+ }
+
+ probeUrl, err := BuildChannelUrl(stored.BaseUrl, protocol,
probeEndpoint)
if err != nil {
return false, 0, err.Error()
}
@@ -391,8 +451,11 @@ func TestChannelConnectivity(channel *Channel) (bool, int,
string) {
if err != nil {
return false, 0, err.Error()
}
+ if protocol == ProtocolAnthropic {
+ req.Header.Set("Anthropic-Version", AnthropicVersion)
+ }
if stored.ApiKey != "" {
- req.Header.Set("Authorization", "Bearer "+stored.ApiKey)
+ SetChannelAuth(req.Header, stored)
}
client := &http.Client{
diff --git a/object/channel_url_test.go b/object/channel_url_test.go
new file mode 100644
index 0000000..e202e66
--- /dev/null
+++ b/object/channel_url_test.go
@@ -0,0 +1,77 @@
+// 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.
+
+package object
+
+import (
+ "net/http"
+ "testing"
+)
+
+func TestBuildChannelUrl(t *testing.T) {
+ cases := []struct {
+ baseUrl string
+ protocol string
+ endpoint string
+ expected string
+ }{
+ {"https://api.openai.com/v1", ProtocolOpenAi,
"/chat/completions", "https://api.openai.com/v1/chat/completions"},
+ {"https://api.openai.com", ProtocolOpenAi, "/chat/completions",
"https://api.openai.com/v1/chat/completions"},
+ {"https://api.openai.com/v1/", ProtocolOpenAi,
"/chat/completions", "https://api.openai.com/v1/chat/completions"},
+
+ // An Anthropic base URL is bare and the endpoint supplies the
/v1.
+ {"https://api.anthropic.com", ProtocolAnthropic,
"/v1/messages", "https://api.anthropic.com/v1/messages"},
+ {"https://api.anthropic.com/", ProtocolAnthropic,
"/v1/messages", "https://api.anthropic.com/v1/messages"},
+ {"https://api.anthropic.com/v1", ProtocolAnthropic,
"/v1/messages", "https://api.anthropic.com/v1/messages"},
+ {"https://api.anthropic.com/v1/messages", ProtocolAnthropic,
"/v1/messages", "https://api.anthropic.com/v1/messages"},
+ {"https://relay.example.com/anthropic", ProtocolAnthropic,
"/v1/messages", "https://relay.example.com/anthropic/v1/messages"},
+ {"https://api.anthropic.com", ProtocolAnthropic,
"/v1/messages/count_tokens",
"https://api.anthropic.com/v1/messages/count_tokens"},
+ }
+
+ for _, tc := range cases {
+ got, err := BuildChannelUrl(tc.baseUrl, tc.protocol,
tc.endpoint)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got != tc.expected {
+ t.Errorf("BuildChannelUrl(%s, %s, %s) = %s, expected
%s", tc.baseUrl, tc.protocol, tc.endpoint, got, tc.expected)
+ }
+ }
+}
+
+func TestChannelProtocolAndAuth(t *testing.T) {
+ for _, channelType := range []string{"openai", "custom", ""} {
+ channel := &Channel{Type: channelType, ApiKey: "sk-test"}
+ if got := ChannelProtocol(channel); got != ProtocolOpenAi {
+ t.Errorf("ChannelProtocol(%q) = %s, expected %s",
channelType, got, ProtocolOpenAi)
+ }
+
+ header := http.Header{}
+ SetChannelAuth(header, channel)
+ if header.Get("Authorization") != "Bearer sk-test" ||
header.Get("X-Api-Key") != "" {
+ t.Errorf("openai auth header = %v", header)
+ }
+ }
+
+ channel := &Channel{Type: "anthropic", ApiKey: "sk-ant-test"}
+ if got := ChannelProtocol(channel); got != ProtocolAnthropic {
+ t.Errorf("ChannelProtocol(anthropic) = %s", got)
+ }
+
+ header := http.Header{}
+ SetChannelAuth(header, channel)
+ if header.Get("X-Api-Key") != "sk-ant-test" ||
header.Get("Authorization") != "" {
+ t.Errorf("anthropic auth header = %v", header)
+ }
+}
diff --git a/routers/router.go b/routers/router.go
index 373fc71..e85d6ab 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -94,7 +94,12 @@ func initAPI() {
beego.Router("/api/delete-channel", &controllers.ApiController{},
"POST:DeleteChannel")
beego.Router("/api/test-channel", &controllers.ApiController{},
"POST:TestChannel")
- // OpenAI-compatible chat completions endpoint for LLM gateway
milestone 1.2.
+ // 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.
beego.Router("/v1/chat/completions", &controllers.ApiController{},
"POST:ChatCompletions")
+ 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/v1/messages",
&controllers.ApiController{}, "POST:AgentMessages")
+ beego.Router("/v1/agents/:agentId/v1/messages/count_tokens",
&controllers.ApiController{}, "POST:AgentCountTokens")
}
diff --git a/web/src/components/EnvSnippet.tsx
b/web/src/components/EnvSnippet.tsx
new file mode 100644
index 0000000..25bf32a
--- /dev/null
+++ b/web/src/components/EnvSnippet.tsx
@@ -0,0 +1,79 @@
+// 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.
+
+import * as React from "react";
+import {Copy} from "lucide-react";
+import copy from "copy-to-clipboard";
+import i18next from "i18next";
+
+import * as Setting from "@/Setting";
+import {Button} from "@/components/ui/button";
+import {Tooltip} from "@/components/ui/tooltip";
+import {cn} from "@/lib/utils";
+import {envSnippet, shells, type Shell} from "@/lib/channels";
+
+/**
+ * The environment variables that point a client at one Gateway endpoint, ready
+ * to paste into the shell the agent is started from.
+ */
+export function EnvSnippet({
+ protocol,
+ baseUrl,
+ defaultShell = "bash",
+}: {
+ protocol: string;
+ baseUrl: string;
+ defaultShell?: Shell;
+}) {
+ const [shell, setShell] = React.useState<Shell>(defaultShell);
+ const snippet = envSnippet(protocol, baseUrl, shell);
+
+ return (
+ <div className="space-y-2">
+ <div className="flex items-center justify-between gap-2">
+ <div className="inline-flex rounded-md border p-0.5">
+ {shells.map(name => (
+ <button
+ key={name}
+ onClick={() => setShell(name)}
+ className={cn(
+ "rounded px-2 py-0.5 text-xs transition-colors",
+ shell === name
+ ? "bg-primary text-primary-foreground"
+ : "text-muted-foreground hover:bg-accent",
+ )}
+ >
+ {name}
+ </button>
+ ))}
+ </div>
+ <Tooltip title={i18next.t("general:Copy")}>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-6 w-6 shrink-0"
+ aria-label={i18next.t("general:Copy")}
+ onClick={() => {
+ copy(snippet);
+ Setting.showMessage("success", i18next.t("general:Copied to
clipboard"));
+ }}
+ >
+ <Copy className="h-3.5 w-3.5" />
+ </Button>
+ </Tooltip>
+ </div>
+ <pre className="overflow-x-auto rounded-md bg-muted p-2
text-xs">{snippet}</pre>
+ </div>
+ );
+}
diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index f64b435..da01246 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -33,11 +33,30 @@ export function agentKey(agent: Pick<Agent, "owner" |
"path">) {
return `${agent.owner}:${agent.path}`;
}
-/** The base URL an agent is pointed at to reach its own channel. */
+/**
+ * The base URL an agent is pointed at to reach its own channel. One URL serves
+ * both wire formats: an OpenAI client appends /chat/completions to it, an
+ * Anthropic one appends /v1/messages.
+ */
export function agentProxyBaseUrl(agentId: string) {
return `${Setting.ServerUrl ||
window.location.origin}/v1/agents/${encodeURIComponent(agentId)}`;
}
+/**
+ * A config file that carries the same setting for good, for the agents that
+ * have one. Empty when only the environment is known to work.
+ */
+export function agentSetupNoteKey(agentId: string) {
+ switch (agentId) {
+ case "claude-code":
+ return "agent:Claude Code config hint";
+ case "codex-cli":
+ return "agent:Codex CLI config hint";
+ default:
+ return "";
+ }
+}
+
/** The detail page route for one installation, disambiguated by its path. */
export function agentDetailPath(agent: Agent) {
return
`/agents/${encodeURIComponent(agent.agentId)}?path=${encodeURIComponent(agent.path)}`;
diff --git a/web/src/lib/channels.ts b/web/src/lib/channels.ts
new file mode 100644
index 0000000..3ad69ca
--- /dev/null
+++ b/web/src/lib/channels.ts
@@ -0,0 +1,70 @@
+// 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.
+
+import * as Setting from "@/Setting";
+
+/** Mirrors object.ChannelProtocol on the server. */
+export function channelProtocol(type: string) {
+ return type === "anthropic" ? "anthropic" : "openai";
+}
+
+/** The env vars a client of one wire format reads its endpoint and key from.
*/
+const protocolEnv: Record<string, {baseUrl: string; token: string}> = {
+ anthropic: {baseUrl: "ANTHROPIC_BASE_URL", token: "ANTHROPIC_AUTH_TOKEN"},
+ openai: {baseUrl: "OPENAI_BASE_URL", token: "OPENAI_API_KEY"},
+};
+
+/**
+ * A stand-in for the key a client refuses to start without. Gateway
authenticates
+ * upstream with the channel's own key, so this value is never used for
anything.
+ */
+const placeholderToken = "casbin-gateway";
+
+export const shells = ["bash", "PowerShell"] as const;
+export type Shell = (typeof shells)[number];
+
+/** The lines that point a client of one wire format at a base URL. */
+export function envSnippet(protocol: string, baseUrl: string, shell: Shell) {
+ const env = protocolEnv[protocol] ?? protocolEnv.openai;
+ const variables: [string, string][] = [
+ [env.baseUrl, baseUrl],
+ [env.token, placeholderToken],
+ ];
+
+ return variables
+ .map(([name, value]) =>
+ shell === "PowerShell" ? `$env:${name} = "${value}"` : `export
${name}="${value}"`,
+ )
+ .join("\n");
+}
+
+/**
+ * The base URL for requests routed by model name, i.e. not through an agent.
+ * An OpenAI client appends /chat/completions to it and an Anthropic one
appends
+ * /v1/messages, which is why the /v1 belongs to only one of them.
+ */
+export function gatewayBaseUrl(protocol: string) {
+ const origin = Setting.ServerUrl || window.location.origin;
+ return protocol === "anthropic" ? origin : `${origin}/v1`;
+}
+
+/** The shell a host is driven from, guessed from an absolute path on it. */
+export function shellForPath(path: string): Shell {
+ return /^[a-zA-Z]:[\\/]/.test(path) ? "PowerShell" : "bash";
+}
+
+/** The shell the browser's own machine is driven from. */
+export function localShell(): Shell {
+ return navigator.userAgent.includes("Windows") ? "PowerShell" : "bash";
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 76e0bb6..d3a6292 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -29,13 +29,13 @@
"All event types": "All event types",
"All outcomes": "All outcomes",
"Auto refresh": "Auto refresh",
- "Base URL copied to clipboard": "Base URL copied to clipboard",
- "Base URL hint": "Point this agent at the base URL below. Requests that
arrive here always go to the channel selected above, whatever model they name.",
+ "Base URL hint": "Give the agent the settings below. Requests that arrive
at this URL always go to the channel selected above, whatever model they name.",
"Channel": "Channel",
"Channel cleared": "Channel cleared",
"Channel hint": "With no channel selected, this agent still talks to its
own upstream and Gateway only watches it.",
"Channel saved": "Channel saved",
- "Copy base URL": "Copy base URL",
+ "Claude Code config hint": "The same variables can live in the \"env\"
block of ~/.claude/settings.json, so every session picks them up without
exporting anything.",
+ "Codex CLI config hint": "The base URL can also be a model provider in
~/.codex/config.toml, selected with model_provider.",
"Copy path": "Copy path",
"Detail": "Detail",
"Details": "Details",
@@ -80,6 +80,7 @@
"Session title": "Session title",
"Target / Model": "Target / Model",
"Time": "Time",
+ "Token hint": "Gateway authenticates upstream with the channel's own key,
so this token is only there because the client insists on one - any value
works.",
"Tool": "Tool",
"Tool use ID": "Tool use ID",
"Turn on monitoring to collect activity": "Turn on monitoring to collect
activity",
@@ -135,13 +136,16 @@
"Failed to get channels": "Failed to get channels",
"Failed to save": "Failed to save",
"Failed to test": "Failed to test",
+ "Model routing hint": "Requests that arrive here are routed by the model
name they carry, to the enabled channel with the smallest priority value that
serves it. To send one agent's requests to this channel whatever model they
name, bind the channel on the agent's page instead.",
"Models": "Models",
"New Channel": "New Channel",
"Priority": "Priority",
"Priority hint": "Smaller number means higher priority, 0 is the highest;
when multiple channels serve the same model, the one with the smaller value is
used first.",
"Status": "Status",
"Test Connectivity": "Test Connectivity",
- "Type": "Type"
+ "Type": "Type",
+ "Usage": "Usage",
+ "Usage hint": "Point any client that speaks this channel's API at Gateway
with these variables."
},
"general": {
"Action": "Action",
@@ -154,6 +158,8 @@
"Certs": "Certs",
"Client IP": "Client IP",
"Client ip": "Client ip",
+ "Copied to clipboard": "Copied to clipboard",
+ "Copy": "Copy",
"Count": "Count",
"Create time": "Create time",
"Created time": "Created time",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 00969ae..267f646 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -29,13 +29,13 @@
"All event types": "全部事件类型",
"All outcomes": "全部结果",
"Auto refresh": "自动刷新",
- "Base URL copied to clipboard": "Base URL 已复制到剪贴板",
- "Base URL hint": "把该 Agent 的 base URL
设成下面这个地址。到达这里的请求一律转发到上面选定的渠道,与请求里的模型名无关。",
+ "Base URL hint": "把下面的设置配给该 Agent。到达这个地址的请求一律转发到上面选定的渠道,与请求里的模型名无关。",
"Channel": "渠道",
"Channel cleared": "已清除渠道",
"Channel hint": "未选择渠道时,该 Agent 仍直连自己的上游,网关只做观测。",
"Channel saved": "渠道已保存",
- "Copy base URL": "复制 Base URL",
+ "Claude Code config hint": "这两个变量也可以写进 ~/.claude/settings.json 的 env
段,这样不用每次 export,所有会话都会生效。",
+ "Codex CLI config hint": "base URL 也可以写成 ~/.codex/config.toml 里的一个 model
provider,再用 model_provider 选中它。",
"Copy path": "复制路径",
"Detail": "详情",
"Details": "详情",
@@ -80,6 +80,7 @@
"Session title": "会话标题",
"Target / Model": "目标 / 模型",
"Time": "时间",
+ "Token hint": "网关是用渠道自己的密钥去认证上游的,这里的 token 只是因为客户端一定要有一个,填什么都行。",
"Tool": "工具",
"Tool use ID": "工具调用 ID",
"Turn on monitoring to collect activity": "开启监控后才会收集活动",
@@ -135,13 +136,16 @@
"Failed to get channels": "获取渠道列表失败",
"Failed to save": "保存失败",
"Failed to test": "测试失败",
+ "Model routing hint": "到达这里的请求按自己带的模型名路由,转发到支持该模型、且 priority
值最小的已启用渠道。如果想让某个 Agent 的请求不管模型名一律走这个渠道,请到该 Agent 的页面上绑定。",
"Models": "模型",
"New Channel": "新建渠道",
"Priority": "优先级",
"Priority hint": "数字越小优先级越高,0 为最高;同模型多渠道时优先使用数值小的渠道。",
"Status": "状态",
"Test Connectivity": "测试连接",
- "Type": "类型"
+ "Type": "类型",
+ "Usage": "使用方法",
+ "Usage hint": "任何说这个渠道所用 API 的客户端,配上下面的变量就能走网关。"
},
"general": {
"Action": "操作",
@@ -154,6 +158,8 @@
"Certs": "证书",
"Client IP": "客户端 IP",
"Client ip": "客户端IP",
+ "Copied to clipboard": "已复制到剪贴板",
+ "Copy": "复制",
"Count": "计数",
"Create time": "创建时间",
"Created time": "创建时间",
diff --git a/web/src/pages/AgentDetailPage.tsx
b/web/src/pages/AgentDetailPage.tsx
index 2b7f6a7..4b850f1 100644
--- a/web/src/pages/AgentDetailPage.tsx
+++ b/web/src/pages/AgentDetailPage.tsx
@@ -23,6 +23,7 @@ import * as ChannelBackend from "@/backend/ChannelBackend";
import * as Setting from "@/Setting";
import {AgentIcon} from "@/components/AgentIcon";
import {DataTable, type Column} from "@/components/DataTable";
+import {EnvSnippet} from "@/components/EnvSnippet";
import {Result, UnauthorizedResult} from "@/components/Result";
import {Alert, AlertDescription} from "@/components/ui/alert";
import {Badge} from "@/components/ui/badge";
@@ -42,11 +43,13 @@ import {cn} from "@/lib/utils";
import {
agentKey,
agentProxyBaseUrl,
+ agentSetupNoteKey,
getOutcomeVariant,
monitorAgentId,
useAgents,
useAgentSessions,
} from "@/lib/agents";
+import {channelProtocol, shellForPath} from "@/lib/channels";
import type {Account, Agent, AgentRecord, AgentSession, Channel} from
"@/types";
/** Radix rejects an empty item value, so "unbound" needs a stand-in. */
@@ -121,7 +124,7 @@ function MonitoringCard({
);
}
-/** Which upstream this agent's requests go to, and the base URL that sends
them there. */
+/** Which upstream this agent's requests go to, and how to point it at them. */
function ChannelCard({
agent,
channels,
@@ -133,7 +136,10 @@ function ChannelCard({
busy: boolean;
onChange: (channel: string) => void;
}) {
- const baseUrl = agentProxyBaseUrl(agent.agentId);
+ // The bound channel decides the wire format, and with it the variable names
+ // the agent has to be given. It is undefined while the channels load.
+ const bound = channels.find(channel => `${channel.owner}/${channel.name}`
=== agent.channel);
+ const noteKey = agentSetupNoteKey(agent.agentId);
return (
<Card>
@@ -156,6 +162,8 @@ function ChannelCard({
{channels.map(channel => (
<SelectItem key={`${channel.owner}/${channel.name}`}
value={`${channel.owner}/${channel.name}`}>
{channel.displayName || channel.name}
+ {/* The type is the wire format, which has to match the one
the agent speaks. */}
+ <span className="ml-2 text-xs
text-muted-foreground">{channel.type}</span>
</SelectItem>
))}
</SelectContent>
@@ -163,26 +171,18 @@ function ChannelCard({
{agent.channel === "" ? (
<p className="text-sm
text-muted-foreground">{i18next.t("agent:Channel hint")}</p>
- ) : (
+ ) : bound === undefined ? null : (
<>
<p className="text-sm
text-muted-foreground">{i18next.t("agent:Base URL hint")}</p>
- <div className="flex items-start gap-1">
- <code className="min-w-0 flex-1 break-all
text-xs">{baseUrl}</code>
- <Tooltip title={i18next.t("agent:Copy base URL")}>
- <Button
- variant="ghost"
- size="icon"
- className="h-6 w-6 shrink-0"
- aria-label={i18next.t("agent:Copy base URL")}
- onClick={() => {
- copy(baseUrl);
- Setting.showMessage("success", i18next.t("agent:Base URL
copied to clipboard"));
- }}
- >
- <Copy className="h-3.5 w-3.5" />
- </Button>
- </Tooltip>
- </div>
+ <EnvSnippet
+ protocol={channelProtocol(bound.type)}
+ baseUrl={agentProxyBaseUrl(agent.agentId)}
+ defaultShell={shellForPath(agent.path)}
+ />
+ <p className="text-sm
text-muted-foreground">{i18next.t("agent:Token hint")}</p>
+ {noteKey === "" ? null : (
+ <p className="text-sm
text-muted-foreground">{i18next.t(noteKey)}</p>
+ )}
</>
)}
</CardContent>
diff --git a/web/src/pages/ChannelEditPage.tsx
b/web/src/pages/ChannelEditPage.tsx
index e4a97f9..c38b08d 100644
--- a/web/src/pages/ChannelEditPage.tsx
+++ b/web/src/pages/ChannelEditPage.tsx
@@ -19,6 +19,7 @@ import i18next from "i18next";
import * as ChannelBackend from "@/backend/ChannelBackend";
import * as Setting from "@/Setting";
+import {EnvSnippet} from "@/components/EnvSnippet";
import {FormRow, PageHeader} from "@/components/FormRow";
import {Result} from "@/components/Result";
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert";
@@ -37,16 +38,19 @@ import {
} from "@/components/ui/select";
import {PageSpinner, Spinner} from "@/components/ui/spinner";
import {TagsInput} from "@/components/ui/tags-input";
+import {channelProtocol, gatewayBaseUrl, localShell} from "@/lib/channels";
import type {Channel, ChannelTestResult} from "@/types";
// Hard-coded presets for milestone 1.1 (per channel type).
const BASE_URL_PRESETS: Record<string, string[]> = {
openai: ["https://api.openai.com/v1"],
+ anthropic: ["https://api.anthropic.com"],
custom: ["https://oneapi.example.com", "https://api.deepseek.com/v1",
"https://api.moonshot.cn/v1"],
};
const MODEL_PRESETS: Record<string, string[]> = {
openai: ["gpt-5.5", "gpt-5", "gpt-5-mini", "o3", "o4-mini"],
+ anthropic: ["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5"],
custom: ["deepseek-chat", "deepseek-reasoner", "moonshot-v1-8k", "qwen-max"],
};
@@ -74,6 +78,36 @@ function buildOpenAiUrl(baseUrl: string, endpoint: string) {
return url.toString();
}
+// Mirrors object.BuildAnthropicUrl: the base URL is bare and the endpoint
+// carries the /v1 prefix, the opposite of the OpenAI convention.
+function buildAnthropicUrl(baseUrl: string, endpoint: string) {
+ let url: URL;
+ try {
+ url = new URL(baseUrl);
+ } catch {
+ return "";
+ }
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
+ return "";
+ }
+
+ let path = url.pathname.replace(/\/+$/, "");
+ if (path.endsWith(endpoint)) {
+ path = path.slice(0, path.length - endpoint.length);
+ }
+ path = path.replace(/\/+$/, "").replace(/\/v1$/, "");
+
+ url.pathname = path + endpoint;
+ return url.toString();
+}
+
+/** The upstream URL requests to a channel of this type end up at. */
+function buildUpstreamUrl(baseUrl: string, type: string) {
+ return channelProtocol(type) === "anthropic"
+ ? buildAnthropicUrl(baseUrl, "/v1/messages")
+ : buildOpenAiUrl(baseUrl, "/chat/completions");
+}
+
export default function ChannelEditPage() {
const {owner = "", channelName = ""} = useParams();
const navigate = useNavigate();
@@ -161,7 +195,7 @@ export default function ChannelEditPage() {
);
}
- const upstreamUrl = buildOpenAiUrl(channel.baseUrl, "/chat/completions");
+ const upstreamUrl = buildUpstreamUrl(channel.baseUrl, channel.type);
return (
<div className="p-4 md:p-6">
@@ -191,6 +225,7 @@ export default function ChannelEditPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value="openai">OpenAI</SelectItem>
+ <SelectItem value="anthropic">Anthropic</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
@@ -209,7 +244,7 @@ export default function ChannelEditPage() {
<Combobox
allowCustomValue
value={channel.baseUrl}
- placeholder="https://api.openai.com/v1"
+ placeholder={channel.type === "anthropic" ?
"https://api.anthropic.com" : "https://api.openai.com/v1"}
options={(BASE_URL_PRESETS[channel.type] ?? []).map(url =>
({value: url}))}
onChange={value => setField("baseUrl", value)}
/>
@@ -224,7 +259,7 @@ export default function ChannelEditPage() {
<FormRow label={i18next.t("channel:Models")}>
<TagsInput
value={channel.models}
- placeholder="gpt-5, gpt-5-mini"
+ placeholder={channel.type === "anthropic" ? "claude-opus-5,
claude-sonnet-5" : "gpt-5, gpt-5-mini"}
suggestions={MODEL_PRESETS[channel.type] ?? []}
onChange={value => setField("models", value)}
/>
@@ -261,6 +296,19 @@ export default function ChannelEditPage() {
</CardContent>
</Card>
+ <Card className="mt-4">
+ <CardContent className="space-y-3 p-4">
+ <div className="text-base
font-medium">{i18next.t("channel:Usage")}</div>
+ <p className="text-sm
text-muted-foreground">{i18next.t("channel:Usage hint")}</p>
+ <EnvSnippet
+ protocol={channelProtocol(channel.type)}
+ baseUrl={gatewayBaseUrl(channelProtocol(channel.type))}
+ defaultShell={localShell()}
+ />
+ <p className="text-sm
text-muted-foreground">{i18next.t("channel:Model routing hint")}</p>
+ </CardContent>
+ </Card>
+
{result ? (
<Alert className="mt-4" variant={result.success ? "success" :
"destructive"}>
{result.success ? <CircleCheck /> : <CircleX />}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]