From e190109c86a64284ec85d294cabf5e12b90a4c0c Mon Sep 17 00:00:00 2001 From: Yetibot Date: Sun, 7 Jun 2026 17:12:43 +0000 Subject: [PATCH 1/2] Add Cloudflare AI Gateway support for Gemini API --- config/config.sample.edn | 6 ++++- src/yetibot/core/commands/agent.clj | 27 +++++++++++++---------- src/yetibot/core/util/gemini.clj | 19 ++++++++++------ test/yetibot/core/test/commands/agent.clj | 8 ++++++- 4 files changed, 39 insertions(+), 21 deletions(-) diff --git a/config/config.sample.edn b/config/config.sample.edn index 80539326..4155f811 100644 --- a/config/config.sample.edn +++ b/config/config.sample.edn @@ -58,7 +58,11 @@ ;; :monthly-budget 10.00 ;; estimated cost per image in USD (default: 0.045 for Gemini 3.1 Flash 512px) ;; :cost-per-image 0.045 - }} + } + ;; optional: custom Cloudflare AI Gateway URL or base URL for Gemini API proxying + ;; (e.g. "https://gateway.ai.cloudflare.com/v1/ACCOUNT_ID/GATEWAY_ID/google-ai-studio") + ;; :gateway-url "" + } :giphy {:key ""}, :weather {:weatherbitio {:key "" :default {:zip ""}}}, :command {:prefix "!" diff --git a/src/yetibot/core/commands/agent.clj b/src/yetibot/core/commands/agent.clj index 16e6e5e9..222e8e07 100644 --- a/src/yetibot/core/commands/agent.clj +++ b/src/yetibot/core/commands/agent.clj @@ -65,6 +65,7 @@ (defn model [] (or (config-str [:gemini :agent :model]) "gemini-3.1-pro-preview")) (defn gemini-key [] (config-str [:gemini :key])) +(defn gateway-url [] (config-str [:gemini :gateway-url])) (defn cli-bin [] (or (config-str [:gemini :cli]) "gemini")) (defn- config-num [path default] @@ -498,21 +499,23 @@ "--prompt" (build-agent-prompt request context mentions)]) (.directory (io/file workdir)) (.redirectError java.lang.ProcessBuilder$Redirect/DISCARD))] - (doto (.environment pb) - (.put "GEMINI_API_KEY" (gemini-key)) - (.put "GEMINI_CLI_TRUST_WORKSPACE" "true") - (.put "GH_TOKEN" (or token "")) - (.put "YETIBOT_PORT" (str (or (System/getenv "PORT") "3003"))) + (let [env (.environment pb)] + (.put env "GEMINI_API_KEY" (gemini-key)) + (when-let [gw (gateway-url)] + (.put env "GOOGLE_GEMINI_BASE_URL" gw)) + (.put env "GEMINI_CLI_TRUST_WORKSPACE" "true") + (.put env "GH_TOKEN" (or token "")) + (.put env "YETIBOT_PORT" (str (or (System/getenv "PORT") "3003"))) ;; inject git config via env (no global state): a credential helper that ;; authenticates HTTPS pushes with GH_TOKEN, plus insteadOf rewrites so any ;; SSH-style github remote is forced to HTTPS (where the token applies). - (.put "GIT_CONFIG_COUNT" "3") - (.put "GIT_CONFIG_KEY_0" "credential.https://github.com.helper") - (.put "GIT_CONFIG_VALUE_0" git-credential-helper) - (.put "GIT_CONFIG_KEY_1" "url.https://github.com/.insteadOf") - (.put "GIT_CONFIG_VALUE_1" "git@github.com:") - (.put "GIT_CONFIG_KEY_2" "url.https://github.com/.insteadOf") - (.put "GIT_CONFIG_VALUE_2" "ssh://git@github.com/")) + (.put env "GIT_CONFIG_COUNT" "3") + (.put env "GIT_CONFIG_KEY_0" "credential.https://github.com.helper") + (.put env "GIT_CONFIG_VALUE_0" git-credential-helper) + (.put env "GIT_CONFIG_KEY_1" "url.https://github.com/.insteadOf") + (.put env "GIT_CONFIG_VALUE_1" "git@github.com:") + (.put env "GIT_CONFIG_KEY_2" "url.https://github.com/.insteadOf") + (.put env "GIT_CONFIG_VALUE_2" "ssh://git@github.com/")) (info "running gemini agent" (cli-bin) "in" (str workdir)) (let [proc (.start pb) timed-out (atom false) diff --git a/src/yetibot/core/util/gemini.clj b/src/yetibot/core/util/gemini.clj index bc200f28..e6c6f684 100644 --- a/src/yetibot/core/util/gemini.clj +++ b/src/yetibot/core/util/gemini.clj @@ -17,13 +17,20 @@ (s/def ::per (s/or :string string? :number number?)) (s/def ::monthly (s/keys :opt-un [::budget])) (s/def ::budget (s/or :string string? :number number?)) +(s/def ::gateway-url string?) -(s/def ::config (s/keys :req-un [::key] :opt-un [::cost ::monthly])) +(s/def ::config (s/keys :req-un [::key] :opt-un [::cost ::monthly ::gateway-url])) ;; All Gemini settings live under [:gemini] and share a single API key ;; (YB_GEMINI_KEY) with the rest of Yetibot's Gemini features. (def config (or (:value (get-config ::config [:gemini])) {})) +(defn- api-base-url [] + (or (:gateway-url config) "https://generativelanguage.googleapis.com")) + +(defn- api-base-v1beta [] + (str (api-base-url) "/v1beta")) + (def default-model "gemini-3.1-flash-image-preview") (defn gemini-model [] default-model) @@ -232,8 +239,8 @@ (let [api-key (:key config) model (gemini-model) url (format - "https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent?key=%s" - model api-key) + "%s/models/%s:generateContent?key=%s" + (api-base-v1beta) model api-key) image-parts (when (seq image-urls) (keep url->inline-part image-urls)) parts (into [{:text prompt}] image-parts) @@ -273,8 +280,6 @@ ;; Veo is asynchronous: start a long-running operation, poll until done, then ;; download the resulting mp4. Settings live under [:gemini :veo]. -(def ^:private api-base "https://generativelanguage.googleapis.com/v1beta") - (def default-veo-model "veo-3.1-lite-generate-preview") (defn veo-model [] (or (-> config :veo :model) default-veo-model)) @@ -331,7 +336,7 @@ [prompt image model duration] (let [model (or model (veo-model)) duration (or duration (veo-duration)) - url (format "%s/models/%s:predictLongRunning?key=%s" api-base model (:key config)) + url (format "%s/models/%s:predictLongRunning?key=%s" (api-base-v1beta) model (:key config)) body {:instances [(cond-> {:prompt prompt} image (assoc :image image))] :parameters {:aspectRatio "16:9" :durationSeconds duration}} resp (client/post url (merge veo-http-timeouts @@ -350,7 +355,7 @@ "Poll a Veo operation until done; returns the completed operation body." [op-name] (loop [n 0] - (let [body (:body (client/get (format "%s/%s?key=%s" api-base op-name (:key config)) + (let [body (:body (client/get (format "%s/%s?key=%s" (api-base-v1beta) op-name (:key config)) (merge veo-http-timeouts {:as :json :throw-exceptions false})))] (cond diff --git a/test/yetibot/core/test/commands/agent.clj b/test/yetibot/core/test/commands/agent.clj index a1d0ece1..7bbcca6c 100644 --- a/test/yetibot/core/test/commands/agent.clj +++ b/test/yetibot/core/test/commands/agent.clj @@ -5,6 +5,7 @@ [clojure.string :as string] [clojure.data.json :as json] [clj-http.client :as client] + [yetibot.core.config :refer [get-config]] [yetibot.core.commands.agent :as agent]) (:import [java.security KeyPairGenerator Signature] @@ -80,7 +81,12 @@ (fact "default max turns is 50" (agent/agent-max-turns) => 50) (fact "default model is the current Gemini 3.1 Pro" - (agent/model) => "gemini-3.1-pro-preview")) + (agent/model) => "gemini-3.1-pro-preview") + (fact "default gateway url is nil" + (agent/gateway-url) => nil) + (fact "gateway url can be overridden by config" + (agent/gateway-url) => "https://my-gateway.com" + (provided (get-config anything [:gemini :gateway-url]) => {:value "https://my-gateway.com"}))) (facts "about mention-glossary" (fact "prefers the server nickname and keeps the <@id> token" From 1ac92c30b95f569092f24883ae4c2eb4f8df8e57 Mon Sep 17 00:00:00 2001 From: Yetibot Date: Sun, 7 Jun 2026 17:22:55 +0000 Subject: [PATCH 2/2] Make env setup more Rich Hickey approved: use pure Clojure map and .putAll instead of multiple .put mutations --- src/yetibot/core/commands/agent.clj | 34 ++++++++++++++--------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/yetibot/core/commands/agent.clj b/src/yetibot/core/commands/agent.clj index 222e8e07..d1c85ac5 100644 --- a/src/yetibot/core/commands/agent.clj +++ b/src/yetibot/core/commands/agent.clj @@ -499,23 +499,23 @@ "--prompt" (build-agent-prompt request context mentions)]) (.directory (io/file workdir)) (.redirectError java.lang.ProcessBuilder$Redirect/DISCARD))] - (let [env (.environment pb)] - (.put env "GEMINI_API_KEY" (gemini-key)) - (when-let [gw (gateway-url)] - (.put env "GOOGLE_GEMINI_BASE_URL" gw)) - (.put env "GEMINI_CLI_TRUST_WORKSPACE" "true") - (.put env "GH_TOKEN" (or token "")) - (.put env "YETIBOT_PORT" (str (or (System/getenv "PORT") "3003"))) - ;; inject git config via env (no global state): a credential helper that - ;; authenticates HTTPS pushes with GH_TOKEN, plus insteadOf rewrites so any - ;; SSH-style github remote is forced to HTTPS (where the token applies). - (.put env "GIT_CONFIG_COUNT" "3") - (.put env "GIT_CONFIG_KEY_0" "credential.https://github.com.helper") - (.put env "GIT_CONFIG_VALUE_0" git-credential-helper) - (.put env "GIT_CONFIG_KEY_1" "url.https://github.com/.insteadOf") - (.put env "GIT_CONFIG_VALUE_1" "git@github.com:") - (.put env "GIT_CONFIG_KEY_2" "url.https://github.com/.insteadOf") - (.put env "GIT_CONFIG_VALUE_2" "ssh://git@github.com/")) + (let [env (.environment pb) + env-map (cond-> {"GEMINI_API_KEY" (gemini-key) + "GEMINI_CLI_TRUST_WORKSPACE" "true" + "GH_TOKEN" (or token "") + "YETIBOT_PORT" (str (or (System/getenv "PORT") "3003")) + ;; inject git config via env (no global state): a credential helper that + ;; authenticates HTTPS pushes with GH_TOKEN, plus insteadOf rewrites so any + ;; SSH-style github remote is forced to HTTPS (where the token applies). + "GIT_CONFIG_COUNT" "3" + "GIT_CONFIG_KEY_0" "credential.https://github.com.helper" + "GIT_CONFIG_VALUE_0" git-credential-helper + "GIT_CONFIG_KEY_1" "url.https://github.com/.insteadOf" + "GIT_CONFIG_VALUE_1" "git@github.com:" + "GIT_CONFIG_KEY_2" "url.https://github.com/.insteadOf" + "GIT_CONFIG_VALUE_2" "ssh://git@github.com/"} + (gateway-url) (assoc "GOOGLE_GEMINI_BASE_URL" (gateway-url)))] + (.putAll env env-map)) (info "running gemini agent" (cli-bin) "in" (str workdir)) (let [proc (.start pb) timed-out (atom false)