From 18b6929ad7aa86df877b4edb34fa1ae32a5c130b Mon Sep 17 00:00:00 2001 From: Andrei Simionescu <1249760+andreis@users.noreply.github.com> Date: Fri, 2 Oct 2026 23:40:30 +0100 Subject: [PATCH] Add xAI (Grok) as a Metabot provider (#83431) * Add xAI (Grok) as a Metabot provider * Scroll the provider buttons into view in the setup e2e test * Turn off reasoning on Grok 4.3 for structured output * Report reasoning support for Grok 4.3 * Add xAI to the shared LLM test connections --- .lychee/config.toml | 1 + docs/ai/providers.md | 18 +++ .../onboarding/setup/setup.cy.spec.ts | 11 +- frontend/src/metabase-types/api/llm.ts | 1 + frontend/src/metabase-types/api/settings.ts | 2 + .../ProviderTypeIcon.tsx | 2 + .../ProviderTypeIcon.unit.spec.tsx | 1 + .../AIProviderConfigurationForm/logos/xai.svg | 1 + src/metabase/llm/provider.clj | 21 +++ src/metabase/llm/settings.clj | 21 +++ src/metabase/metabot/self/registry.clj | 6 + src/metabase/metabot/self/xai.clj | 117 +++++++++++++++ test/metabase/llm/api/provider_test.clj | 11 +- test/metabase/llm/provider_test.clj | 6 +- test/metabase/llm/test_util.clj | 5 +- test/metabase/metabot/self/adapter_test.clj | 3 + test/metabase/metabot/self/xai_test.clj | 134 ++++++++++++++++++ test/metabase/metabot/self_test.clj | 6 +- test/metabase/metabot/settings_test.clj | 7 +- 19 files changed, 358 insertions(+), 16 deletions(-) create mode 100644 frontend/src/metabase/metabot/components/AIProviderConfigurationForm/logos/xai.svg create mode 100644 src/metabase/metabot/self/xai.clj create mode 100644 test/metabase/metabot/self/xai_test.clj diff --git a/.lychee/config.toml b/.lychee/config.toml index d62f47bbd2d..c1893123c95 100644 --- a/.lychee/config.toml +++ b/.lychee/config.toml @@ -6,6 +6,7 @@ exclude = [ # Bot-blocked: 403 for anything that isn't a browser. "^https://(www|dev)\\.mysql\\.com/", "^https://platform\\.deepseek\\.com/", + "^https://console\\.x\\.ai/", # Login-walled: redirects forever between console and auth. "^https://console\\.mistral\\.ai/", # GitHub renders issue comments client-side, so lychee can't see #issuecomment anchors. diff --git a/docs/ai/providers.md b/docs/ai/providers.md index 5e68d3deda0..b52d478b707 100644 --- a/docs/ai/providers.md +++ b/docs/ai/providers.md @@ -183,6 +183,24 @@ Credentials: - **API key** (required). [Where do I find this?](https://platform.deepseek.com/api_keys) You can also set it with the environment variable `MB_LLM_DEEPSEEK_API_KEY`. - **API base URL** (advanced). The root both surfaces hang off; leave off any /anthropic or /v1 path. Defaults to `https://api.deepseek.com`. You can also set it with the environment variable `MB_LLM_DEEPSEEK_API_BASE_URL`. +## xAI + +- Provider key: `xai` +- Default model: `grok-4.7` +- Model for short tasks like naming a conversation: `grok-4.3` + +Supported models: + +| Model | Model ID | Context window (tokens) | +| -------- | ---------- | ----------------------- | +| Grok 4.3 | `grok-4.3` | 1,000,000 | +| Grok 4.7 | `grok-4.7` | 500,000 | + +Credentials: + +- **API key** (required). [Where do I find this?](https://console.x.ai/team/default/api-keys) You can also set it with the environment variable `MB_LLM_XAI_API_KEY`. +- **API base URL** (advanced). Defaults to `https://api.x.ai/v1`. You can also set it with the environment variable `MB_LLM_XAI_API_BASE_URL`. + ## Google Gemini Enterprise - Provider key: `google` diff --git a/e2e/test/scenarios/onboarding/setup/setup.cy.spec.ts b/e2e/test/scenarios/onboarding/setup/setup.cy.spec.ts index 6b46426d05d..017cc2b09b9 100644 --- a/e2e/test/scenarios/onboarding/setup/setup.cy.spec.ts +++ b/e2e/test/scenarios/onboarding/setup/setup.cy.spec.ts @@ -502,10 +502,13 @@ describe("scenarios > setup > AI config step", () => { navigateToAiConfigStep(); cy.findByLabelText("Connect to an AI provider").within(() => { - cy.findByRole("button", { name: /OpenAI/ }).should("be.visible"); - cy.findByRole("button", { name: /OpenRouter/ }).should("be.visible"); - cy.findByRole("button", { name: /Microsoft Azure/ }).should("be.visible"); - cy.findByRole("button", { name: /Amazon Bedrock/ }).should("be.visible"); + [/OpenAI/, /OpenRouter/, /Microsoft Azure/, /Amazon Bedrock/].forEach( + (name) => { + cy.findByRole("button", { name }) + .scrollIntoView() + .should("be.visible"); + }, + ); // the managed provider is offered but not connectable without the LLM proxy, // which e2e does not configure cy.findByRole("button", { name: /Metabase/ }).should("be.disabled"); diff --git a/frontend/src/metabase-types/api/llm.ts b/frontend/src/metabase-types/api/llm.ts index eaa695ed002..9b4abde19dc 100644 --- a/frontend/src/metabase-types/api/llm.ts +++ b/frontend/src/metabase-types/api/llm.ts @@ -41,6 +41,7 @@ export type LlmProviderTypeName = | "zai" | "moonshot" | "deepseek" + | "xai" | "google" | "azure" | "bedrock" diff --git a/frontend/src/metabase-types/api/settings.ts b/frontend/src/metabase-types/api/settings.ts index aa02a7c45e3..5b7313ae65b 100644 --- a/frontend/src/metabase-types/api/settings.ts +++ b/frontend/src/metabase-types/api/settings.ts @@ -544,6 +544,7 @@ interface SettingsManagerSettings { "llm-mistral-api-key"?: string | null; "llm-moonshot-api-key"?: string | null; "llm-deepseek-api-key"?: string | null; + "llm-xai-api-key"?: string | null; "llm-azure-api-key"?: string | null; "llm-azure-api-base-url"?: string | null; "llm-google-service-account-key"?: string | null; @@ -792,6 +793,7 @@ export interface EnterpriseSettings extends Settings { "llm-mistral-api-key"?: string | null; "llm-moonshot-api-key"?: string | null; "llm-deepseek-api-key"?: string | null; + "llm-xai-api-key"?: string | null; "session-timeout": TimeoutValue | null; "search-engine": SearchEngineSettingValue | null; "scim-enabled"?: boolean | null; diff --git a/frontend/src/metabase/metabot/components/AIProviderConfigurationForm/ProviderTypeIcon.tsx b/frontend/src/metabase/metabot/components/AIProviderConfigurationForm/ProviderTypeIcon.tsx index 9850d1a44ca..050abb10058 100644 --- a/frontend/src/metabase/metabot/components/AIProviderConfigurationForm/ProviderTypeIcon.tsx +++ b/frontend/src/metabase/metabot/components/AIProviderConfigurationForm/ProviderTypeIcon.tsx @@ -14,6 +14,7 @@ import MistralMark from "./logos/mistral.svg?component"; import MoonshotMark from "./logos/moonshot.svg?component"; import OpenAiMark from "./logos/openai.svg?component"; import OpenRouterMark from "./logos/openrouter.svg?component"; +import XaiMark from "./logos/xai.svg?component"; import ZaiMark from "./logos/zai.svg?component"; const GENERIC_PROVIDER_ICON = "ai"; @@ -34,6 +35,7 @@ const PROVIDER_LOGOS: Record< zai: ZaiMark, moonshot: MoonshotMark, deepseek: DeepSeekMark, + xai: XaiMark, google: GoogleMark, azure: AzureMark, vllm: null, diff --git a/frontend/src/metabase/metabot/components/AIProviderConfigurationForm/ProviderTypeIcon.unit.spec.tsx b/frontend/src/metabase/metabot/components/AIProviderConfigurationForm/ProviderTypeIcon.unit.spec.tsx index f4559f93417..621cf5a688f 100644 --- a/frontend/src/metabase/metabot/components/AIProviderConfigurationForm/ProviderTypeIcon.unit.spec.tsx +++ b/frontend/src/metabase/metabot/components/AIProviderConfigurationForm/ProviderTypeIcon.unit.spec.tsx @@ -30,6 +30,7 @@ describe("ProviderTypeIcon", () => { "zai", "moonshot", "deepseek", + "xai", "google", "azure", "bedrock", diff --git a/frontend/src/metabase/metabot/components/AIProviderConfigurationForm/logos/xai.svg b/frontend/src/metabase/metabot/components/AIProviderConfigurationForm/logos/xai.svg new file mode 100644 index 00000000000..8f7264c0569 --- /dev/null +++ b/frontend/src/metabase/metabot/components/AIProviderConfigurationForm/logos/xai.svg @@ -0,0 +1 @@ + diff --git a/src/metabase/llm/provider.clj b/src/metabase/llm/provider.clj index 98049b4e972..0de8422cd8c 100644 --- a/src/metabase/llm/provider.clj +++ b/src/metabase/llm/provider.clj @@ -178,6 +178,24 @@ :advanced? true :default "https://api.deepseek.com" :help (deferred-tru "The root both surfaces hang off; leave off any /anthropic or /v1 path.")}]} + {:type "xai" + :label (deferred-tru "xAI") + :default-model "grok-4.7" + :mini-model "grok-4.3" + :fields [{:key :api-key + :label (deferred-tru "API key") + :type :password + :required? true + :placeholder "xai-..." + :prefix "xai-" + :docs-url "https://console.x.ai/team/default/api-keys"} + {:key :base-url + :normalize strip-trailing-slashes + :validate llm.provider.settings/llm-url-problem + :label (deferred-tru "API base URL") + :type :text + :advanced? true + :default "https://api.x.ai/v1"}]} {:type "google" ;; "Google Gemini Enterprise" (nearly the official "Gemini Enterprise Agent Platform" name), not "Google ;; Gemini": the Gemini API is a separate surface with its own credentials, and may become a provider type of @@ -612,6 +630,9 @@ "deepseek" {:type "deepseek" :settings {:api-key {:setting :llm-deepseek-api-key :credential? true} :base-url {:setting :llm-deepseek-api-base-url}}} + "xai" {:type "xai" + :settings {:api-key {:setting :llm-xai-api-key :credential? true} + :base-url {:setting :llm-xai-api-base-url}}} "google" {:type "google" :settings {:service-account-key {:setting :llm-google-service-account-key :credential? true} :oauth-access-token {:setting :llm-google-oauth-access-token :credential? true} diff --git a/src/metabase/llm/settings.clj b/src/metabase/llm/settings.clj index a32704aba20..92f1b02d02b 100644 --- a/src/metabase/llm/settings.clj +++ b/src/metabase/llm/settings.clj @@ -236,6 +236,27 @@ :setter (connection-field-setter :llm-deepseek-api-key) :doc "Backed by the deepseek connection in the admin AI settings provider list: reads and writes go through the llm-providers connection list, and a value set by this environment variable shadows this one field of that connection.") +;;; ---------------------------------------------------- xAI ---------------------------------------------------- + +(defsetting llm-xai-api-base-url + (deferred-tru "The xAI API base URL used for Chat Completions.") + :encryption :when-encryption-key-set + :visibility :settings-manager + :default "https://api.x.ai/v1" + :export? false + :getter (connection-field-getter :llm-xai-api-base-url) + :setter (connection-field-setter :llm-xai-api-base-url) + :doc "Backed by the xai connection in the admin AI settings provider list: reads and writes go through the llm-providers connection list, and a value set by this environment variable shadows this one field of that connection.") + +(defsetting llm-xai-api-key + (deferred-tru "The xAI API Key.") + :sensitive? true + :visibility :settings-manager + :export? false + :getter (connection-field-getter :llm-xai-api-key) + :setter (connection-field-setter :llm-xai-api-key) + :doc "Backed by the xai connection in the admin AI settings provider list: reads and writes go through the llm-providers connection list, and a value set by this environment variable shadows this one field of that connection.") + ;;; ------------------------------------ Google Gemini Enterprise Agent Platform -------------------------------- ;;; The Gemini Enterprise Agent Platform (formerly Vertex AI). Every request applies to one Google Cloud project. The ;;; project ID is necessary. The location is optional and defaults to `global`. diff --git a/src/metabase/metabot/self/registry.clj b/src/metabase/metabot/self/registry.clj index 08694d990e4..060af170803 100644 --- a/src/metabase/metabot/self/registry.clj +++ b/src/metabase/metabot/self/registry.clj @@ -17,6 +17,7 @@ [metabase.metabot.self.openai :as openai] [metabase.metabot.self.openrouter :as openrouter] [metabase.metabot.self.vllm :as vllm] + [metabase.metabot.self.xai :as xai] [metabase.metabot.self.zai :as zai] [metabase.util.malli :as mu])) @@ -119,6 +120,11 @@ "vllm" {:stream #'vllm/vllm :list-models #'vllm/list-models :reasoning? #'vllm/streams-reasoning?} + "xai" {:stream #'xai/xai + :list-models #'xai/list-models + :supported-models #'xai/supported-models + :context-window #'xai/context-window-tokens + :reasoning? #'xai/streams-reasoning?} "zai" {:stream #'zai/zai :list-models #'zai/list-models :supported-models #'zai/supported-models diff --git a/src/metabase/metabot/self/xai.clj b/src/metabase/metabot/self/xai.clj new file mode 100644 index 00000000000..c6233324f8d --- /dev/null +++ b/src/metabase/metabot/self/xai.clj @@ -0,0 +1,117 @@ +(ns metabase.metabot.self.xai + "xAI (Grok) / Chat Completions adapter. + + xAI exposes an OpenAI-compatible Chat Completions API. + + https://docs.x.ai/developers/rest-api-reference/inference/chat-completions" + (:require + [clojure.set :as set] + [metabase.metabot.self.adapter :as adapter] + [metabase.metabot.self.core :as core] + [metabase.metabot.self.openai.chat-completions :as chat-completions] + [metabase.util.i18n :refer [tru]] + [metabase.util.malli :as mu])) + +(set! *warn-on-reflection* true) + +(def ^:private default-model "grok-4.7") + +(def ^:private provider + (adapter/provider + {:slug "xai" + :display-name "xAI" + :error-fallback #(tru "xAI API error (HTTP {0})" %) + :errors {401 #(tru "xAI API key expired or invalid") + 403 #(tru "xAI API key lacks permission, or the team is out of credits") + 404 #(tru "xAI API endpoint or model was not found") + 429 #(tru "xAI has rate limited us") + 500 #(tru "xAI returned an internal server error")}})) + +(def supported-models + "xAI models offered in the Metabot model picker, keyed by model id. + `list-models` returns the intersection of this map with the `/models` catalog. + + `:lowest-effort` is the lowest `reasoning_effort` the model accepts, `none` where reasoning can be turned off." + {"grok-4.3" {:display-name "Grok 4.3" :context-window 1000000 :lowest-effort "none"} + "grok-4.7" {:display-name "Grok 4.7" :context-window 500000 :lowest-effort "low"}}) + +(mu/defn context-window-tokens :- [:maybe :int] + "The input context window for `model`, or nil when it isn't one we know." + [model :- [:maybe :string]] + (get-in supported-models [model :context-window])) + +(mu/defn streams-reasoning? :- :boolean + "Registry capability. Every xAI model we offer streams summaries of its reasoning as `delta.reasoning_content`." + [{:keys [model]} :- adapter/ResolvedRef] + (contains? supported-models model)) + +(mu/defn list-models :- adapter/ModelListing + "List the xAI models supported by this adapter (see [[supported-models]]). + + The `/models` catalog it intersects doubles as the credential round trip behind the admin Connect button. Its + entries carry no display name, so the names come from [[supported-models]]. + `:ai-proxy?` is not supported for xAI and throws when true." + ([] (list-models {})) + ([opts :- adapter/ListOpts] + (adapter/model-listing supported-models (adapter/fetch-catalog provider opts)))) + +(mu/defn xai-request-body + "Build the Chat Completions request body for an LLM request. + + xAI's dialect matches what [[chat-completions/request-body]] emits, except: + + - The output cap is sent as `max_completion_tokens`, since xAI deprecates `max_tokens`. It caps visible output + only, not reasoning or tool calls, so a small cap cannot cut off a forced tool call. + - The structured path, and a caller opting out of reasoning, get the model's `:lowest-effort` from + [[supported-models]], which turns reasoning off on Grok 4.3 and lowers it to `low` on Grok 4.7. Chat keeps each + model's default. Models off the allow-list get no effort at all, since some of them (Grok 4.20) reject the + parameter. + - A `:prompt-cache-key`, the conversation id, is forwarded as `prompt_cache_key`, which routes a conversation's + requests to the same server so its prompt cache hits." + [{:keys [model prompt-cache-key reasoning? schema] :as opts + :or {model default-model reasoning? true}} :- core/LLMRequestOpts] + (let [lowest-effort (get-in supported-models [model :lowest-effort])] + (-> (chat-completions/request-body (assoc opts :model model)) + (set/rename-keys {:max_tokens :max_completion_tokens}) + (cond-> (and lowest-effort (or (some? schema) (not reasoning?))) + (assoc :reasoning_effort lowest-effort) + + prompt-cache-key + (assoc :prompt_cache_key prompt-cache-key))))) + +(mu/defn xai-raw + "Perform a streaming request to the xAI Chat Completions API. + Opts map takes `:credentials` (`{:api-key ... :base-url ...}`) from the connection serving this request, and + throws when they are missing. + `:ai-proxy?` is not supported for xAI and throws when true." + [{:keys [model] :as opts + :or {model default-model}} :- core/LLMRequestOpts] + (let [opts (assoc opts :model model)] + (adapter/stream! provider opts + {:path "/chat/completions" + :body (xai-request-body opts)}))) + +(defn- count-reasoning-as-output + "Rewrite a chunk's `completion_tokens` as everything `total_tokens` holds beyond the prompt. + + xAI reports reasoning tokens next to `completion_tokens` rather than inside them, unlike OpenAI, and bills them + as output: https://docs.x.ai/developers/advanced-api-usage/prompt-caching/usage-and-pricing" + [{:keys [usage] :as chunk}] + (if-let [total (:total_tokens usage)] + (assoc-in chunk [:usage :completion_tokens] (- total (:prompt_tokens usage 0))) + chunk)) + +(defn xai->aisdk-chunks-xf + "Translates xAI Chat Completions streaming chunks into AI SDK v5 protocol chunks. + + Reasoning summaries arrive as `delta.reasoning_content` and are forwarded as reasoning chunks." + [] + (comp (map count-reasoning-as-output) + (chat-completions/chat-completions->aisdk-chunks-xf chat-completions/stop-reasons + {:forward-reasoning? true}))) + +(defn xai + "Call the xAI Chat Completions API, return AISDK stream." + [& args] + (let [raw (apply xai-raw args)] + (eduction (xai->aisdk-chunks-xf) raw))) diff --git a/test/metabase/llm/api/provider_test.clj b/test/metabase/llm/api/provider_test.clj index c8849d8aa34..f91df997288 100644 --- a/test/metabase/llm/api/provider_test.clj +++ b/test/metabase/llm/api/provider_test.clj @@ -58,11 +58,11 @@ (deftest provider-types-test (testing "every provider type is listed with the credential fields a connection needs" (let [types (mt/user-http-request :crowberto :get 200 "llm/provider-types")] - (is (= #{"anthropic" "openai" "openrouter" "mistral" "zai" "moonshot" "deepseek" "google" "azure" "bedrock" - "vllm" "metabase"} + (is (= #{"anthropic" "openai" "openrouter" "mistral" "zai" "moonshot" "deepseek" "xai" "google" "azure" + "bedrock" "vllm" "metabase"} (set (map :type types)))) - (is (= ["anthropic" "openai" "openrouter" "mistral" "zai" "moonshot" "deepseek" "google" "azure" "bedrock" - "vllm"] + (is (= ["anthropic" "openai" "openrouter" "mistral" "zai" "moonshot" "deepseek" "xai" "google" "azure" + "bedrock" "vllm"] (remove #{"metabase"} (map :type types))) "the bring-your-own-key providers keep their registry order") (is (=? {:type "anthropic" @@ -95,7 +95,8 @@ (testing "the API-key prefixes reach the client, which uses them to recognize a pasted key" (is (= {"anthropic" "sk-ant-" "openai" "sk-" - "openrouter" "sk-or-v1-"} + "openrouter" "sk-or-v1-" + "xai" "xai-"} (into {} (keep (fn [{:keys [type fields]}] (when-let [prefix (some :prefix fields)] diff --git a/test/metabase/llm/provider_test.clj b/test/metabase/llm/provider_test.clj index 59992df61de..c00498824b4 100644 --- a/test/metabase/llm/provider_test.clj +++ b/test/metabase/llm/provider_test.clj @@ -689,8 +689,8 @@ "so a type without a decided logo fails to compile. Nothing links the two, so adding a type here " "without updating them ships a provider that silently falls back to the generic icon. Update " "both, then this list.") - (is (= #{"anthropic" "openai" "openrouter" "mistral" "zai" "moonshot" "deepseek" "google" "azure" "bedrock" - "vllm" "metabase"} + (is (= #{"anthropic" "openai" "openrouter" "mistral" "zai" "moonshot" "deepseek" "xai" "google" "azure" + "bedrock" "vllm" "metabase"} (into #{} (map :type) (llm.provider/provider-types)))))) (deftest ^:parallel provider-types-test @@ -719,6 +719,7 @@ "zai" "glm-5.2" "moonshot" "kimi-k3" "deepseek" "deepseek-v4-pro" + "xai" "grok-4.7" "google" "google/gemini-3.5-flash" ;; azure's models are deployment names the admin chooses, so there is nothing to default to "azure" nil @@ -739,6 +740,7 @@ "zai" "glm-5.2" "moonshot" "kimi-k3" "deepseek" "deepseek-v4-flash" + "xai" "grok-4.3" "google" nil "azure" nil "bedrock" "anthropic.claude-haiku-4-5" diff --git a/test/metabase/llm/test_util.clj b/test/metabase/llm/test_util.clj index f93798598bd..8e4583d151d 100644 --- a/test/metabase/llm/test_util.clj +++ b/test/metabase/llm/test_util.clj @@ -15,6 +15,7 @@ "zai" {:api-key "zai-test-key"} "moonshot" {:api-key "sk-moonshot-test-key"} "deepseek" {:api-key "sk-deepseek-test-key"} + "xai" {:api-key "xai-test-key"} "google" {:oauth-access-token "ya29.test-token" :project-id "my-project"} "azure" {:api-key "azure-test-key" @@ -37,8 +38,8 @@ :config (merge (get dummy-configs type-name) config-overrides)})) (def default-connections - (mapv connection ["anthropic" "openai" "openrouter" "mistral" "zai" "moonshot" "deepseek" "google" "azure" - "bedrock" "metabase"])) + (mapv connection ["anthropic" "openai" "openrouter" "mistral" "zai" "moonshot" "deepseek" "xai" "google" + "azure" "bedrock" "metabase"])) (defn do-with-connections! [connections thunk] diff --git a/test/metabase/metabot/self/adapter_test.clj b/test/metabase/metabot/self/adapter_test.clj index 6d3b550ba62..621adee2922 100644 --- a/test/metabase/metabot/self/adapter_test.clj +++ b/test/metabase/metabot/self/adapter_test.clj @@ -19,6 +19,7 @@ [metabase.metabot.self.openrouter :as openrouter] [metabase.metabot.self.registry :as registry] [metabase.metabot.self.vllm :as vllm] + [metabase.metabot.self.xai :as xai] [metabase.metabot.self.zai :as zai] [metabase.premium-features.core :as premium-features] [metabase.test :as mt] @@ -46,6 +47,7 @@ #'openai/provider :metabot.openai/request #'openrouter/provider :metabot.openrouter/request #'vllm/provider :metabot.vllm/request + #'xai/provider :metabot.xai/request #'zai/provider :metabot.zai/request}) (deftest ^:parallel span-name-test @@ -71,6 +73,7 @@ #'openai/provider "OpenAI API error (HTTP 418)" #'openrouter/provider "OpenRouter API error (HTTP 418)" #'vllm/provider "vLLM API error (HTTP 418)" + #'xai/provider "xAI API error (HTTP 418)" #'zai/provider "Z.AI API error (HTTP 418)"}) (deftest ^:parallel error-message-test diff --git a/test/metabase/metabot/self/xai_test.clj b/test/metabase/metabot/self/xai_test.clj new file mode 100644 index 00000000000..aa6abd0365f --- /dev/null +++ b/test/metabase/metabot/self/xai_test.clj @@ -0,0 +1,134 @@ +(ns metabase.metabot.self.xai-test + (:require + [clj-http.client :as http] + [clojure.test :refer :all] + [metabase.metabot.self.core :as self.core] + [metabase.metabot.self.debug :as debug] + [metabase.metabot.self.xai :as xai] + [metabase.metabot.test-util :as metabot.tu] + [metabase.test :as mt] + [metabase.util.json :as json])) + +(set! *warn-on-reflection* true) + +(def ^:private credentials + {:api-key "xai-test-key" :base-url "https://api.x.ai/v1"}) + +(def ^:private input + [{:role :user :content "hi"}]) + +(deftest xai-raw-request-test + (testing "a chat request goes to the connection's Chat Completions endpoint with its key, on Grok 4.7 by default" + (with-redefs [self.core/sse-reducible identity + self.core/reducible-with-api-errors (fn [r _ _] r) + debug/capture-stream (fn [r _] r) + http/request (fn [req] {:body req})] + (let [req (xai/xai-raw {:input input :credentials credentials})] + (is (=? {:method :post + :url "https://api.x.ai/v1/chat/completions" + :headers {"Authorization" "Bearer xai-test-key"}} + req)) + (is (=? {:model "grok-4.7" :stream true :stream_options {:include_usage true}} + (json/decode+kw (:body req)))))))) + +(deftest ^:parallel request-body-test + (testing "the output cap is sent as max_completion_tokens, since xAI deprecates max_tokens" + (let [body (xai/xai-request-body {:input input :max-tokens 512})] + (is (= 512 (:max_completion_tokens body))) + (is (not (contains? body :max_tokens))))) + (testing "chat keeps each model's default reasoning effort" + (is (not (contains? (xai/xai-request-body {:input input :tools [(metabot.tu/get-time-tool)]}) + :reasoning_effort)))) + (testing "structured output, and a caller opting out of reasoning, drop to the model's lowest effort" + (doseq [[model effort] {"grok-4.3" "none" "grok-4.7" "low"} + opts [{:schema {:type "object"}} {:reasoning? false}]] + (testing (str model " " opts) + (is (= effort (:reasoning_effort (xai/xai-request-body (merge {:model model :input input} opts)))))))) + (testing "a model off the allow-list gets no effort, since Grok 4.20 rejects the parameter" + (is (not (contains? (xai/xai-request-body {:model "grok-4.20-0309-reasoning" + :input input + :schema {:type "object"}}) + :reasoning_effort)))) + (testing "the conversation id is forwarded as prompt_cache_key" + (is (= "conversation-1" + (:prompt_cache_key (xai/xai-request-body {:input input :prompt-cache-key "conversation-1"})))) + (is (not (contains? (xai/xai-request-body {:input input}) :prompt_cache_key))))) + +(defn- grok-chunk + [m] + (merge {:id "resp-1" :object "chat.completion.chunk" :model "grok-4.7"} m)) + +(def ^:private tool-call-stream + "A Grok turn that reasons and calls a tool, streamed the way api.x.ai streams one: reasoning summary deltas, the + whole tool call in one delta, the finish reason, then usage on a final chunk with no choices. Usage counts the + reasoning tokens next to `completion_tokens`: 291 + 26 + 196 = 513." + (mapv grok-chunk + [{:choices [{:index 0 :delta {:role "assistant" :reasoning_content "Checking the"}}]} + {:choices [{:index 0 :delta {:reasoning_content " time."}}]} + {:choices [{:index 0 :delta {:tool_calls [{:id "call_1" + :index 0 + :type "function" + :function {:name "get-time" + :arguments "{\"tz\":\"Europe/Bucharest\"}"}}]}}]} + {:choices [{:index 0 :delta {} :finish_reason "tool_calls"}]} + {:choices [] + :usage {:prompt_tokens 291 + :completion_tokens 26 + :total_tokens 513 + :prompt_tokens_details {:text_tokens 291 :cached_tokens 290} + :completion_tokens_details {:reasoning_tokens 196}}}])) + +(deftest ^:parallel tool-call-stream-test + (testing "reasoning, the tool call and usage come through, with reasoning tokens counted as output" + (is (=? [{:type :start} + {:type :reasoning :text "Checking the time."} + {:type :tool-input + :id "call_1" + :function "get-time" + :arguments {:tz "Europe/Bucharest"}} + {:type :usage + :model "grok-4.7" + :finish-reason "tool-calls" + :usage {:promptTokens 291 + :completionTokens 222 + :cacheReadTokens 290}}] + (into [] (comp (xai/xai->aisdk-chunks-xf) (self.core/aisdk-xf)) tool-call-stream))))) + +(deftest list-models-test + (testing "the picker offers the allow-listed models the key can reach, named from the allow-list" + (mt/with-dynamic-fn-redefs [http/request (fn [req] + (is (=? {:method :get + :url "https://api.x.ai/v1/models" + :headers {"Authorization" "Bearer xai-test-key"}} + req)) + {:status 200 + :body {:object "list" + :data [{:id "grok-4.7" :aliases []} + {:id "grok-4.3" :aliases ["grok-4.3-latest"]} + {:id "grok-imagine-image" :aliases []}]}})] + (is (= {:models [{:id "grok-4.3" :display_name "Grok 4.3"} + {:id "grok-4.7" :display_name "Grok 4.7"}]} + (xai/list-models {:credentials credentials})))))) + +(defn- list-models-error + "The message `list-models` fails with when xAI answers with `status` and `body`." + [status body] + (mt/with-dynamic-fn-redefs [http/request (fn [_] + (throw (ex-info (str "clj-http: status " status) + {:status status :body (json/encode body)})))] + (try + (xai/list-models {:credentials credentials}) + nil + (catch clojure.lang.ExceptionInfo e + (ex-message e))))) + +(deftest error-message-test + (testing "xAI answers an incorrect key with a 400, and its own explanation reaches the message" + (is (re-find #"^xAI API error \(HTTP 400\).*Incorrect API key provided: xa\*\*\*kA" + (list-models-error 400 {:code "Client specified an invalid argument" + :error (str "Incorrect API key provided: xa***kA. " + "You can obtain an API key from https://console.x.ai")})))) + (testing "a 403 names both of its causes and keeps the response body out of the message" + (is (= "xAI API key lacks permission, or the team is out of credits" + (list-models-error 403 {:error (str "Your team has either used all available credits or reached its " + "monthly spending limit.")}))))) diff --git a/test/metabase/metabot/self_test.clj b/test/metabase/metabot/self_test.clj index 6516e674d44..e778fa60ac0 100644 --- a/test/metabase/metabot/self_test.clj +++ b/test/metabase/metabot/self_test.clj @@ -21,6 +21,7 @@ [metabase.metabot.self.openai :as openai] [metabase.metabot.self.openrouter :as openrouter] [metabase.metabot.self.registry :as registry] + [metabase.metabot.self.xai :as xai] [metabase.metabot.self.zai :as zai] [metabase.metabot.settings :as metabot.settings] [metabase.metabot.test-util :as test-util] @@ -48,6 +49,7 @@ "moonshot" #'moonshot/supported-models "openai" #'openai/supported-models "openrouter" #'openrouter/supported-models + "xai" #'xai/supported-models "zai" #'zai/supported-models}) (deftest ^:parallel registry-models-are-listable-test @@ -82,6 +84,8 @@ (#'self/parse-provider-model "moonshot/kimi-k3"))) (is (=? {:provider "deepseek" :model "deepseek-v4-flash" :ai-proxy? false} (#'self/parse-provider-model "deepseek/deepseek-v4-flash"))) + (is (=? {:provider "xai" :model "grok-4.7" :ai-proxy? false} + (#'self/parse-provider-model "xai/grok-4.7"))) (is (=? {:provider "google" :model "google/gemini-3.5-flash" :ai-proxy? false} (#'self/parse-provider-model "google/google/gemini-3.5-flash")))) (testing "resolves the provider type, not the admin's name for the connection" @@ -2341,7 +2345,7 @@ #"Unrecognized supported-models entry" (#'self/normalize-known-model "anthropic" "some-model" {:context-window 200000})))) (testing "every provider that publishes an allow-list names every model in it" - (doseq [provider ["anthropic" "bedrock" "deepseek" "mistral" "moonshot" "openai" "openrouter" "zai"]] + (doseq [provider ["anthropic" "bedrock" "deepseek" "mistral" "moonshot" "openai" "openrouter" "xai" "zai"]] (let [models (self/known-models provider)] (is (seq models) provider) (is (every? (comp string? :display-name val) models) provider))))) diff --git a/test/metabase/metabot/settings_test.clj b/test/metabase/metabot/settings_test.clj index 8c69d2afb27..026520fdd25 100644 --- a/test/metabase/metabot/settings_test.clj +++ b/test/metabase/metabot/settings_test.clj @@ -200,7 +200,8 @@ (connection "zai" "zai") (connection "openrouter" "openrouter") (connection "mistral" "mistral") - (connection "moonshot" "moonshot")] + (connection "moonshot" "moonshot") + (connection "xai" "xai")] (doseq [[model-ref expected] {"anthropic/claude-sonnet-4-6" true "anthropic/claude-haiku-4-5" false @@ -239,7 +240,9 @@ "moonshot/kimi-k3" true "moonshot/kimi-k2.6" true ;; thinking-capable but excluded from supported-models — see moonshot/reasoning-model? - "moonshot/kimi-k2.7-code" false}] + "moonshot/kimi-k2.7-code" false + "xai/grok-4.7" true + "xai/grok-4.3" true}] (testing model-ref (with-selected-model model-ref (is (= expected (metabot.settings/llm-metabot-supports-reasoning?)))))))))