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
This commit is contained in:
committed by
GitHub
parent
dff4d17f72
commit
18b6929ad7
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -41,6 +41,7 @@ export type LlmProviderTypeName =
|
||||
| "zai"
|
||||
| "moonshot"
|
||||
| "deepseek"
|
||||
| "xai"
|
||||
| "google"
|
||||
| "azure"
|
||||
| "bedrock"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -30,6 +30,7 @@ describe("ProviderTypeIcon", () => {
|
||||
"zai",
|
||||
"moonshot",
|
||||
"deepseek",
|
||||
"xai",
|
||||
"google",
|
||||
"azure",
|
||||
"bedrock",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg width="24" height="24" xmlns="http://www.w3.org/2000/svg" fill="currentColor" fill-rule="evenodd" viewBox="0 0 24 24"><path d="M6.469 8.776L16.512 23h-4.464L2.005 8.776H6.47zm-.004 7.9l2.233 3.164L6.467 23H2l4.465-6.324zM22 2.582V23h-3.659V7.764L22 2.582zM22 1l-9.952 14.095-2.233-3.163L17.533 1H22z"/></svg>
|
||||
|
After Width: | Height: | Size: 314 B |
@@ -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}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
117
src/metabase/metabot/self/xai.clj
Normal file
117
src/metabase/metabot/self/xai.clj
Normal file
@@ -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)))
|
||||
@@ -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)]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
134
test/metabase/metabot/self/xai_test.clj
Normal file
134
test/metabase/metabot/self/xai_test.clj
Normal file
@@ -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.")})))))
|
||||
@@ -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)))))
|
||||
|
||||
@@ -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?)))))))))
|
||||
|
||||
Reference in New Issue
Block a user