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 5d51c9a feat(agent): support the Gemini CLI
5d51c9a is described below
commit 5d51c9a6435296479f3337e8f8fc36263e7334a7
Author: Yang Luo <[email protected]>
AuthorDate: Wed Sep 2 21:17:54 2026 +0800
feat(agent): support the Gemini CLI
---
README.md | 2 +-
README_zh.md | 2 +-
agent/fingerprint_load_test.go | 3 +-
agent/fingerprints/gemini-cli.json | 8 +
agentconfig/layout.go | 12 +
agentpatch/unsupported.go | 4 +-
agentprovider/dotenv.go | 121 ++++++++
agentprovider/gemini.go | 267 +++++++++++++++++
controllers/gemini.go | 213 +++++++++++++
controllers/proxy.go | 81 ++++-
protocol/gemini.go | 583 ++++++++++++++++++++++++++++++++++++
protocol/protocol.go | 4 +
routers/filter.go | 5 +-
routers/router.go | 7 +
web/src/components/AgentIcon.tsx | 1 +
web/src/components/ProviderCard.tsx | 15 +-
web/src/lib/agents.ts | 17 ++
web/src/locales/en/data.json | 2 +
web/src/locales/zh/data.json | 2 +
19 files changed, 1322 insertions(+), 27 deletions(-)
diff --git a/README.md b/README.md
index 3979ef1..b88e87a 100644
--- a/README.md
+++ b/README.md
@@ -107,7 +107,7 @@ 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. The API the provider serves need not be the same one: Gateway
translates between all three, in both directions and for streamed answers too,
so Codex runs on DeepSeek, Kimi or Qwen, and Claude Code runs on any of them
just as well. A provider serving the [...]
+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 [...]
### No API key: keep the sign-in the agent already has
diff --git a/README_zh.md b/README_zh.md
index 98d0517..ea2412d 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -105,7 +105,7 @@ 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
这套线格式,只剩这一种。Provider 用的是哪套 API 不必与之相同:Gateway 会在这三套之间双向转换,流式回复也一样,所以 Codex 能跑在
DeepSeek、Kimi、Qwen 上,Claude Code 同样能跑在它们上面。如果 Provider 正好就说请求进来的那套
API,就原样转发,一个字节都不改。唯一由 Gateway 自己应答的是 Anthropic 客户端每轮之前问的 token 数:绑定的 Provider
没有对应接口时,由 Gateway 估算。
+同一个 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 估算。
### 没有 API Key:沿用 Agent 已有的登录
diff --git a/agent/fingerprint_load_test.go b/agent/fingerprint_load_test.go
index 96810b9..0156710 100644
--- a/agent/fingerprint_load_test.go
+++ b/agent/fingerprint_load_test.go
@@ -23,7 +23,8 @@ import (
func TestFingerprintsLoad(t *testing.T) {
want := []string{
"claude-code", "claude-desktop", "codex-cli", "codex",
"cursor-agent",
- "cursor", "dsh", "openagent", "openclaw", "opencode-desktop",
"opencode",
+ "cursor", "dsh", "gemini-cli", "openagent", "openclaw",
"opencode-desktop",
+ "opencode",
"windsurf",
}
diff --git a/agent/fingerprints/gemini-cli.json
b/agent/fingerprints/gemini-cli.json
new file mode 100644
index 0000000..1d3820e
--- /dev/null
+++ b/agent/fingerprints/gemini-cli.json
@@ -0,0 +1,8 @@
+{
+ "id": "gemini-cli",
+ "displayName": "Gemini CLI",
+ "installUrl": "https://google-gemini.github.io/gemini-cli/",
+ "execName": "gemini",
+ "stateDir": "gemini",
+ "npmPackage": "@google/gemini-cli"
+}
diff --git a/agentconfig/layout.go b/agentconfig/layout.go
index 5ba4d8b..77473c1 100644
--- a/agentconfig/layout.go
+++ b/agentconfig/layout.go
@@ -171,6 +171,18 @@ var layouts = map[string]layout{
"codex_vscode": codexLayout,
"cursor": cursorLayout,
"cursor-agent": cursorLayout,
+ // The Gemini CLI reads the shared ~/.agents skills too, and prefers
them
+ // over its own; writes go to its own directory, which is the first user
+ // source.
+ "gemini-cli": {
+ skills: &skillLayout{sources: []skillSource{
+ userSkills(".gemini", "skills"),
+ userSkills(".agents", "skills"),
+ pluginSkills(".gemini", "extensions"),
+ }},
+ mcp: &mcpLayout{file: under(".gemini", "settings.json"),
store: &jsonStore{paths: [][]string{{"mcpServers"}}}},
+ prompt: promptFile("GEMINI.md", ".gemini"),
+ },
"windsurf": {
mcp: &mcpLayout{
file: under(".codeium", "windsurf", "mcp_config.json"),
diff --git a/agentpatch/unsupported.go b/agentpatch/unsupported.go
index aea890b..6ca284b 100644
--- a/agentpatch/unsupported.go
+++ b/agentpatch/unsupported.go
@@ -15,7 +15,9 @@
package agentpatch
func init() {
- for _, id := range []string{"cursor", "cursor-agent", "windsurf",
"opencode", "opencode-desktop"} {
+ for _, id := range []string{
+ "cursor", "cursor-agent", "windsurf", "opencode",
"opencode-desktop", "gemini-cli",
+ } {
register(unimplemented{id: id})
}
}
diff --git a/agentprovider/dotenv.go b/agentprovider/dotenv.go
new file mode 100644
index 0000000..0125f82
--- /dev/null
+++ b/agentprovider/dotenv.go
@@ -0,0 +1,121 @@
+// 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 agentprovider
+
+import (
+ "strings"
+)
+
+// envFile is a .env file kept as the lines it is made of, so the keys Gateway
+// does not own stay where they were, comments and all.
+type envFile struct {
+ lines []string
+}
+
+func readEnvFile(path string) (*envFile, error) {
+ data, _, _, err := readFile(path)
+ if err != nil {
+ return nil, err
+ }
+
+ file := &envFile{}
+ text := strings.ReplaceAll(string(data), "\r\n", "\n")
+ if strings.TrimSpace(text) != "" {
+ file.lines = strings.Split(strings.TrimSuffix(text, "\n"), "\n")
+ }
+ return file, nil
+}
+
+// keyOf is the variable one line assigns, empty for a comment or a blank line.
+func keyOf(line string) string {
+ trimmed := strings.TrimSpace(line)
+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
+ return ""
+ }
+
+ name, _, found := strings.Cut(trimmed, "=")
+ if !found {
+ return ""
+ }
+ return strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(name),
"export "))
+}
+
+func (file *envFile) get(key string) (string, bool) {
+ for _, line := range file.lines {
+ if keyOf(line) != key {
+ continue
+ }
+ _, value, _ := strings.Cut(line, "=")
+ return unquoteEnv(strings.TrimSpace(value)), true
+ }
+ return "", false
+}
+
+// set writes a variable, in place when the file already assigns it so the file
+// keeps its order.
+func (file *envFile) set(key string, value string) {
+ line := key + "=" + quoteEnv(value)
+ for i, existing := range file.lines {
+ if keyOf(existing) == key {
+ file.lines[i] = line
+ return
+ }
+ }
+ file.lines = append(file.lines, line)
+}
+
+func (file *envFile) remove(key string) {
+ kept := make([]string, 0, len(file.lines))
+ for _, line := range file.lines {
+ if keyOf(line) != key {
+ kept = append(kept, line)
+ }
+ }
+ file.lines = kept
+}
+
+func (file *envFile) bytes() []byte {
+ if len(file.lines) == 0 {
+ return nil
+ }
+ return []byte(strings.Join(file.lines, "\n") + "\n")
+}
+
+// quoteEnv quotes a value the shell rules would otherwise cut short. A key or
a
+// URL needs none of it, which is what these files usually hold.
+func quoteEnv(value string) string {
+ if value == "" {
+ return ""
+ }
+ if !strings.ContainsAny(value, " \t\"'#$`\\") {
+ return value
+ }
+ return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(value) +
`"`
+}
+
+func unquoteEnv(value string) string {
+ if len(value) < 2 {
+ return value
+ }
+
+ switch {
+ case strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`):
+ inner := value[1 : len(value)-1]
+ return strings.NewReplacer(`\"`, `"`, `\\`, `\`).Replace(inner)
+ case strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'"):
+ return value[1 : len(value)-1]
+ }
+ return value
+}
diff --git a/agentprovider/gemini.go b/agentprovider/gemini.go
new file mode 100644
index 0000000..ced253a
--- /dev/null
+++ b/agentprovider/gemini.go
@@ -0,0 +1,267 @@
+// 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 agentprovider
+
+import (
+ "path/filepath"
+ "strings"
+
+ "github.com/apache/casbin-gateway/agenthome"
+)
+
+// geminiBuiltin is what the Gemini CLI talks to with no endpoint of its own:
+// the Google sign-in, whose model the CLI picks by itself.
+const geminiBuiltin = "Gemini"
+
+// geminiApiKeyAuth is the auth method that reads GEMINI_API_KEY and honours
+// GOOGLE_GEMINI_BASE_URL. The CLI stores the method it last signed in with,
and
+// that stored value wins over anything the environment says, so a switch has
to
+// write it or an account already signed in to Google would keep going there.
+const geminiApiKeyAuth = "gemini-api-key"
+
+// geminiAuthKey is where that method is stored, and how the previous value is
+// kept beside the environment variables a switch owns.
+const geminiAuthKey = "security.auth.selectedType"
+
+// geminiLocalKey stands in for a key the endpoint does not have. The CLI
+// refuses to start with an empty GEMINI_API_KEY, while the gateway it is being
+// pointed at takes any credential from this host.
+const geminiLocalKey = "casbin-gateway"
+
+// The environment Gateway owns in ~/.gemini/.env.
+var geminiKeys = []string{
+ "GOOGLE_GEMINI_BASE_URL",
+ "GEMINI_API_KEY",
+ "GEMINI_MODEL",
+}
+
+type geminiWriter struct{}
+
+func init() {
+ register(geminiWriter{})
+}
+
+func (geminiWriter) AgentId() string { return "gemini-cli" }
+
+func (geminiWriter) Protocol() string { return "gemini" }
+
+func (w geminiWriter) Plan(target Target, endpoint Endpoint) ([]File, error) {
+ envPath, settingsPath, err := w.paths(target)
+ if err != nil {
+ return nil, err
+ }
+
+ preview := &envFile{}
+ for key, value := range w.env(endpoint, true) {
+ preview.set(key, value)
+ }
+ auth, err := encodeJSON(map[string]any{
+ "security": map[string]any{"auth":
map[string]any{"selectedType": geminiApiKeyAuth}},
+ })
+ if err != nil {
+ return nil, err
+ }
+ return []File{
+ {Path: envPath, Format: "env", Preview:
string(preview.bytes())},
+ {Path: settingsPath, Format: "json", Preview: string(auth)},
+ }, nil
+}
+
+func (w geminiWriter) Apply(target Target, endpoint Endpoint)
(map[string]string, error) {
+ envPath, settingsPath, err := w.paths(target)
+ if err != nil {
+ return nil, err
+ }
+ env, err := readEnvFile(envPath)
+ if err != nil {
+ return nil, err
+ }
+ settings, _, err := readJSON(settingsPath)
+ if err != nil {
+ return nil, err
+ }
+
+ previous := map[string]string{}
+ for _, key := range geminiKeys {
+ if value, ok := env.get(key); ok {
+ previous[key] = value
+ }
+ env.remove(key)
+ }
+ for key, value := range w.env(endpoint, false) {
+ env.set(key, value)
+ }
+
+ auth := nestedObject(settings, "security", "auth")
+ if value, ok := auth["selectedType"].(string); ok {
+ previous[geminiAuthKey] = value
+ }
+ auth["selectedType"] = geminiApiKeyAuth
+
+ data, err := encodeJSON(settings)
+ if err != nil {
+ return nil, err
+ }
+
+ changes := &txn{}
+ if err := changes.write(envPath, env.bytes()); err != nil {
+ changes.abort()
+ return nil, err
+ }
+ if err := changes.write(settingsPath, data); err != nil {
+ changes.abort()
+ return nil, err
+ }
+ if err := changes.commit(); err != nil {
+ return nil, err
+ }
+ return previous, nil
+}
+
+func (w geminiWriter) Restore(target Target, previous map[string]string) error
{
+ envPath, settingsPath, err := w.paths(target)
+ if err != nil {
+ return err
+ }
+ env, err := readEnvFile(envPath)
+ if err != nil {
+ return err
+ }
+ settings, _, err := readJSON(settingsPath)
+ if err != nil {
+ return err
+ }
+
+ for _, key := range geminiKeys {
+ if value, ok := previous[key]; ok {
+ env.set(key, value)
+ } else {
+ env.remove(key)
+ }
+ }
+
+ if auth := objectAt(objectAt(settings, "security"), "auth"); auth !=
nil {
+ if value, ok := previous[geminiAuthKey]; ok {
+ auth["selectedType"] = value
+ } else {
+ delete(auth, "selectedType")
+ }
+ }
+
+ data, err := encodeJSON(settings)
+ if err != nil {
+ return err
+ }
+
+ changes := &txn{}
+ if err := changes.write(envPath, env.bytes()); err != nil {
+ changes.abort()
+ return err
+ }
+ if err := changes.write(settingsPath, data); err != nil {
+ changes.abort()
+ return err
+ }
+ return changes.commit()
+}
+
+func (w geminiWriter) Current(target Target) (string, error) {
+ env, err := w.envOf(target)
+ if err != nil {
+ return "", err
+ }
+ baseUrl, _ := env.get("GOOGLE_GEMINI_BASE_URL")
+ return baseUrl, nil
+}
+
+func (w geminiWriter) Builtin(target Target, previous map[string]string)
string {
+ if previous != nil {
+ return emptyAs(previous["GEMINI_MODEL"], geminiBuiltin)
+ }
+
+ env, err := w.envOf(target)
+ if err != nil {
+ return geminiBuiltin
+ }
+ // A model beside an endpoint Gateway wrote is Gateway's, not the one
the
+ // CLI would pick for itself.
+ if baseUrl, _ := env.get("GOOGLE_GEMINI_BASE_URL");
strings.Contains(baseUrl, "/v1/agents/") {
+ return geminiBuiltin
+ }
+ if model, _ := env.get("GEMINI_MODEL"); model != "" {
+ return model
+ }
+
+ _, settingsPath, err := w.paths(target)
+ if err != nil {
+ return geminiBuiltin
+ }
+ settings, _, err := readJSON(settingsPath)
+ if err != nil {
+ return geminiBuiltin
+ }
+ return emptyAs(stringAt(objectAt(settings, "model"), "name"),
geminiBuiltin)
+}
+
+// env is what a switch writes into ~/.gemini/.env. The base URL is what
selects
+// the endpoint; the key is only there because the CLI insists on one.
+func (geminiWriter) env(endpoint Endpoint, masked bool) map[string]string {
+ key := emptyAs(endpoint.ApiKey, geminiLocalKey)
+ if masked {
+ key = maskSecret(key)
+ }
+
+ env := map[string]string{
+ "GOOGLE_GEMINI_BASE_URL": endpoint.BaseUrl,
+ "GEMINI_API_KEY": key,
+ }
+ if endpoint.Model != "" {
+ env["GEMINI_MODEL"] = endpoint.Model
+ }
+ return env
+}
+
+func (geminiWriter) envOf(target Target) (*envFile, error) {
+ home, err := agenthome.Resolve(target.Owner)
+ if err != nil {
+ return nil, err
+ }
+ return readEnvFile(filepath.Join(home, ".gemini", ".env"))
+}
+
+// paths are the two files a switch writes: the environment the CLI loads, and
+// the settings that say which sign-in it loads it for.
+func (geminiWriter) paths(target Target) (string, string, error) {
+ home, err := agenthome.Resolve(target.Owner)
+ if err != nil {
+ return "", "", err
+ }
+ return filepath.Join(home, ".gemini", ".env"), filepath.Join(home,
".gemini", "settings.json"), nil
+}
+
+// nestedObject is the object at a path of keys, adding the ones the file does
+// not have yet.
+func nestedObject(config map[string]any, keys ...string) map[string]any {
+ current := config
+ for _, key := range keys {
+ next := objectAt(current, key)
+ if next == nil {
+ next = map[string]any{}
+ current[key] = next
+ }
+ current = next
+ }
+ return current
+}
diff --git a/controllers/gemini.go b/controllers/gemini.go
new file mode 100644
index 0000000..79edd54
--- /dev/null
+++ b/controllers/gemini.go
@@ -0,0 +1,213 @@
+// 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 controllers
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strings"
+
+ "github.com/apache/casbin-gateway/object"
+ "github.com/apache/casbin-gateway/protocol"
+ "github.com/beego/beego"
+)
+
+// The Gemini API puts the model and the call in one path segment, spelled
+// "<model>:<action>", and asks for a stream by the action rather than by a
+// field of the body.
+const (
+ geminiGenerateAction = "generateContent"
+ geminiStreamAction = "streamGenerateContent"
+ geminiCountTokensAction = "countTokens"
+)
+
+var (
+ geminiGenerate = proxyTarget{protocol: protocol.Gemini, endpoint:
"/v1beta/models:generateContent"}
+ geminiStream = proxyTarget{protocol: protocol.Gemini, endpoint:
"/v1beta/models:streamGenerateContent"}
+ geminiCountTokens = proxyTarget{
+ protocol: protocol.Gemini, endpoint:
"/v1beta/models:countTokens", countTokens: true,
+ }
+)
+
+// The context a model entry claims, for the client that sizes its own prompt
+// against the answer. The gateway does not know what the bound provider
serves,
+// so these are the limits of the Gemini models the clients expect here.
+const (
+ geminiInputTokenLimit = 1048576
+ geminiOutputTokenLimit = 65536
+)
+
+// GeminiGenerate is the Gemini API entry point, which the Gemini CLI reaches
+// its provider on. No provider serves this API, so the request is translated
on
+// the way out and the answer back on the way in.
+func (c *ApiController) GeminiGenerate() {
+ route, ok := c.readGeminiRoute()
+ if !ok {
+ return
+ }
+ c.forwardByModel(route)
+}
+
+// AgentGeminiGenerate is the per-agent entry point of the same API: an agent
+// pointed at ".../v1/agents/<agentId>" appends
"/v1beta/models/<model>:<action>"
+// to it, which is the path the Google client library builds.
+func (c *ApiController) AgentGeminiGenerate() {
+ route, ok := c.readGeminiRoute()
+ if !ok {
+ return
+ }
+ c.forwardByAgent(route)
+}
+
+// readGeminiRoute reads the model and the call out of the path, since this API
+// names neither in the body.
+func (c *ApiController) readGeminiRoute() (*proxyRoute, bool) {
+ model, action := geminiCall(c.Ctx.Input.Param(":model"))
+
+ target := geminiGenerate
+ switch action {
+ case geminiGenerateAction:
+ case geminiStreamAction:
+ target = geminiStream
+ case geminiCountTokensAction:
+ target = geminiCountTokens
+ default:
+ c.writeGeminiError(http.StatusNotFound, "not_found_error",
+ "the gateway does not serve the "+action+" method")
+ return nil, false
+ }
+
+ if !c.allowRelayFor(target) {
+ return nil, false
+ }
+ if !json.Valid(c.Ctx.Input.RequestBody) {
+ c.writeGeminiError(http.StatusBadRequest,
"invalid_request_error", "invalid request body")
+ return nil, false
+ }
+ return c.newProxyRoute(target, model, action == geminiStreamAction)
+}
+
+// geminiCall splits "<model>:<action>" into the two. A path segment without an
+// action names the model alone, which is what the retrieve-a-model endpoint
is.
+func geminiCall(param string) (string, string) {
+ param = strings.TrimPrefix(param, "models/")
+ model, action, found := strings.Cut(param, ":")
+ if !found {
+ return param, ""
+ }
+ return model, action
+}
+
+// GeminiModels answers the model list a Gemini client fills its picker from.
+func (c *ApiController) GeminiModels() {
+ if !c.allowGeminiListing() {
+ return
+ }
+
+ models, err := object.ListEnabledModels()
+ if err != nil {
+ beego.Error("model listing failed:", err)
+ c.writeGeminiError(http.StatusBadGateway, "server_error",
"provider lookup failed")
+ return
+ }
+ c.writeProxyBody(http.StatusOK, mustEncode(geminiModelList(models)))
+}
+
+// AgentGeminiModels is the same list narrowed to what the agent's own provider
+// chain serves.
+func (c *ApiController) AgentGeminiModels() {
+ if !c.allowGeminiListing() {
+ return
+ }
+
+ providers, err :=
object.GetProvidersByAgent(c.Ctx.Input.Param(":agentId"))
+ if err != nil {
+ c.writeGeminiError(http.StatusBadRequest,
"invalid_request_error", err.Error())
+ return
+ }
+ c.writeProxyBody(http.StatusOK,
mustEncode(geminiModelList(object.ModelsOfProviders(providers))))
+}
+
+// GeminiModel answers the retrieve-a-model endpoint a Gemini client checks its
+// configured model with. A name reaches an upstream as long as one provider is
+// enabled, which is what "available" means here.
+func (c *ApiController) GeminiModel() {
+ if !c.allowGeminiListing() {
+ return
+ }
+
+ model, _ := geminiCall(c.Ctx.Input.Param(":model"))
+ if _, err := object.GetProvidersByModel(model); err != nil {
+ if errors.Is(err, object.ErrNoProviderAvailable) {
+ c.writeGeminiError(http.StatusNotFound,
"not_found_error", err.Error())
+ } else {
+ beego.Error("model lookup failed:", err)
+ c.writeGeminiError(http.StatusBadGateway,
"server_error", "provider lookup failed")
+ }
+ return
+ }
+ c.writeProxyBody(http.StatusOK, mustEncode(geminiModelEntry(model)))
+}
+
+// AgentGeminiModel is the same check against the agent's own provider chain.
+func (c *ApiController) AgentGeminiModel() {
+ if !c.allowGeminiListing() {
+ return
+ }
+
+ if _, err := object.GetProvidersByAgent(c.Ctx.Input.Param(":agentId"));
err != nil {
+ c.writeGeminiError(http.StatusBadRequest,
"invalid_request_error", err.Error())
+ return
+ }
+ model, _ := geminiCall(c.Ctx.Input.Param(":model"))
+ c.writeProxyBody(http.StatusOK, mustEncode(geminiModelEntry(model)))
+}
+
+func geminiModelList(models []string) map[string]any {
+ entries := []map[string]any{}
+ for _, model := range models {
+ entries = append(entries, geminiModelEntry(model))
+ }
+ return map[string]any{"models": entries}
+}
+
+func geminiModelEntry(model string) map[string]any {
+ return map[string]any{
+ "name": "models/" + model,
+ "baseModelId": model,
+ "version": "001",
+ "displayName": model,
+ "description": "Served through Casbin Gateway",
+ "inputTokenLimit": geminiInputTokenLimit,
+ "outputTokenLimit": geminiOutputTokenLimit,
+ "supportedGenerationMethods": []string{geminiGenerateAction,
geminiStreamAction, geminiCountTokensAction},
+ }
+}
+
+// allowGeminiListing gates the listing on the same token the proxy endpoints
ask
+// an off-box caller for.
+func (c *ApiController) allowGeminiListing() bool {
+ if c.allowRelay() {
+ return true
+ }
+ c.writeGeminiError(http.StatusUnauthorized, "authentication_error",
+ "this relay is reachable from the network, so it needs the
token shown next to the provider in Casbin Gateway")
+ return false
+}
+
+func (c *ApiController) writeGeminiError(statusCode int, kind string, message
string) {
+ c.writeProxyError(protocol.Of(protocol.Gemini), statusCode, kind,
message)
+}
diff --git a/controllers/proxy.go b/controllers/proxy.go
index 5a3947a..a09b5fd 100644
--- a/controllers/proxy.go
+++ b/controllers/proxy.go
@@ -258,6 +258,12 @@ func (c *ApiController) proxyByModel(target proxyTarget) {
if !ok {
return
}
+ c.forwardByModel(route)
+}
+
+// forwardByModel is the rest of that, for an entry point that read its route
+// somewhere other than the request body.
+func (c *ApiController) forwardByModel(route *proxyRoute) {
route.source = "model: " + route.model
// Every way out of a proxy entry point ends the client request, which
is
// what a record describes, so this is the only place one is written.
@@ -286,6 +292,12 @@ func (c *ApiController) proxyByAgent(target proxyTarget) {
if !ok {
return
}
+ c.forwardByAgent(route)
+}
+
+// forwardByAgent is the rest of that, for an entry point that read its route
+// somewhere other than the request body.
+func (c *ApiController) forwardByAgent(route *proxyRoute) {
agentId := c.Ctx.Input.Param(":agentId")
route.source = "agent: " + agentId
if route.record != nil {
@@ -312,36 +324,51 @@ func (c *ApiController) proxyByAgent(target proxyTarget) {
}
func (c *ApiController) readProxyRoute(target proxyTarget) (*proxyRoute, bool)
{
- codec := protocol.Of(target.protocol)
- if !c.allowRelay() {
- c.writeProxyError(codec, http.StatusUnauthorized,
"authentication_error",
- "this relay is reachable from the network, so it needs
the token shown next to the provider in Casbin Gateway")
+ if !c.allowRelayFor(target) {
return nil, false
}
- rawBody := c.Ctx.Input.RequestBody
-
var fields routingFields
- if err := json.Unmarshal(rawBody, &fields); err != nil {
- c.writeProxyError(codec, http.StatusBadRequest,
"invalid_request_error", "invalid request body")
+ if err := json.Unmarshal(c.Ctx.Input.RequestBody, &fields); err != nil {
+ c.writeProxyError(protocol.Of(target.protocol),
http.StatusBadRequest,
+ "invalid_request_error", "invalid request body")
return nil, false
}
- if fields.Model == "" {
+ return c.newProxyRoute(target, fields.Model, fields.Stream)
+}
+
+// allowRelayFor answers an off-box caller without the relay token in the
format
+// it called in, and reports whether the request may go on.
+func (c *ApiController) allowRelayFor(target proxyTarget) bool {
+ if c.allowRelay() {
+ return true
+ }
+ c.writeProxyError(protocol.Of(target.protocol),
http.StatusUnauthorized, "authentication_error",
+ "this relay is reachable from the network, so it needs the
token shown next to the provider in Casbin Gateway")
+ return false
+}
+
+// newProxyRoute builds the route the forwarding works off. The model and the
+// stream flag come out of the body for most APIs and out of the URL for
Gemini,
+// which names them there.
+func (c *ApiController) newProxyRoute(target proxyTarget, model string, stream
bool) (*proxyRoute, bool) {
+ codec := protocol.Of(target.protocol)
+ if model == "" {
c.writeProxyError(codec, http.StatusBadRequest,
"invalid_request_error", "model is required")
return nil, false
}
route := &proxyRoute{
- target: target, codec: codec, body: rawBody,
- model: fields.Model, stream: fields.Stream, start: time.Now(),
+ target: target, codec: codec, body: c.Ctx.Input.RequestBody,
+ model: model, stream: stream, start: time.Now(),
}
if object.IsLlmRecording() {
route.record = &object.LlmRecord{
Protocol: target.protocol,
Endpoint: target.endpoint,
- Model: fields.Model,
+ Model: model,
ClientIp: util.GetClientIp(c.Ctx.Request),
- Stream: fields.Stream,
+ Stream: stream,
}
}
return route, true
@@ -516,7 +543,7 @@ func (c *ApiController) answerCountTokens(route
*proxyRoute) (int, string, bool)
return http.StatusBadRequest, err.Error(), false
}
- body, err := json.Marshal(map[string]any{"input_tokens":
protocol.EstimateTokens(request)})
+ body, err := json.Marshal(countTokensBody(route.target.protocol,
protocol.EstimateTokens(request)))
if err != nil {
return http.StatusBadGateway, "token count failed", false
}
@@ -525,6 +552,14 @@ func (c *ApiController) answerCountTokens(route
*proxyRoute) (int, string, bool)
return 0, "", true
}
+// countTokensBody is a token count as the client's own API reports one.
+func countTokensBody(name string, tokens int) map[string]any {
+ if name == protocol.Gemini {
+ return map[string]any{"totalTokens": tokens}
+ }
+ return map[string]any{"input_tokens": tokens}
+}
+
// reportProviderStatus feeds the breaker that decides in which order providers
// are tried. Every status worth failing over from counts as a provider
failure:
// a wrong key or an exhausted quota is not something the next request will do
@@ -558,6 +593,9 @@ func (c *ApiController) relayCredential() string {
if key := strings.TrimSpace(header.Get("X-Api-Key")); key != "" {
return key
}
+ if key := strings.TrimSpace(header.Get("X-Goog-Api-Key")); key != "" {
+ return key
+ }
authorization := strings.TrimSpace(header.Get("Authorization"))
return strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer "))
@@ -571,6 +609,7 @@ func (c *ApiController) relayCredential() string {
var clientAuthHeaders = []string{
"Authorization",
"X-Api-Key",
+ "X-Goog-Api-Key",
"User-Agent",
"X-App",
"Openai-Beta",
@@ -583,7 +622,8 @@ var clientAuthHeaders = []string{
// a client-auth provider could forward.
func (c *ApiController) hasClientCredentials() bool {
header := c.Ctx.Request.Header
- return header.Get("Authorization") != "" || header.Get("X-Api-Key") !=
""
+ return header.Get("Authorization") != "" || header.Get("X-Api-Key") !=
"" ||
+ header.Get("X-Goog-Api-Key") != ""
}
func (c *ApiController) copyClientAuthHeaders(dst http.Header, upstream
protocol.Upstream) {
@@ -594,6 +634,17 @@ func (c *ApiController) copyClientAuthHeaders(dst
http.Header, upstream protocol
}
}
+ // A Gemini client sends its key in a header no provider reads, so it is
+ // copied into the two the upstreams do.
+ if key := dst.Get("X-Goog-Api-Key"); key != "" {
+ if dst.Get("X-Api-Key") == "" {
+ dst.Set("X-Api-Key", key)
+ }
+ if dst.Get("Authorization") == "" {
+ dst.Set("Authorization", "Bearer "+key)
+ }
+ }
+
// The two APIs carry the credential in different headers, so a client
// speaking one of them and a provider serving the other would end up
// authenticating with nothing at all.
diff --git a/protocol/gemini.go b/protocol/gemini.go
new file mode 100644
index 0000000..54ebabd
--- /dev/null
+++ b/protocol/gemini.go
@@ -0,0 +1,583 @@
+// 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 speaks the Gemini generateContent API, the only wire format the
+// Gemini CLI knows. It is a client format only: the gateway reads requests in
+// it and writes answers back in it, while a Gemini provider is reached through
+// its OpenAI-compatible endpoint, so a request arriving here is always
+// translated for whichever upstream answers it.
+//
+// This API names its model in the URL rather than in the body, so the model of
+// a decoded request is filled in by the caller that read the path.
+
+package protocol
+
+import (
+ "encoding/json"
+ "errors"
+ "io"
+ "strings"
+)
+
+type geminiCodec struct{}
+
+func init() {
+ register(geminiCodec{})
+}
+
+func (geminiCodec) Name() string { return Gemini }
+
+// ---------------------------------------------------------------------------
+// The wire types
+// ---------------------------------------------------------------------------
+
+type geminiRequest struct {
+ Contents []geminiContent `json:"contents"`
+ SystemInstruction *geminiContent `json:"systemInstruction"`
+ Tools []geminiTool `json:"tools"`
+ ToolConfig *geminiToolConfig `json:"toolConfig"`
+ GenerationConfig *geminiGenerationConfig `json:"generationConfig"`
+}
+
+// geminiContent is one turn: this API spells the assistant "model", and
carries
+// tool calls and their results as parts of a turn rather than as turns of
their
+// own.
+type geminiContent struct {
+ Role string `json:"role,omitempty"`
+ Parts []geminiPart `json:"parts,omitempty"`
+}
+
+// geminiPart is one block of a turn. Only the field its kind names is filled
+// in, which is how this API models a union.
+type geminiPart struct {
+ Text string `json:"text,omitempty"`
+ // Thought marks a text part as the model's own reasoning rather than
the
+ // answer, and ThoughtSignature is what the vendor signed it with.
+ Thought bool `json:"thought,omitempty"`
+ ThoughtSignature string
`json:"thoughtSignature,omitempty"`
+ InlineData *geminiBlob `json:"inlineData,omitempty"`
+ FileData *geminiFileData `json:"fileData,omitempty"`
+ FunctionCall *geminiFunctionCall `json:"functionCall,omitempty"`
+ FunctionResponse *geminiFunctionResponse
`json:"functionResponse,omitempty"`
+}
+
+type geminiBlob struct {
+ MimeType string `json:"mimeType,omitempty"`
+ Data string `json:"data,omitempty"`
+}
+
+type geminiFileData struct {
+ MimeType string `json:"mimeType,omitempty"`
+ FileUri string `json:"fileUri,omitempty"`
+}
+
+type geminiFunctionCall struct {
+ Id string `json:"id,omitempty"`
+ Name string `json:"name"`
+ Args json.RawMessage `json:"args,omitempty"`
+}
+
+type geminiFunctionResponse struct {
+ Id string `json:"id,omitempty"`
+ Name string `json:"name,omitempty"`
+ Response json.RawMessage `json:"response,omitempty"`
+}
+
+type geminiTool struct {
+ FunctionDeclarations []geminiFunctionDeclaration
`json:"functionDeclarations,omitempty"`
+}
+
+type geminiFunctionDeclaration struct {
+ Name string `json:"name"`
+ Description string `json:"description,omitempty"`
+ Parameters json.RawMessage `json:"parameters,omitempty"`
+ // ParametersJsonSchema is the same schema in the spelling the newer
clients
+ // use: a plain JSON Schema rather than the trimmed dialect above.
+ ParametersJsonSchema json.RawMessage
`json:"parametersJsonSchema,omitempty"`
+}
+
+type geminiToolConfig struct {
+ FunctionCallingConfig *struct {
+ Mode string `json:"mode"`
+ AllowedFunctionNames []string `json:"allowedFunctionNames"`
+ } `json:"functionCallingConfig"`
+}
+
+type geminiGenerationConfig struct {
+ Temperature *float64 `json:"temperature,omitempty"`
+ TopP *float64 `json:"topP,omitempty"`
+ MaxOutputTokens *int `json:"maxOutputTokens,omitempty"`
+ StopSequences []string `json:"stopSequences,omitempty"`
+ ResponseMimeType string `json:"responseMimeType,omitempty"`
+ ResponseSchema json.RawMessage `json:"responseSchema,omitempty"`
+ ResponseJsonSchema json.RawMessage `json:"responseJsonSchema,omitempty"`
+ ThinkingConfig *struct {
+ IncludeThoughts bool `json:"includeThoughts"`
+ // ThinkingBudget is a token budget, or -1 for as much as the
model
+ // decides it needs.
+ ThinkingBudget *int `json:"thinkingBudget"`
+ } `json:"thinkingConfig,omitempty"`
+}
+
+type geminiUsage struct {
+ PromptTokenCount int `json:"promptTokenCount,omitempty"`
+ CandidatesTokenCount int `json:"candidatesTokenCount,omitempty"`
+ CachedContentTokenCount int `json:"cachedContentTokenCount,omitempty"`
+ ThoughtsTokenCount int `json:"thoughtsTokenCount,omitempty"`
+ TotalTokenCount int `json:"totalTokenCount,omitempty"`
+}
+
+// ---------------------------------------------------------------------------
+// Request
+// ---------------------------------------------------------------------------
+
+func (geminiCodec) DecodeRequest(raw []byte) (*Request, error) {
+ var body geminiRequest
+ if err := json.Unmarshal(raw, &body); err != nil {
+ return nil, errors.New("invalid request body")
+ }
+ if len(body.Contents) == 0 {
+ return nil, errors.New("contents is required")
+ }
+
+ request := &Request{ToolChoice: geminiToolChoiceOf(body.ToolConfig)}
+ if body.SystemInstruction != nil {
+ if system := geminiTextOf(body.SystemInstruction.Parts); system
!= "" {
+ request.System = []string{system}
+ }
+ }
+ for _, tool := range body.Tools {
+ for _, declaration := range tool.FunctionDeclarations {
+ if declaration.Name == "" {
+ continue
+ }
+ parameters := declaration.Parameters
+ if len(parameters) == 0 {
+ parameters = declaration.ParametersJsonSchema
+ }
+ request.Tools = append(request.Tools, Tool{
+ Name: declaration.Name, Description:
declaration.Description, Parameters: parameters,
+ })
+ }
+ }
+ for _, content := range body.Contents {
+ request.Messages = appendGeminiContent(request.Messages,
content)
+ }
+ if len(request.Messages) == 0 {
+ return nil, errors.New("contents is required")
+ }
+
+ geminiApplyGenerationConfig(request, body.GenerationConfig)
+ return request, nil
+}
+
+func appendGeminiContent(messages []Message, content geminiContent) []Message {
+ role := RoleUser
+ if content.Role == "model" || content.Role == RoleAssistant {
+ role = RoleAssistant
+ }
+
+ blocks := []Content{}
+ for _, part := range content.Parts {
+ switch {
+ case part.FunctionCall != nil:
+ blocks = append(blocks, Content{
+ Kind: KindToolUse,
+ Signature: part.ThoughtSignature,
+ ToolUse: &ToolUse{
+ Id: part.FunctionCall.Id,
+ Name: part.FunctionCall.Name,
+ Arguments:
emptyAsObject(string(part.FunctionCall.Args)),
+ },
+ })
+ case part.FunctionResponse != nil:
+ blocks = append(blocks, Content{Kind: KindToolResult,
ToolResult: &ToolResult{
+ Id: emptyAs(part.FunctionResponse.Id,
part.FunctionResponse.Name),
+ Text:
geminiResponseText(part.FunctionResponse.Response),
+ }})
+ case part.InlineData != nil:
+ blocks = append(blocks, Content{Kind: KindImage, Image:
&Image{
+ MediaType: part.InlineData.MimeType, Data:
part.InlineData.Data,
+ }})
+ case part.FileData != nil:
+ blocks = append(blocks, Content{Kind: KindImage, Image:
&Image{
+ MediaType: part.FileData.MimeType, Url:
part.FileData.FileUri,
+ }})
+ case part.Text != "" && part.Thought:
+ blocks = append(blocks, Content{Kind: KindThinking,
Text: part.Text, Signature: part.ThoughtSignature})
+ case part.Text != "":
+ blocks = append(blocks, Content{Kind: KindText, Text:
part.Text})
+ }
+ }
+ if len(blocks) == 0 {
+ return messages
+ }
+ return append(messages, Message{Role: role, Content: blocks})
+}
+
+// geminiTextOf is the plain text of a set of parts, which is all a system
+// instruction ever holds.
+func geminiTextOf(parts []geminiPart) string {
+ text := []string{}
+ for _, part := range parts {
+ if part.Text != "" && !part.Thought {
+ text = append(text, part.Text)
+ }
+ }
+ return strings.Join(text, "\n")
+}
+
+// geminiResponseText is the text of a tool result. This API wraps one in an
+// object of the tool's own making, and the clients put the result under
+// "output" or "error"; anything else is passed on as the JSON it is.
+func geminiResponseText(raw json.RawMessage) string {
+ if len(raw) == 0 {
+ return ""
+ }
+
+ var text string
+ if err := json.Unmarshal(raw, &text); err == nil {
+ return text
+ }
+
+ var wrapper map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &wrapper); err != nil {
+ return string(raw)
+ }
+ for _, key := range []string{"output", "error", "result", "content"} {
+ value, ok := wrapper[key]
+ if !ok {
+ continue
+ }
+ var inner string
+ if err := json.Unmarshal(value, &inner); err == nil {
+ return inner
+ }
+ return string(value)
+ }
+ return string(raw)
+}
+
+func geminiToolChoiceOf(config *geminiToolConfig) *ToolChoice {
+ if config == nil || config.FunctionCallingConfig == nil {
+ return nil
+ }
+
+ calling := config.FunctionCallingConfig
+ switch strings.ToUpper(calling.Mode) {
+ case "AUTO":
+ return &ToolChoice{Mode: ChoiceAuto}
+ case "NONE":
+ return &ToolChoice{Mode: ChoiceNone}
+ case "ANY":
+ // One allowed name is the same thing as naming the tool that
has to be
+ // called, which is how the other formats spell it.
+ if len(calling.AllowedFunctionNames) == 1 {
+ return &ToolChoice{Mode: ChoiceTool, Name:
calling.AllowedFunctionNames[0]}
+ }
+ return &ToolChoice{Mode: ChoiceRequired}
+ }
+ return nil
+}
+
+func geminiApplyGenerationConfig(request *Request, config
*geminiGenerationConfig) {
+ if config == nil {
+ return
+ }
+
+ request.Temperature = config.Temperature
+ request.TopP = config.TopP
+ request.MaxTokens = config.MaxOutputTokens
+ request.StopSequences = config.StopSequences
+
+ schema := config.ResponseSchema
+ if len(schema) == 0 {
+ schema = config.ResponseJsonSchema
+ }
+ switch {
+ case len(schema) > 0:
+ request.Format = &Format{Kind: "json_schema", Schema: schema}
+ case config.ResponseMimeType == "application/json":
+ request.Format = &Format{Kind: "json_object"}
+ }
+
+ thinking := config.ThinkingConfig
+ if thinking == nil {
+ return
+ }
+ switch {
+ case thinking.ThinkingBudget != nil && *thinking.ThinkingBudget > 0:
+ request.Reasoning = &Reasoning{BudgetTokens:
*thinking.ThinkingBudget}
+ case thinking.IncludeThoughts || (thinking.ThinkingBudget != nil &&
*thinking.ThinkingBudget < 0):
+ // A budget the model decides for itself, which no other format
spells,
+ // asks for the middle of what they do.
+ request.Reasoning = &Reasoning{Effort: EffortMedium}
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Response
+// ---------------------------------------------------------------------------
+
+// geminiAnswer builds the answer as this API reports it. The whole-body
encoder
+// and the stream writer share it, so a client reading events and one reading a
+// body are told the same thing.
+type geminiAnswer struct {
+ id string
+ model string
+ // calls holds the tool calls being assembled, keyed by the index of the
+ // canonical stream, with callOrder keeping the order they were opened
in.
+ calls map[int]*geminiFunctionCall
+ callOrder []int
+ usage Usage
+ stop string
+ failure *Failure
+}
+
+func newGeminiAnswer(model string) *geminiAnswer {
+ return &geminiAnswer{
+ id: "gw-" + newToken(),
+ model: model,
+ calls: map[int]*geminiFunctionCall{},
+ }
+}
+
+// add records one event of a streamed answer.
+func (answer *geminiAnswer) add(event Event) {
+ if event.Model != "" {
+ answer.model = event.Model
+ }
+
+ switch event.Kind {
+ case EventToolUse:
+ if event.Tool == nil {
+ return
+ }
+ call, ok := answer.calls[event.Tool.Index]
+ if !ok {
+ call = &geminiFunctionCall{}
+ answer.calls[event.Tool.Index] = call
+ answer.callOrder = append(answer.callOrder,
event.Tool.Index)
+ }
+ if event.Tool.Id != "" {
+ call.Id = event.Tool.Id
+ }
+ call.Name += event.Tool.Name
+ // The arguments arrive in pieces, and this API carries them as
one JSON
+ // object, so the call is only written once the stream has
closed.
+ call.Args = append(call.Args, event.Tool.Arguments...)
+ case EventDone:
+ if event.StopReason != "" {
+ answer.stop = event.StopReason
+ }
+ if event.Usage != nil {
+ answer.usage = *event.Usage
+ }
+ case EventFailure:
+ answer.failure = event.Failure
+ }
+}
+
+// callParts are the tool calls the answer ended with, with their arguments
+// parsed back into the object this API carries them as.
+func (answer *geminiAnswer) callParts() []geminiPart {
+ parts := []geminiPart{}
+ for _, index := range answer.callOrder {
+ call := answer.calls[index]
+ parts = append(parts, geminiPart{FunctionCall:
&geminiFunctionCall{
+ Id: call.Id, Name: call.Name, Args:
geminiArgs(string(call.Args)),
+ }})
+ }
+ return parts
+}
+
+// chunk is one whole answer, or one piece of a streamed one: this API sends
+// both in the same shape.
+func (answer *geminiAnswer) chunk(parts []geminiPart, finish string)
map[string]any {
+ candidate := map[string]any{
+ "content": map[string]any{"role": "model", "parts": parts},
+ "index": 0,
+ }
+ if finish != "" {
+ candidate["finishReason"] = finish
+ }
+
+ chunk := map[string]any{
+ "candidates": []any{candidate},
+ "modelVersion": answer.model,
+ "responseId": answer.id,
+ }
+ if usage := geminiUsageOf(answer.usage); usage != nil {
+ chunk["usageMetadata"] = usage
+ }
+ if answer.failure != nil {
+ // This API has no error event, so an upstream failing
mid-answer is
+ // reported beside the last piece of it.
+ chunk["error"] = geminiError(emptyAs(answer.failure.Kind,
"server_error"), answer.failure.Message)
+ }
+ return chunk
+}
+
+func (geminiCodec) EncodeResponse(response *Response) ([]byte, error) {
+ answer := newGeminiAnswer(response.Model)
+ answer.usage = response.Usage
+ answer.stop = response.StopReason
+ answer.failure = response.Failure
+
+ parts := []geminiPart{}
+ for _, content := range response.Content {
+ switch content.Kind {
+ case KindText:
+ parts = append(parts, geminiPart{Text: content.Text})
+ case KindThinking:
+ parts = append(parts, geminiPart{
+ Text: content.Text, Thought: true,
ThoughtSignature: content.Signature,
+ })
+ case KindToolUse:
+ if content.ToolUse == nil {
+ continue
+ }
+ parts = append(parts, geminiPart{FunctionCall:
&geminiFunctionCall{
+ Id: content.ToolUse.Id,
+ Name: content.ToolUse.Name,
+ Args: geminiArgs(content.ToolUse.Arguments),
+ }})
+ }
+ }
+ return json.Marshal(answer.chunk(parts,
geminiFinishOfStop(answer.stop)))
+}
+
+// ---------------------------------------------------------------------------
+// Stream
+// ---------------------------------------------------------------------------
+
+// geminiStreamWriter turns canonical events into the chunks this API streams:
+// one whole answer object per event, with the tool calls, the finish reason
and
+// the counters in the last of them.
+type geminiStreamWriter struct {
+ events *eventWriter
+ answer *geminiAnswer
+}
+
+func (geminiCodec) NewStreamWriter(writer io.Writer, flush func(), model
string) StreamWriter {
+ return &geminiStreamWriter{
+ events: &eventWriter{writer: writer, flush: flush},
+ answer: newGeminiAnswer(model),
+ }
+}
+
+func (writer *geminiStreamWriter) Open() {}
+
+func (writer *geminiStreamWriter) Write(event Event) {
+ writer.answer.add(event)
+
+ switch event.Kind {
+ case EventText:
+ if event.Text == "" {
+ return
+ }
+ writer.send([]geminiPart{{Text: event.Text}}, "")
+ case EventThinking:
+ if event.Text == "" && event.Signature == "" {
+ return
+ }
+ writer.send([]geminiPart{{
+ Text: event.Text, Thought: true, ThoughtSignature:
event.Signature,
+ }}, "")
+ }
+}
+
+func (writer *geminiStreamWriter) Close() {
+ // The tool calls go out in the last chunk: their arguments arrive in
+ // pieces, and this API has no event to carry a piece of one in.
+ writer.send(writer.answer.callParts(),
geminiFinishOfStop(writer.answer.stop))
+}
+
+func (writer *geminiStreamWriter) send(parts []geminiPart, finish string) {
+ writer.events.send("", writer.answer.chunk(parts, finish))
+}
+
+func (geminiCodec) EncodeError(kind string, message string) []byte {
+ data, err := json.Marshal(map[string]any{"error": geminiError(kind,
message)})
+ if err != nil {
+ return []byte(`{"error":{"code":500,"message":"server
error","status":"INTERNAL"}}`)
+ }
+ return data
+}
+
+// ---------------------------------------------------------------------------
+// Shared pieces
+// ---------------------------------------------------------------------------
+
+// geminiArgs is a tool call's arguments as the object this API carries them
in.
+// Half an object is what a stream that was cut off leaves behind, and an empty
+// one is what a client can still act on.
+func geminiArgs(arguments string) json.RawMessage {
+ trimmed := strings.TrimSpace(arguments)
+ if trimmed == "" {
+ return json.RawMessage("{}")
+ }
+ var value map[string]any
+ if err := json.Unmarshal([]byte(trimmed), &value); err != nil {
+ return json.RawMessage("{}")
+ }
+ return json.RawMessage(trimmed)
+}
+
+// geminiUsageOf writes the counters back out. This API counts the cached part
+// inside the prompt total, which is where the canonical form keeps it too.
+func geminiUsageOf(usage Usage) *geminiUsage {
+ if usage.IsZero() {
+ return nil
+ }
+
+ written := &geminiUsage{
+ PromptTokenCount: usage.InputTokens +
usage.CacheWriteTokens,
+ CandidatesTokenCount: usage.OutputTokens,
+ CachedContentTokenCount: usage.CacheReadTokens,
+ ThoughtsTokenCount: usage.ReasoningTokens,
+ }
+ written.TotalTokenCount = written.PromptTokenCount +
written.CandidatesTokenCount
+ return written
+}
+
+func geminiFinishOfStop(stop string) string {
+ switch stop {
+ case StopMaxTokens:
+ return "MAX_TOKENS"
+ case StopFilter:
+ return "SAFETY"
+ }
+ return "STOP"
+}
+
+// geminiError is one failure as Google's APIs report it: the canonical error
+// name in their own spelling, beside the status code that name is answered
+// with, which is where their clients read the reason off.
+func geminiError(kind string, message string) map[string]any {
+ code, status := 500, "INTERNAL"
+ switch kind {
+ case "invalid_request_error":
+ code, status = 400, "INVALID_ARGUMENT"
+ case "authentication_error":
+ code, status = 401, "UNAUTHENTICATED"
+ case "permission_error":
+ code, status = 403, "PERMISSION_DENIED"
+ case "not_found_error":
+ code, status = 404, "NOT_FOUND"
+ case "rate_limit_error":
+ code, status = 429, "RESOURCE_EXHAUSTED"
+ }
+ return map[string]any{"code": code, "message": message, "status":
status}
+}
diff --git a/protocol/protocol.go b/protocol/protocol.go
index c94f41e..9ad725e 100644
--- a/protocol/protocol.go
+++ b/protocol/protocol.go
@@ -32,6 +32,10 @@ const (
// Responses is the OpenAI Responses API, which recent Codex versions
speak.
// No provider type serves it, so it is a client format only.
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
+ // its OpenAI-compatible endpoint.
+ Gemini = "gemini"
)
// Codec is a format a client can call the gateway in: it reads the request and
diff --git a/routers/filter.go b/routers/filter.go
index d2df1b5..89516e6 100644
--- a/routers/filter.go
+++ b/routers/filter.go
@@ -27,7 +27,10 @@ import (
func TransparentStatic(ctx *context.Context) {
urlPath := ctx.Request.URL.Path
- if strings.HasPrefix(urlPath, "/api/") || strings.HasPrefix(urlPath,
"/v1/") {
+ // "/v1beta/" is the Gemini API, which the gateway serves at the root
the
+ // same way it serves the others under "/v1/".
+ if strings.HasPrefix(urlPath, "/api/") || strings.HasPrefix(urlPath,
"/v1/") ||
+ strings.HasPrefix(urlPath, "/v1beta/") {
return
}
diff --git a/routers/router.go b/routers/router.go
index e5eac4e..21e83b9 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -110,4 +110,11 @@ func initAPI() {
beego.Router("/v1/agents/:agentId/v1/models",
&controllers.ApiController{}, "GET:AgentModels")
beego.Router("/v1/agents/:agentId/models/:modelId",
&controllers.ApiController{}, "GET:AgentModel")
beego.Router("/v1/agents/:agentId/v1/models/:modelId",
&controllers.ApiController{}, "GET:AgentModel")
+ // The Gemini API, which names the model and the call in the path: the
+ // Google client library appends "/v1beta/models/<model>:<action>" to
the
+ // base URL it is given.
+ beego.Router("/v1beta/models", &controllers.ApiController{},
"GET:GeminiModels")
+ beego.Router("/v1beta/models/:model", &controllers.ApiController{},
"GET:GeminiModel;POST:GeminiGenerate")
+ beego.Router("/v1/agents/:agentId/v1beta/models",
&controllers.ApiController{}, "GET:AgentGeminiModels")
+ beego.Router("/v1/agents/:agentId/v1beta/models/:model",
&controllers.ApiController{}, "GET:AgentGeminiModel;POST:AgentGeminiGenerate")
}
diff --git a/web/src/components/AgentIcon.tsx b/web/src/components/AgentIcon.tsx
index 1a1e9cc..7c5463a 100644
--- a/web/src/components/AgentIcon.tsx
+++ b/web/src/components/AgentIcon.tsx
@@ -29,6 +29,7 @@ const agentSites: Record<string, string> = {
opencode: "opencode.ai",
"opencode Desktop": "opencode.ai",
"DeepSeek Harness": "deepseek.com",
+ "Gemini CLI": "gemini.google.com",
};
const agentKey = (agent: unknown) =>
diff --git a/web/src/components/ProviderCard.tsx
b/web/src/components/ProviderCard.tsx
index da00d60..147899f 100644
--- a/web/src/components/ProviderCard.tsx
+++ b/web/src/components/ProviderCard.tsx
@@ -43,7 +43,7 @@ import {
import {Switch} from "@/components/ui/switch";
import {
agentBuiltin,
- agentNeedsResponsesApi,
+ agentGatewayOnlyKey,
agentProxyBaseUrl,
agentSetupNoteKey,
builtinProvider,
@@ -53,7 +53,6 @@ import {
import {
providerIdOf,
providerProtocol,
- servesResponsesApi,
shellForPath,
usesClientAuth,
} from "@/lib/providers";
@@ -221,10 +220,12 @@ export function ProviderCard({
provider => providerIdOf(provider) !== agent.provider &&
speaksAgentApi(provider),
);
- // Codex reads nothing but the Responses API, which Gateway serves by
- // translating it: a provider stopping at chat completions is out of its
reach
- // directly, so the choice is not offered rather than failing on the write.
- const gatewayOnly = agentNeedsResponsesApi(agent.agentId) &&
!servesResponsesApi(bound);
+ // An agent whose API no provider here serves is out of reach directly: Codex
+ // reads nothing but the Responses API, the Gemini CLI nothing but Google's.
+ // Gateway serves both by translating, so the choice is not offered rather
+ // than failing on the write.
+ const gatewayOnlyKey = agentGatewayOnlyKey(agent, bound);
+ const gatewayOnly = gatewayOnlyKey !== "";
// Nothing is bound, yet the agent still calls the proxy: it gets an error
// back on every request until its own configuration is put back.
@@ -333,7 +334,7 @@ export function ProviderCard({
{i18next.t("agent:Route through Gateway")}
<span className="block text-muted-foreground">
{gatewayOnly
- ? i18next.t("agent:Gateway only hint")
+ ? i18next.t(gatewayOnlyKey)
: mode === gatewayMode
? i18next.t("agent:Gateway mode hint")
: i18next.t("agent:Direct mode hint")}
diff --git a/web/src/lib/agents.ts b/web/src/lib/agents.ts
index 0eab2bf..f1d7943 100644
--- a/web/src/lib/agents.ts
+++ b/web/src/lib/agents.ts
@@ -85,6 +85,8 @@ export function agentSetupNoteKey(agentId: string) {
case "opencode":
case "opencode-desktop":
return "agent:opencode config hint";
+ case "gemini-cli":
+ return "agent:Gemini CLI config hint";
default:
return "";
}
@@ -99,6 +101,21 @@ export function agentNeedsResponsesApi(agentId: string) {
return agentId.startsWith("codex");
}
+/**
+ * Why an agent can only be routed through the gateway, as the key of the line
+ * that says so. Empty for an agent that can also be bound to a provider
+ * directly.
+ */
+export function agentGatewayOnlyKey(agent: Agent, bound: Provider | undefined)
{
+ if (agentProtocol(agent) === "gemini") {
+ return "agent:Gemini gateway only hint";
+ }
+ if (agentNeedsResponsesApi(agent.agentId) && !servesResponsesApi(bound)) {
+ return "agent:Gateway only hint";
+ }
+ return "";
+}
+
/**
* The wire format an agent's client speaks, as the server reports it. Empty
for
* an agent Gateway does not know, which is then not filtered on.
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index ea6d031..3584578 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -73,6 +73,8 @@
"Gateway": "Gateway",
"Gateway mode hint": "The agent is pointed at the local proxy, so
switching to another provider takes effect on the next request without
restarting the agent.",
"Gateway only hint": "Codex speaks only the OpenAI Responses API. Gateway
serves it by translating to the chat completions this provider answers, so
Codex cannot be pointed at it directly.",
+ "Gemini CLI config hint": "The Gemini CLI reads no base URL from a
settings file: the endpoint is GOOGLE_GEMINI_BASE_URL in ~/.gemini/.env, with
GEMINI_API_KEY beside it and \"security.auth.selectedType\": \"gemini-api-key\"
in ~/.gemini/settings.json. A project of its own .env is read first, and that
one wins.",
+ "Gemini gateway only hint": "The Gemini CLI speaks only Google's
generateContent API, which no provider here serves. Gateway serves it by
translating to the API the provider answers, so the CLI cannot be pointed at a
provider directly.",
"Image": "Image",
"In use": "In use",
"Install": "Install",
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index 09c3a25..90fb4df 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -73,6 +73,8 @@
"Gateway": "网关",
"Gateway mode hint": "把 Agent 指向本地代理,切换供应商在下一次请求即生效,无需重启 Agent。",
"Gateway only hint": "Codex 只会说 OpenAI Responses API。Gateway 会把它翻译成该供应商应答的
chat completions,所以 Codex 无法直连这个供应商。",
+ "Gemini CLI config hint": "Gemini CLI 不从配置文件读取 Base URL:端点是 ~/.gemini/.env
中的 GOOGLE_GEMINI_BASE_URL,旁边是 GEMINI_API_KEY,同时 ~/.gemini/settings.json 中写入
\"security.auth.selectedType\": \"gemini-api-key\"。项目自带的 .env 会被优先读取并覆盖它。",
+ "Gemini gateway only hint": "Gemini CLI 只会说 Google 的 generateContent
API,这里没有供应商提供该 API。Gateway 会把它翻译成供应商应答的 API,所以 Gemini CLI 无法直连供应商。",
"Image": "图片",
"In use": "使用中",
"Install": "安装",
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]