diff --git a/config/config.sample.edn b/config/config.sample.edn index 80539326..69c3e5e1 100644 --- a/config/config.sample.edn +++ b/config/config.sample.edn @@ -51,14 +51,34 @@ :key "path-to-key"}]}, :url "http://localhost:3003", :ebay {:appid ""}, - :gemini {:api {:key "" - ;; optional: override the default model (gemini-3.1-flash-image-preview) - ;; :model "gemini-3.1-flash-image-preview" - ;; monthly budget in USD for image generation (default: 10.00) - ;; :monthly-budget 10.00 - ;; estimated cost per image in USD (default: 0.045 for Gemini 3.1 Flash 512px) - ;; :cost-per-image 0.045 - }} + ;; Gemini powers image (banana) and video (veo) generation. The monthly budget + ;; below is a single USD pool shared across banana + veo + agent. + :gemini {:key "" + ;; optional: override the default image model (gemini-3.1-flash-image-preview) + ;; :model "gemini-3.1-flash-image-preview" + ;; shared monthly budget in USD (default: 5.00) + ;; :monthly {:budget 5.00} + ;; estimated cost per image in USD (default: 0.039) + ;; :cost {:per 0.039} + ;; how much one agent reply draws from the shared budget (default: 0.05) + ;; :agent {:cost-per-session 0.05} + } + ;; The `agent` command answers with a fast Kimi model, routed through a + ;; Cloudflare AI Gateway (below) for unified observability and cost control. + :agent {:model "kimi-k2.5"} + ;; Cloudflare AI Gateway: proxies the agent's model calls to an upstream + ;; provider. Moonshot/Kimi is reached via a custom provider β€” create one in the + ;; gateway with base URL https://api.moonshot.ai/v1 and name it (e.g. "moonshot"). + :cloudflare {:ai-gateway {:account-id "" + :gateway-id "" + ;; provider route prefix; for a custom provider named + ;; "moonshot" this is "custom-moonshot" (the default) + :provider "custom-moonshot" + ;; the upstream provider's API key (sent as the Bearer token) + :api-key "" + ;; optional: only when the gateway has Authenticated Gateway on + ;; :auth-token "" + }} :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 dbf6dc3b..660a213d 100644 --- a/src/yetibot/core/commands/agent.clj +++ b/src/yetibot/core/commands/agent.clj @@ -1,24 +1,17 @@ (ns yetibot.core.commands.agent - "A meme-loving coding agent. `agent ` hands the request to the Gemini - CLI running headlessly as an autonomous agent: Gemini uses the authenticated - `gh` CLI and `git` to find the right repo(s), make the change, and open pull - requests itself. Yetibot's job is just to run it and relay what it's doing, - live, into a chat thread β€” in the playful persona of Bonzi Buddy. + "A meme-loving chat agent. `agent ` answers the teammate's request with + a fast Kimi model, reached through a Cloudflare AI Gateway for unified + observability and cost control. Yetibot relays the answer into a chat thread, + in the playful persona of Bonzi Buddy. On Discord the agent works inside a thread spun off the triggering message, so a team can keep replying and re-trigger `agent` to iterate; the thread is fed back as context. On other adapters it degrades to plain in-channel - replies. - - GitHub auth is a GitHub App (preferred) or a static token; either is handed - to Gemini as GH_TOKEN so its `gh`/`git` calls are authenticated." + replies." (:require - [clojure.java.io :as io] [clojure.spec.alpha :as s] [clojure.string :as string] [clojure.data.json :as json] - [clj-http.client :as client] - [clojure.java.jdbc :as jdbc] [discljord.messaging :as discord] [taoensso.timbre :refer [debug info warn error]] [yetibot.core.adapters.adapter :as a] @@ -27,32 +20,18 @@ [yetibot.core.db :as db] [yetibot.core.db.agent-run :as agent-run] [yetibot.core.db.alias :as db.alias] - [yetibot.core.db.util :as db.util] [yetibot.core.hooks :refer [cmd-hook]] [yetibot.core.interpreter :as interp] - [yetibot.core.loader :as loader] [yetibot.core.models.help :as help] - [yetibot.core.commands.alias :as alias] [yetibot.core.handler :refer [record-and-run-raw]] + [yetibot.core.util.ai-gateway :as ai-gateway] [yetibot.core.util.gemini :as gemini]) - (:import - [java.nio.file Files] - [java.nio.file.attribute FileAttribute] - [java.security KeyFactory Signature] - [java.security.spec PKCS8EncodedKeySpec] - [java.util Base64]) (:gen-class)) ;; --------------------------------------------------------------------------- -;; Config -;; -;; YB_GEMINI_KEY - Gemini API key (required) -;; YB_GEMINI_CLI - path to the gemini CLI binary (default "gemini") -;; -;; GitHub auth, in order of preference: -;; GitHub App: YB_GITHUB_APP_ID + YB_GITHUB_APP_PRIVATE_KEY -;; (+ optional YB_GITHUB_APP_INSTALLATION_ID) -;; Static token: YB_GITHUB_TOKEN +;; Config β€” the agent talks to a Cloudflare AI Gateway (see util.ai-gateway). +;; The model lives under [:agent :model]; budget knobs stay in util.gemini's +;; shared monthly ledger. ;; --------------------------------------------------------------------------- (s/def ::str string?) @@ -61,12 +40,12 @@ (defn- config-str [path] (:value (get-config ::str path))) -;; Gemini model for the agent. Defaults to the strongest current Pro model; -;; override with [:gemini :agent :model] (e.g. "gemini-3.5-flash") for speed. -(defn model [] (or (config-str [:gemini :agent :model]) "gemini-3.1-pro-preview")) - -(defn gemini-key [] (config-str [:gemini :key])) -(defn cli-bin [] (or (config-str [:gemini :cli]) "gemini")) +;; The agent's model, routed through the gateway. Defaults to a fast Kimi model; +;; override with [:agent :model]. The old [:gemini :agent :model] is honored too. +(defn model [] + (or (config-str [:agent :model]) + (config-str [:gemini :agent :model]) + "kimi-k2.5")) (defn- config-num [path default] (let [v (:value (get-config ::id path))] @@ -75,12 +54,9 @@ (string? v) (try (Long/parseLong v) (catch Exception _ default)) :else default))) -;; How long the headless Gemini run may take before the bot kills it, and how -;; many agent turns it may take. Both configurable under [:gemini :agent]. -(defn agent-timeout-ms [] (config-num [:gemini :agent :timeout-ms] 900000)) -(defn agent-max-turns [] (config-num [:gemini :agent :max-turns] 50)) -;; how long a leftover scratch dir may linger before the sweep reaps it (1 day) -(defn agent-workdir-max-age-ms [] (config-num [:gemini :agent :workdir-max-age-ms] 86400000)) +;; Wall-clock ceiling on a single agent reply (the gateway HTTP call's socket +;; timeout), configurable under [:agent :timeout-ms]. +(defn agent-timeout-ms [] (config-num [:agent :timeout-ms] 900000)) ;; Restart resilience: an in-flight run is persisted and, after a restart that ;; killed it, re-dispatched on the next boot. @@ -88,62 +64,43 @@ ;; resume-stale-ms - skip resuming a run older than this (default 6h) ;; resume-ready-ms - how long to wait at boot for the DB + an adapter to be live ;; resume-stagger-ms - gap between resumed dispatches (2 cores, uncapped concurrency) -(defn agent-max-attempts [] (config-num [:gemini :agent :max-attempts] 2)) -(defn agent-resume-stale-ms [] (config-num [:gemini :agent :resume-stale-ms] 21600000)) -(defn agent-resume-ready-ms [] (config-num [:gemini :agent :resume-ready-ms] 60000)) -(defn agent-resume-stagger-ms [] (config-num [:gemini :agent :resume-stagger-ms] 3000)) - -(defn github-pat [] (config-str [:github :token])) - -(defn app-id [] - (some-> (:value (get-config ::id [:github :app :id])) str)) - -(defn app-private-key - "The GitHub App's PEM private key. Tolerates env-var encoded newlines (\\n)." - [] - (some-> (config-str [:github :app :private-key]) - (string/replace "\\n" "\n"))) - -(defn app-configured? [] - (boolean (and (not (string/blank? (app-id))) - (not (string/blank? (app-private-key)))))) - -(defn github-auth-configured? [] - (or (app-configured?) (not (string/blank? (github-pat))))) +(defn agent-max-attempts [] (config-num [:agent :max-attempts] 2)) +(defn agent-resume-stale-ms [] (config-num [:agent :resume-stale-ms] 21600000)) +(defn agent-resume-ready-ms [] (config-num [:agent :resume-ready-ms] 60000)) +(defn agent-resume-stagger-ms [] (config-num [:agent :resume-stagger-ms] 3000)) (defn configured? - "Available only when Gemini and some GitHub auth are set." + "Available only when the Cloudflare AI Gateway is configured." [] - (boolean (and (not (string/blank? (gemini-key))) - (github-auth-configured?)))) + (ai-gateway/configured?)) ;; --------------------------------------------------------------------------- ;; Persona β€” Bonzi Buddy voice for the agent's chat messages only. ;; --------------------------------------------------------------------------- -;; Yetibot is the middleman. One transient status message shows the latest step -;; while Gemini works; it's deleted at the end and replaced by a clean summary. +;; Yetibot is the middleman. One transient status message shows that work is in +;; flight; it's deleted at the end and replaced by a clean summary. (defn say-working - "Transient status message, deleted once Gemini returns its final answer." + "Transient status message, deleted once the agent returns its final answer." [] "🐡 Bonzi Buddy is swinging into action! Please wait a moment…") (defn say-final - "The clean final reply: Gemini's summary plus links to any relevant PRs." + "The clean final reply: the agent's answer plus links to any URLs it cited." [summary pr-urls] (str (if (string/blank? summary) "βœ… done." (str "βœ… " summary)) (when (seq pr-urls) (str "\n\nπŸ”— " (string/join " β€’ " (distinct pr-urls)))))) (defn say-broken [msg] - (str "⚠️ Gemini error: " msg)) + (str "⚠️ agent error: " msg)) (defn say-timeout [minutes] (str "⏰ timed out after " minutes " min β€” try a smaller ask?")) (defn say-unconfigured [] - (str "🍌 Oh no! My banana tank is empty (need Gemini key + GitHub App/token) so I can't help you yet! 🍌")) + (str "🍌 Oh no! My banana tank is empty (need a Cloudflare AI Gateway + model key) so I can't help you yet! 🍌")) (defn say-resuming [] "🐡 Bonzi got bumped by a reboot, but I'm swinging back into action!…") @@ -155,13 +112,11 @@ "πŸ’€ Bonzi fell asleep waiting β€” ask again?") (defn resume-request - "Prefix a request for a resumed run so Gemini continues whatever its interrupted - attempt already started instead of duplicating it." + "Prefix a request for a resumed run so the agent picks the interrupted ask back + up rather than starting over." [request] - (str "(Your previous attempt at this was interrupted by a restart before it could " - "finish. Before doing anything else, run `gh pr list` and check for a branch " - "you may have already pushed for this β€” if one exists, continue and finish " - "that work rather than opening a duplicate PR.)\n\n" + (str "(Your previous reply to this was interrupted by a restart before it could " + "be sent. Please answer the request below.)\n\n" request)) ;; --------------------------------------------------------------------------- @@ -178,38 +133,6 @@ (string/replace #"(://)[^:/@\s]+(@)" "$1***$2") (string/replace #"gh[pousr]_[A-Za-z0-9]{20,}" "***")))) -(def ^:private workdir-prefix "yetibot-agent-") - -(defn- delete-tree! [^java.io.File dir] - (when (.exists dir) - (doseq [f (reverse (file-seq dir))] (.delete f)))) - -(defn- work-dir - "A unique scratch dir (under the system temp dir, i.e. /tmp) for one agent run, - namespaced by the thread/target so concurrent runs in different threads never - share a checkout. createTempDirectory guarantees uniqueness; the target tag - just makes ownership obvious on disk." - [target] - (let [tag (-> (str target) (string/replace #"[^A-Za-z0-9_-]" "")) - tag (subs tag 0 (min 40 (count tag)))] - (.toFile (Files/createTempDirectory (str workdir-prefix tag "-") - (make-array FileAttribute 0))))) - -(defn sweep-stale-workdirs! - "Best-effort cleanup of agent scratch dirs orphaned by a crash: delete any - leftover under the temp dir older than `max-age-ms`. Each run also cleans its - own dir in a finally; this is the safety net." - [max-age-ms] - (try - (let [cutoff (- (System/currentTimeMillis) max-age-ms) - tmp (io/file (System/getProperty "java.io.tmpdir"))] - (doseq [^java.io.File d (or (.listFiles tmp) []) - :when (and (.isDirectory d) - (string/starts-with? (.getName d) workdir-prefix) - (< (.lastModified d) cutoff))] - (delete-tree! d))) - (catch Exception e (debug "sweep-stale-workdirs! failed:" (.getMessage e))))) - (defn pr-urls "GitHub pull request URLs mentioned in text, de-duplicated." [text] @@ -218,9 +141,9 @@ (defn mention-glossary "A note mapping each Discord mention in the message to the person's server - display name and their <@id> token. The request keeps its <@id> tokens so - Gemini's reply can ping people (Discord renders their server name); this just - tells Gemini who's who. Empty string when there are no mentions." + display name and their <@id> token. The request keeps its <@id> tokens so the + agent's reply can ping people (Discord renders their server name); this just + tells the agent who's who. Empty string when there are no mentions." [mentions] (let [lines (for [{:keys [id username global-name member]} mentions :when id] @@ -231,313 +154,49 @@ (string/join "\n" lines)) ""))) -(defn parse-json-response - "Pull the `response` field out of Gemini's --output-format json stdout, - tolerating any leading non-JSON noise." - [stdout] - (let [grab #(-> (json/read-str % :key-fn keyword) :response)] - (try (grab stdout) - (catch Exception _ - (when-let [m (re-find #"(?s)\{.*\}" (or stdout ""))] - (try (grab m) (catch Exception _ nil))))))) - ;; --------------------------------------------------------------------------- -;; GitHub auth β€” enough to mint a token to hand Gemini as GH_TOKEN +;; The agent prompt β€” a chat exchange (system persona + the teammate's request) ;; --------------------------------------------------------------------------- -(def ^:private api-base "https://api.github.com") - -(defn- gh-headers [auth] - {"Authorization" auth - "Accept" "application/vnd.github+json" - "X-GitHub-Api-Version" "2022-11-28"}) - -(defn- gh-get [url auth] - (client/get url {:headers (gh-headers auth) :as :json - :coerce :always :throw-exceptions false})) - -(defn- gh-ok [{:keys [status body]} what] - (if (<= 200 status 299) - body - (throw (ex-info (str what " failed: " (or (:message body) status)) - {:status status :body body})))) - -;; -- RS256 JWT (pure JDK; no BouncyCastle, to avoid classpath conflicts) ------ - -(defn- b64url [^bytes bs] - (.encodeToString (.withoutPadding (Base64/getUrlEncoder)) bs)) - -(defn- pem->der [pem] - (-> pem - (string/replace #"-----(BEGIN|END)[A-Z ]+-----" "") - (string/replace #"\s" "") - (->> (.decode (Base64/getDecoder))))) - -(defn- der-tlv [tag ^bytes content] - (let [n (count content) - len (cond - (< n 0x80) [n] - (< n 0x100) [0x81 n] - :else [0x82 (bit-shift-right n 8) (bit-and n 0xff)])] - (byte-array (concat [tag] (map unchecked-byte len) content)))) - -(defn- pkcs1->pkcs8 [^bytes pkcs1] - (let [version (byte-array [0x02 0x01 0x00]) - rsa-alg (byte-array (map unchecked-byte - [0x30 0x0d 0x06 0x09 0x2a 0x86 0x48 - 0x86 0xf7 0x0d 0x01 0x01 0x01 0x05 0x00]))] - (der-tlv 0x30 (byte-array (concat version rsa-alg (der-tlv 0x04 pkcs1)))))) - -(defn- rsa-private-key [pem] - (let [der (pem->der pem) - der (if (string/includes? pem "BEGIN RSA PRIVATE KEY") - (pkcs1->pkcs8 der) - der)] - (.generatePrivate (KeyFactory/getInstance "RSA") - (PKCS8EncodedKeySpec. der)))) - -(defn- rs256 [^String signing-input pem] - (b64url (-> (doto (Signature/getInstance "SHA256withRSA") - (.initSign (rsa-private-key pem)) - (.update (.getBytes signing-input "UTF-8"))) - (.sign)))) - -(defn app-jwt [] - (let [now (quot (System/currentTimeMillis) 1000) - seg (fn [m] (b64url (.getBytes (json/write-str m) "UTF-8"))) - signing-input (str (seg {:alg "RS256" :typ "JWT"}) - "." - (seg {:iat (- now 60) :exp (+ now (* 9 60)) :iss (app-id)}))] - (str signing-input "." (rs256 signing-input (app-private-key))))) - -(defn any-installation-id - "An installation id for the App: the configured one, else its first install." - [jwt-token] - (or (config-str [:github :app :installation-id]) - (-> (gh-get (str api-base "/app/installations") (str "Bearer " jwt-token)) - (gh-ok "list app installations") - first :id))) - -(defn github-token - "A token to hand Gemini as GH_TOKEN: a freshly-minted App installation token - (scoped to everything the App can reach), or the static PAT." +(defn build-system-prompt + "The agent's persona and ground rules. It operates purely through chat β€” it + cannot run commands, browse, or open pull requests." [] - (if (app-configured?) - (let [jwt-token (app-jwt)] - (-> (client/post (format "%s/app/installations/%s/access_tokens" - api-base (any-installation-id jwt-token)) - {:headers (gh-headers (str "Bearer " jwt-token)) - :as :json :coerce :always :throw-exceptions false}) - (gh-ok "GitHub App token exchange") - :token)) - (github-pat))) - -;; --------------------------------------------------------------------------- -;; The agent prompt β€” Gemini does everything via gh + git -;; --------------------------------------------------------------------------- - -(defn build-agent-prompt [request context mentions] - (str "You are Yetibot β€” the team's coding-agent bot, appearing as @Yetibot in " - "their chat. A teammate gives you a request and you carry out THAT request " - "end to end through the `gh` CLI and `git`, running non-interactively.\n\n" - "SCOPE β€” read this first:\n" - "- Do exactly what the request asks, and nothing more. Touch only the " - "specific repo and files the request needs.\n" - "- Do NOT survey or list other repos, and do NOT look at CI, tests, or PR " - "status unless the request is explicitly about those β€” a request to change " - "code is not a request to audit CI.\n" - "- If you can't tell what the request refers to, reply briefly asking for " - "the missing detail. NEVER invent work or report an unrelated summary like " - "\"checked all repos, everything green\".\n" - "- If you need more background context than what is provided in the thread " - "context, you are highly encouraged to search the entire channel's history " - "using the `yetibot` tool with the `history` command (e.g., `history` or " - "`history | grep keyword`). It is your job as an autonomous agent to " - "perform this extra search when needed!\n\n" - "The codebase (so you can go straight to the right place β€” don't rediscover " - "it). The org is `yetibot`:\n" - "- `yetibot/core` β€” the library: chat commands, adapters, and most logic.\n" - " β€’ `src/yetibot/core/commands/.clj` β€” one file per chat command, " - "loaded dynamically (a new command is just a new file).\n" - " β€’ `src/yetibot/core/adapters/` β€” chat adapters (discord, slack, irc, " - "mattermost).\n" - " β€’ `src/yetibot/core/util/` β€” shared utils (e.g. `gemini.clj`: Gemini " - "image generation, Veo video, and the monthly budget).\n" - " β€’ `src/yetibot/core/webapp/routes/` β€” web routes (e.g. `images.clj` " - "serves generated media).\n" - " β€’ `test/yetibot/core/test/…` mirrors `src/`; tests are midje, run with " - "`lein test`.\n" - "- `yetibot/yetibot` β€” the deployable bot; pins `yetibot/core` and holds " - "config/deploy.\n" - "Most command and feature work is in `yetibot/core` β€” clone just that.\n\n" - "Your working directory is an EMPTY scratch dir β€” there is no code here, so " - "get what you need yourself; never wait for files or ask the user to add " - "code (GH_TOKEN is set, you have write access).\n" - "- Code change: clone the one relevant repo over HTTPS (`gh repo clone " - "/` β€” never SSH, never fork), set git config user.name " - "'Yetibot' / user.email 'yetibot@yetibot.com', make a minimal change on a " - "new branch, push to origin, and `gh pr create`.\n" - "- Question: just answer it; clone only if the answer needs the code.\n\n" - "Tools (shell): `gh` (authenticated) and `git`.\n\n" - "Introspecting and running Yetibot commands:\n" - "You have a built-in `yetibot` tool. You can use it to execute any built-in Yetibot command or alias directly. Do NOT run them as shell commands; always use the `yetibot` tool call.\n" - "Call the `yetibot` tool with the `command` argument (e.g. `{\"command\": \"temps\"}`). For example:\n" - "- `yetibot` with command \"agent list-commands\": returns all available built-in commands as a JSON map\n" - "- `yetibot` with command \"agent list-aliases\": returns all configured command aliases as a JSON map\n" - "- `yetibot` with command \"temps\": runs the `temps` alias to get weather/temp data\n" - "- `yetibot` with command \"kroki \": generates a chart using kroki\n\n" - "For example, you can call the `yetibot` tool with \"agent list-aliases\", find a weather/temp alias, run it using the `yetibot` tool to get its data, and then pass that data to another command like `yetibot` with \"kroki ...\" to generate a chart!\n\n" - (when-not (string/blank? mentions) (str mentions "\n\n")) + (str "You are Yetibot β€” the team's helpful assistant bot, appearing as @Yetibot " + "in their chat, in the brief, playful, and cheerful persona of Bonzi Buddy, " + "the classic purple gorilla Windows assistant (keep every fact exact).\n\n" + "Answer the teammate's request directly and concisely: questions, code, " + "debugging, reviews, ideas. You operate purely through chat β€” you cannot run " + "commands, browse the web, or open pull requests, so never claim to have done " + "so. If a request needs an action you can't take, say so plainly. If you can't " + "tell what the request refers to, ask briefly for the missing detail rather " + "than inventing an answer.\n\n" + "Reply with ONLY your final answer β€” no step-by-step narration or lists of " + "justifications. When you address a person, write their Discord mention token " + "<@id> verbatim (e.g. <@49312021375614976>) β€” it pings them and Discord shows " + "their server name; never invent names or use raw numeric ids.")) + +(defn build-user-message + "The teammate's request, with the mention glossary and thread context (both + reference-only) folded in." + [request context mentions] + (str (when-not (string/blank? mentions) (str mentions "\n\n")) (when-not (string/blank? context) (str "This thread's conversation so far, for REFERENCE ONLY β€” background, " "not a task list. Use it only to resolve what the request refers to " - "(e.g. a \"retry\" or follow-up points back to an earlier ask here). " - "Do NOT act on it on your own or investigate anything just because " - "it's mentioned:\n────\n" (string/trim context) "\n────\n\n")) - "The teammate's request:\n" (string/trim request) "\n\n" - "When you mention or address a person, write their Discord mention token " - "<@id> verbatim (e.g. <@49312021375614976>) β€” it pings them and Discord " - "shows their server name; never invent names or use raw numeric ids.\n\n" - "Now do the work, then reply with ONLY your final answer β€” concise, direct, " - "and in the brief, playful, and cheerful persona of Bonzi Buddy, the classic purple " - "gorilla Windows assistant (keep the facts exact). Do NOT include any step-by-step " - "narration or lists of justifications/explanations in your final reply. " - "Just provide the final conclusion or result. Reference any pull requests as full " - "URLs (https://github.com/owner/repo/pull/123), never the #123 shorthand.")) - -;; Authenticate git pushes to github.com with GH_TOKEN, so the agent's plain -;; `git push` over HTTPS works without prompting (the token alone only auths the -;; `gh` API, not git). The helper reads GH_TOKEN from the environment at runtime. -(def ^:private git-credential-helper - "!f() { echo username=x-access-token; echo \"password=$GH_TOKEN\"; }; f") - -(defn- kill-tree! - "Forcibly kill a process and all of its descendants. Gemini spawns bun/git/gh - children; killing only the parent would orphan them and pile up CPU load." - [^Process proc] - (let [descendants (doall (iterator-seq (.iterator ^java.util.stream.Stream (.descendants proc))))] - (doseq [^java.lang.ProcessHandle h (cons (.toHandle proc) descendants)] - (try (.destroyForcibly h) (catch Exception _ nil))))) - -(defn run-gemini-agent - "Run the Gemini CLI headlessly with structured JSON output (no intermediate - narration on stdout; stderr is discarded). Returns - {:response :exit n :timed-out bool}." - [workdir request context mentions token] - ;; cap the agent's turn budget via a workspace settings file, configure custom tools, and hide model reasoning to keep responses concise - (let [settings-dir (io/file workdir ".gemini")] - (.mkdirs settings-dir) - (spit (io/file settings-dir "settings.json") - (json/write-str {:maxSessionTurns (agent-max-turns) - :tools {:discoveryCommand "./yetibot-tool.py --list" - :callCommand "./yetibot-tool.py"} - :ui {:inlineThinkingMode "off"} - :modelConfig {:generateContentConfig {:thinkingConfig {:includeThoughts false - :thinkingBudget 16000}}}}))) - ;; write the yetibot custom tool script - (let [yetibot-tool-script (io/file workdir "yetibot-tool.py")] - (spit yetibot-tool-script - (str "#!/usr/bin/env python3\n" - "import sys\n" - "import json\n" - "import urllib.request\n" - "import urllib.parse\n" - "import os\n\n" - "if len(sys.argv) > 1 and sys.argv[1] == \"--list\":\n" - " tools = [\n" - " {\n" - " \"name\": \"yetibot\",\n" - " \"description\": \"Execute any built-in Yetibot command or alias. Examples: 'temps', 'kroki '.\",\n" - " \"inputSchema\": {\n" - " \"type\": \"object\",\n" - " \"properties\": {\n" - " \"command\": {\n" - " \"type\": \"string\",\n" - " \"description\": \"The Yetibot command or alias with its arguments to execute.\"\n" - " }\n" - " },\n" - " \"required\": [\"command\"]\n" - " }\n" - " }\n" - " ]\n" - " print(json.dumps(tools))\n" - " sys.exit(0)\n\n" - "if len(sys.argv) > 1 and sys.argv[1] == \"yetibot\":\n" - " try:\n" - " input_data = json.loads(sys.stdin.read())\n" - " cmd = input_data.get(\"command\", \"\")\n" - " except Exception as e:\n" - " print(json.dumps({\n" - " \"content\": [{\"type\": \"text\", \"text\": f\"Error parsing stdin JSON: {str(e)}\"}],\n" - " \"isError\": True\n" - " }))\n" - " sys.exit(0)\n\n" - " if not cmd:\n" - " print(json.dumps({\n" - " \"content\": [{\"type\": \"text\", \"text\": \"Error: command parameter is missing\"}],\n" - " \"isError\": True\n" - " }))\n" - " sys.exit(0)\n\n" - " if cmd.startswith(\"agent \"):\n" - " payload = cmd\n" - " else:\n" - " payload = f\"agent run {cmd}\"\n\n" - " port = os.environ.get(\"YETIBOT_PORT\", \"3003\")\n" - " url = f\"http://localhost:{port}/api\"\n" - " data = urllib.parse.urlencode({\n" - " \"chat-source\": \"{:adapter :agent :room \\\"agent-room\\\"}\",\n" - " \"command\": payload\n" - " }).encode(\"utf-8\")\n\n" - " try:\n" - " req = urllib.request.Request(url, data=data, method=\"POST\")\n" - " with urllib.request.urlopen(req) as response:\n" - " res_text = response.read().decode(\"utf-8\")\n" - " print(json.dumps({\n" - " \"content\": [{\"type\": \"text\", \"text\": res_text}],\n" - " \"isError\": False\n" - " }))\n" - " except Exception as e:\n" - " print(json.dumps({\n" - " \"content\": [{\"type\": \"text\", \"text\": f\"API request failed: {str(e)}\"}],\n" - " \"isError\": True\n" - " }))\n" - " sys.exit(0)\n")) - (.setExecutable yetibot-tool-script true)) - (let [pb (doto (ProcessBuilder. [(cli-bin) "--yolo" "--output-format" "json" - "--model" (model) - "--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"))) - ;; 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/")) - (info "running gemini agent" (cli-bin) "in" (str workdir)) - (let [proc (.start pb) - timed-out (atom false) - ;; hard wall-clock cap: kill the run if it overruns - watchdog (future - (Thread/sleep (agent-timeout-ms)) - (when (.isAlive proc) - (reset! timed-out true) - (kill-tree! proc))) - stdout (slurp (.getInputStream proc)) - exit (.waitFor proc)] - (future-cancel watchdog) - {:response (redact (parse-json-response stdout)) - :exit exit - :timed-out @timed-out}))) + "(e.g. a \"retry\" or follow-up points back to an earlier ask here):\n" + "────\n" (string/trim context) "\n────\n\n")) + "The teammate's request:\n" (string/trim request))) + +(defn run-model + "Send one chat exchange through the gateway and return the agent's reply text. + Throws on a non-2xx gateway response or a transport timeout." + [request context mentions] + (:text (ai-gateway/chat {:model (model) + :timeout-ms (agent-timeout-ms) + :messages [{:role "system" :content (build-system-prompt)} + {:role "user" + :content (build-user-message request context mentions)}]}))) ;; --------------------------------------------------------------------------- ;; Discord thread plumbing (guarded; degrades to plain replies elsewhere) @@ -637,38 +296,31 @@ ;; --------------------------------------------------------------------------- (defn run-agent - "Async body: mint a token, run Gemini headlessly, then delete the transient - status message and post one clean final reply β€” Gemini's answer plus links to - any relevant PRs. No intermediate narration." + "Async body: send the request through the gateway, then delete the transient + status message and post one clean final reply. No intermediate narration." [{:keys [request target context-channel on-discord status-id mentions run-id]}] (binding [chat/*target* target] - (sweep-stale-workdirs! (agent-workdir-max-age-ms)) - (let [dir (work-dir target)] - (try - (gemini/check-budget!) - (let [context (when on-discord (thread-context context-channel)) - token (github-token) - {:keys [response exit timed-out]} (run-gemini-agent dir request context mentions token) - _ (gemini/record-image-generated! (gemini/agent-cost-units)) - reply (cond - timed-out (say-timeout (quot (agent-timeout-ms) 60000)) - (not (string/blank? response)) (say-final response (pr-urls response)) - (pos? exit) (say-broken (str "exited " exit - " β€” no answer returned (a PR may still have been opened)")) - :else (say-final "done." nil))] - (when (and on-discord status-id) (delete-msg! target status-id)) - (chat/send-msg reply)) - (catch Exception e - (error "agent command failed" e) - (when (and on-discord status-id) (delete-msg! target status-id)) - (chat/send-msg (say-broken (.getMessage e)))) - (finally - (try (delete-tree! dir) - (catch Exception e (warn "cleanup failed" (str dir) e))) - (clear-run! run-id)))))) + (try + (gemini/check-budget!) + (let [context (when on-discord (thread-context context-channel)) + response (run-model request context mentions)] + (gemini/record-image-generated! (gemini/agent-cost-units)) + (when (and on-discord status-id) (delete-msg! target status-id)) + (chat/send-msg (if (string/blank? response) + (say-final "done." nil) + (say-final response (pr-urls response))))) + (catch java.net.SocketTimeoutException _ + (when (and on-discord status-id) (delete-msg! target status-id)) + (chat/send-msg (say-timeout (quot (agent-timeout-ms) 60000)))) + (catch Exception e + (error "agent command failed" e) + (when (and on-discord status-id) (delete-msg! target status-id)) + (chat/send-msg (say-broken (redact (.getMessage e))))) + (finally + (clear-run! run-id))))) (defn agent-cmd - "agent # hand the request to Gemini (gh+git) and reply with its answer" + "agent # ask the Kimi-powered agent and reply with its answer" {:yb/cat #{:util}} [{[_ request] :match chat-source :chat-source}] (cond @@ -676,7 +328,7 @@ :else (let [adapter chat/*adapter* {:keys [raw-event]} chat-source - ;; keep <@id> tokens in the request; give Gemini a name glossary so its + ;; keep <@id> tokens in the request; give the agent a name glossary so its ;; reply pings the right people (Discord renders their server names) mentions (mention-glossary (:mentions raw-event)) channel (or (:channel-id raw-event) chat/*target*) @@ -739,8 +391,8 @@ (chat/send-msg msg)))) (defn- dispatch-resume! - "Re-run an interrupted run, restoring its adapter + thread and nudging Gemini to - continue any work it already started." + "Re-run an interrupted run, restoring its adapter + thread and nudging the agent + to pick the request back up." [{:keys [run-id request target context-channel on-discord status-id mentions adapter-uuid]}] (if-let [adapter (adapter-by-uuid adapter-uuid)] @@ -811,7 +463,7 @@ out (string/join "\n" (map #(or (:result %) (:error %)) results))] {:result/value out})) -;; Register only when Gemini + GitHub auth are configured. +;; Register only when the Cloudflare AI Gateway is configured. (when (configured?) (cmd-hook #"agent" #"list-commands" agent-list-commands-cmd diff --git a/src/yetibot/core/util/ai_gateway.clj b/src/yetibot/core/util/ai_gateway.clj new file mode 100644 index 00000000..71f84039 --- /dev/null +++ b/src/yetibot/core/util/ai_gateway.clj @@ -0,0 +1,71 @@ +(ns yetibot.core.util.ai-gateway + "OpenAI-compatible chat completions routed through a Cloudflare AI Gateway. + + The gateway fronts any upstream provider (Moonshot/Kimi by default) so every + model call is observable and cost-controlled in one place. Settings live under + [:cloudflare :ai-gateway]: + + :account-id Cloudflare account id + :gateway-id the AI Gateway's slug + :provider gateway route / custom-provider prefix (default \"custom-moonshot\") + :api-key the upstream provider key, sent as the Bearer token + :auth-token optional cf-aig-authorization token (Authenticated Gateway)" + (:require [clj-http.client :as client] + [clojure.data.json :as json] + [clojure.string :as string] + [taoensso.timbre :refer [error]] + [yetibot.core.config :refer [get-config]])) + +(defn- cfg [k] (:value (get-config string? [:cloudflare :ai-gateway k]))) + +(defn- account-id [] (cfg :account-id)) +(defn- gateway-id [] (cfg :gateway-id)) +(defn- provider [] (or (cfg :provider) "custom-moonshot")) +(defn- api-key [] (cfg :api-key)) +(defn- auth-token [] (cfg :auth-token)) + +(defn configured? [] + (boolean (and (not (string/blank? (account-id))) + (not (string/blank? (gateway-id))) + (not (string/blank? (api-key)))))) + +(defn- endpoint [] + (format "https://gateway.ai.cloudflare.com/v1/%s/%s/compat/chat/completions" + (account-id) (gateway-id))) + +(defn- qualified-model + "Prefix the model with the gateway provider route (e.g. kimi-k2.5 -> + custom-moonshot/kimi-k2.5) so the unified endpoint knows where to route." + [model] + (let [p (provider)] + (if (string/blank? p) model (str p "/" model)))) + +(defn- redact [s] + (some-> s (string/replace #"(?i)(Bearer\s+)[\w._-]+" "$1***"))) + +(defn chat + "POST a chat completion through the gateway. `messages` is a vector of + {:role .. :content ..}. Returns {:text :usage }. + Throws ex-info on a non-2xx response; clj-http surfaces transport timeouts." + [{:keys [model messages timeout-ms]}] + (let [headers (cond-> {"Authorization" (str "Bearer " (api-key))} + (not (string/blank? (auth-token))) + (assoc "cf-aig-authorization" (str "Bearer " (auth-token)))) + resp (client/post (endpoint) + {:headers headers + :content-type :json + :body (json/write-str {:model (qualified-model model) + :messages messages} + :escape-slash false) + :as :json + :connection-timeout 10000 + :socket-timeout (or timeout-ms 120000) + :throw-exceptions false}) + status (:status resp)] + (when-not (<= 200 status 299) + (let [msg (redact (str (:body resp)))] + (error "ai-gateway: error" status "-" msg) + (throw (ex-info (str "AI gateway error (" status "): " msg) + {:type :ai-gateway-error :status status})))) + {:text (some-> (get-in resp [:body :choices 0 :message :content]) string/trim) + :usage (get-in resp [:body :usage])})) diff --git a/src/yetibot/core/util/gemini.clj b/src/yetibot/core/util/gemini.clj index d1bd6069..876953fe 100644 --- a/src/yetibot/core/util/gemini.clj +++ b/src/yetibot/core/util/gemini.clj @@ -69,7 +69,9 @@ (long (Math/floor (/ (monthly-budget) (cost-per-image))))) (defn- agent-cost-per-session [] - (or (parse-number (-> config :agent :cost-per-session)) 1.00)) + "Estimated USD an agent reply draws from the shared monthly budget. Defaults to + a fast Kimi chat turn; override with [:gemini :agent :cost-per-session]." + (or (parse-number (-> config :agent :cost-per-session)) 0.05)) (defn agent-cost-units [] (max 1 (long (Math/ceil (/ (agent-cost-per-session) (cost-per-image)))))) diff --git a/test/yetibot/core/test/commands/agent.clj b/test/yetibot/core/test/commands/agent.clj index 8975e554..8e72ab13 100644 --- a/test/yetibot/core/test/commands/agent.clj +++ b/test/yetibot/core/test/commands/agent.clj @@ -3,12 +3,8 @@ [midje.sweet :refer [fact facts => anything]] [midje.checkers :refer [contains]] [clojure.string :as string] - [clojure.data.json :as json] - [clj-http.client :as client] - [yetibot.core.commands.agent :as agent]) - (:import - [java.security KeyPairGenerator Signature] - [java.util Arrays Base64])) + [yetibot.core.util.ai-gateway :as ai-gateway] + [yetibot.core.commands.agent :as agent])) (facts "about redact" (fact "strips an embedded token from a url" @@ -30,59 +26,53 @@ (fact "empty when none" (agent/pr-urls "no prs here") => [])) -(facts "about build-agent-prompt" - (fact "tells gemini it can use the gh cli" - (agent/build-agent-prompt "do x" nil nil) => (contains "gh")) - (fact "instructs it to open a pull request" - (agent/build-agent-prompt "do x" nil nil) => (contains "pull request")) +(facts "about build-system-prompt" + (fact "gives the bot an identity and persona" + (agent/build-system-prompt) => (contains "Yetibot") + (agent/build-system-prompt) => (contains "Bonzi")) + (fact "is clear it can't take actions like opening PRs" + (agent/build-system-prompt) => (contains "cannot") + (agent/build-system-prompt) => (contains "pull requests")) (fact "asks for the final answer only (no narration)" - (agent/build-agent-prompt "do x" nil nil) => (contains "final answer")) + (agent/build-system-prompt) => (contains "final answer")) + (fact "tells the agent to mention people with their <@id> token" + (agent/build-system-prompt) => (contains "<@id>"))) + +(facts "about build-user-message" + (fact "includes the request" + (agent/build-user-message "do x" nil nil) => (contains "do x")) (fact "includes conversation context when present" - (agent/build-agent-prompt "do x" "alice: hi" nil) => (contains "alice: hi")) + (agent/build-user-message "do x" "alice: hi" nil) => (contains "alice: hi")) (fact "omits the context section when blank" - (agent/build-agent-prompt "do x" "" nil) => #(not (string/includes? % "Recent conversation"))) - (fact "tells gemini to use HTTPS (no SSH, no fork)" - (agent/build-agent-prompt "do x" nil nil) => (contains "HTTPS")) - (fact "warns the working dir is empty and to clone, never wait for files" - (agent/build-agent-prompt "do x" nil nil) => (contains "EMPTY")) - (fact "tells gemini to mention people with their <@id> token" - (agent/build-agent-prompt "do x" nil nil) => (contains "<@id>")) + (agent/build-user-message "do x" "" nil) => #(not (string/includes? % "REFERENCE ONLY"))) (fact "includes the mention glossary when present" - (agent/build-agent-prompt "do x" nil "β€’ <@1> is Bob") => (contains "<@1> is Bob")) - (fact "gives the bot an identity" - (agent/build-agent-prompt "do x" nil nil) => (contains "Yetibot")) - (fact "tells gemini to use yetibot tool to run yetibot commands" - (agent/build-agent-prompt "do x" nil nil) => (contains "yetibot")) - (fact "encourages searching entire channel if needed" - (agent/build-agent-prompt "do x" nil nil) => (contains "search the entire channel's history"))) - -(facts "about parse-json-response" - (fact "pulls the response field" - (agent/parse-json-response "{\"response\": \"hi there\", \"stats\": {}}") => "hi there") - (fact "tolerates leading noise before the json" - (agent/parse-json-response "warning: foo\n{\"response\": \"ok\"}") => "ok") - (fact "nil on unparseable output" - (agent/parse-json-response "not json at all") => nil)) + (agent/build-user-message "do x" nil "β€’ <@1> is Bob") => (contains "<@1> is Bob"))) (facts "about final messages" (fact "say-working is a generic status, not the prompt" (agent/say-working) => (contains "Bonzi")) - (fact "say-final shows Gemini's answer" + (fact "say-final shows the agent's answer" (agent/say-final "Added the bagif command" nil) => (contains "Added the bagif command")) - (fact "say-final appends relevant PR links" + (fact "say-final appends any cited PR links" (agent/say-final "done" ["https://github.com/yetibot/core/pull/242"]) => (contains "pull/242")) (fact "say-final copes with a blank answer" (agent/say-final "" nil) => (contains "done")) (fact "say-timeout names the limit" (agent/say-timeout 5) => (contains "5 min"))) -(facts "about agent limits config defaults" +(facts "about agent config defaults" (fact "default timeout is 15 minutes" (agent/agent-timeout-ms) => 900000) - (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")) + (fact "default model is a fast Kimi model" + (agent/model) => "kimi-k2.5")) + +(facts "about configured?" + (fact "is available only when the AI gateway is configured" + (agent/configured?) => true + (provided (ai-gateway/configured?) => true)) + (fact "is unavailable when the gateway is not configured" + (agent/configured?) => false + (provided (ai-gateway/configured?) => false))) (facts "about mention-glossary" (fact "prefers the server nickname and keeps the <@id> token" @@ -100,70 +90,6 @@ => (contains "banana") (provided (agent/configured?) => false))) -;; --- GitHub auth: enough to mint GH_TOKEN for Gemini --- - -(facts "about github auth config" - (fact "app-configured? requires both id and private key" - (agent/app-configured?) => true - (provided (agent/app-id) => "123" (agent/app-private-key) => "KEY")) - (fact "github-auth-configured? is satisfied by a PAT alone" - (agent/github-auth-configured?) => true - (provided (agent/app-configured?) => false (agent/github-pat) => "tok")) - (fact "configured? needs gemini and github auth" - (agent/configured?) => true - (provided (agent/gemini-key) => "k" (agent/github-auth-configured?) => true))) - -(facts "about github-token" - (fact "uses the static PAT when no App is configured" - (agent/github-token) => "pat" - (provided (agent/app-configured?) => false (agent/github-pat) => "pat")) - (fact "mints an App installation token, scoped to the whole installation" - (agent/github-token) => "ghs_org" - (provided - (agent/app-configured?) => true - (agent/app-jwt) => "jwt" - (agent/any-installation-id "jwt") => 99 - (client/post "https://api.github.com/app/installations/99/access_tokens" anything) - => {:status 201 :body {:token "ghs_org"}}))) - -;; --- RS256 JWT (JDK crypto, PKCS#8 + PKCS#1) --- - -(defn- rsa-keypair [] - (.generateKeyPair (doto (KeyPairGenerator/getInstance "RSA") (.initialize 2048)))) - -(defn- pem [label ^bytes der] - (str "-----BEGIN " label "-----\n" - (.encodeToString (Base64/getMimeEncoder) der) - "\n-----END " label "-----\n")) - -(defn- pkcs8-pem [kp] (pem "PRIVATE KEY" (.getEncoded (.getPrivate kp)))) - -(defn- pkcs1-pem [kp] - (let [pkcs8 (.getEncoded (.getPrivate kp))] - (pem "RSA PRIVATE KEY" (Arrays/copyOfRange pkcs8 26 (count pkcs8))))) - -(defn- jwt-verifies? [token pub] - (let [[h p s] (string/split token #"\.") - signing-input (str h "." p) - ok? (-> (doto (Signature/getInstance "SHA256withRSA") - (.initVerify pub) - (.update (.getBytes signing-input "UTF-8"))) - (.verify (.decode (Base64/getUrlDecoder) ^String s)))] - {:valid? ok? - :payload (json/read-str (String. (.decode (Base64/getUrlDecoder) ^String p)) - :key-fn keyword)})) - -(facts "about app-jwt" - (fact "signs an RS256 token (PKCS#8 key) verifiable with the matching public key" - (let [kp (rsa-keypair)] - (jwt-verifies? (agent/app-jwt) (.getPublic kp)) - => (fn [{:keys [valid? payload]}] (and valid? (= "123" (:iss payload)))) - (provided (agent/app-id) => "123" (agent/app-private-key) => (pkcs8-pem kp)))) - (fact "also accepts a PKCS#1 key, the format GitHub issues App keys in" - (let [kp (rsa-keypair)] - (:valid? (jwt-verifies? (agent/app-jwt) (.getPublic kp))) => true - (provided (agent/app-id) => "123" (agent/app-private-key) => (pkcs1-pem kp))))) - ;; --- restart resilience: resume runs a restart left in-flight --- (facts "about resume config defaults" @@ -181,8 +107,8 @@ (agent/resume-action 2 200000 2 100000) => :give-up)) (facts "about resume-request" - (fact "prepends a dedup note so a resumed run won't open a duplicate PR" - (agent/resume-request "add a bagif command") => (contains "gh pr list")) + (fact "notes the previous attempt was interrupted" + (agent/resume-request "add a bagif command") => (contains "interrupted")) (fact "keeps the original request text" (agent/resume-request "add a bagif command") => (contains "add a bagif command"))) @@ -222,4 +148,4 @@ (fact "agent-run-cmd evaluates a yetibot command" (agent/agent-run-cmd {:match ["agent run echo hello" "echo hello"]}) => {:result/value "hello"} (provided - (yetibot.core.handler/record-and-run-raw "echo hello" anything nil anything) => [{:result "hello"}])) ) + (yetibot.core.handler/record-and-run-raw "echo hello" anything nil anything) => [{:result "hello"}]))) diff --git a/test/yetibot/core/test/util/ai_gateway_test.clj b/test/yetibot/core/test/util/ai_gateway_test.clj new file mode 100644 index 00000000..d2d06b94 --- /dev/null +++ b/test/yetibot/core/test/util/ai_gateway_test.clj @@ -0,0 +1,65 @@ +(ns yetibot.core.test.util.ai-gateway-test + (:require + [midje.sweet :refer [fact facts => throws provided anything]] + [midje.checkers :refer [checker]] + [clojure.data.json :as json] + [clj-http.client :as client] + [yetibot.core.util.ai-gateway :as ai-gateway])) + +(facts "about configured?" + (fact "true when account, gateway, and key are all set" + (ai-gateway/configured?) => true + (provided (#'ai-gateway/account-id) => "acct" + (#'ai-gateway/gateway-id) => "gw" + (#'ai-gateway/api-key) => "sk-test")) + (fact "false when the key is missing" + (ai-gateway/configured?) => false + (provided (#'ai-gateway/account-id) => "acct" + (#'ai-gateway/gateway-id) => "gw" + (#'ai-gateway/api-key) => ""))) + +(facts "about endpoint + model routing" + (fact "endpoint is the gateway's OpenAI-compat chat completions url" + (#'ai-gateway/endpoint) => "https://gateway.ai.cloudflare.com/v1/acct/gw/compat/chat/completions" + (provided (#'ai-gateway/account-id) => "acct" + (#'ai-gateway/gateway-id) => "gw")) + (fact "the model is prefixed with the provider route" + (#'ai-gateway/qualified-model "kimi-k2.5") => "custom-moonshot/kimi-k2.5" + (provided (#'ai-gateway/provider) => "custom-moonshot")) + (fact "a blank provider leaves the model untouched" + (#'ai-gateway/qualified-model "openai/gpt-5") => "openai/gpt-5" + (provided (#'ai-gateway/provider) => ""))) + +(def ^:private request-ok? + "Checker for the clj-http opts: bearer auth + provider-qualified model in body." + (checker [opts] + (and (= "Bearer sk-test" (get-in opts [:headers "Authorization"])) + (= "custom-moonshot/kimi-k2.5" + (-> (:body opts) (json/read-str :key-fn keyword) :model))))) + +(facts "about chat" + (fact "posts to the compat endpoint with bearer auth + qualified model, returning trimmed text + usage" + (ai-gateway/chat {:model "kimi-k2.5" :messages [{:role "user" :content "hi"}]}) + => {:text "hello there" :usage {:total_tokens 5}} + (provided + (#'ai-gateway/account-id) => "acct" + (#'ai-gateway/gateway-id) => "gw" + (#'ai-gateway/provider) => "custom-moonshot" + (#'ai-gateway/api-key) => "sk-test" + (#'ai-gateway/auth-token) => nil + (client/post "https://gateway.ai.cloudflare.com/v1/acct/gw/compat/chat/completions" + request-ok?) + => {:status 200 + :body {:choices [{:message {:content " hello there "}}] + :usage {:total_tokens 5}}})) + + (fact "throws on a non-2xx response" + (ai-gateway/chat {:model "kimi-k2.5" :messages []}) + => (throws clojure.lang.ExceptionInfo) + (provided + (#'ai-gateway/account-id) => "acct" + (#'ai-gateway/gateway-id) => "gw" + (#'ai-gateway/provider) => "custom-moonshot" + (#'ai-gateway/api-key) => "sk-test" + (#'ai-gateway/auth-token) => nil + (client/post anything anything) => {:status 500 :body {:error "boom"}})))