diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 0cd034c82..fee1ad4e3 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -13,6 +13,7 @@ import { SYNTHETIC_MODEL_CATALOG, } from "./synthetic-models.js"; import { discoverVeniceModels, VENICE_BASE_URL } from "./venice-models.js"; +import { discoverXaiModels, XAI_BASE_URL } from "./xai-models.js"; type ModelsConfig = NonNullable; export type ProviderConfig = NonNullable[string]; @@ -379,6 +380,15 @@ async function buildVeniceProvider(): Promise { }; } +async function buildXaiProvider(apiKey: string): Promise { + const models = await discoverXaiModels(apiKey); + return { + baseUrl: XAI_BASE_URL, + api: "openai-completions", + models, + }; +} + async function buildOllamaProvider(): Promise { const models = await discoverOllamaModels(); return { @@ -431,6 +441,13 @@ export async function resolveImplicitProviders(params: { providers.venice = { ...(await buildVeniceProvider()), apiKey: veniceKey }; } + const xaiKey = + resolveEnvApiKeyVarName("xai") ?? + resolveApiKeyFromProfiles({ provider: "xai", store: authStore }); + if (xaiKey) { + providers.xai = { ...(await buildXaiProvider(xaiKey)), apiKey: xaiKey }; + } + const qwenProfiles = listProfilesForProvider(authStore, "qwen-portal"); if (qwenProfiles.length > 0) { providers["qwen-portal"] = { diff --git a/src/agents/xai-models.test.ts b/src/agents/xai-models.test.ts new file mode 100644 index 000000000..acc966407 --- /dev/null +++ b/src/agents/xai-models.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; + +import { + buildXaiModelDefinition, + discoverXaiModels, + XAI_BASE_URL, + XAI_DEFAULT_COST, + XAI_DEFAULT_MODEL_ID, + XAI_MODEL_CATALOG, +} from "./xai-models.js"; + +describe("XAI_MODEL_CATALOG", () => { + it("contains expected model series", () => { + const ids = XAI_MODEL_CATALOG.map((m) => m.id); + + // Grok 4.1 series + expect(ids).toContain("grok-4-1-fast-reasoning"); + expect(ids).toContain("grok-4-1-fast-non-reasoning"); + + // Grok 4 series + expect(ids).toContain("grok-4-07-09"); + expect(ids).toContain("grok-4-fast-reasoning"); + expect(ids).toContain("grok-4-fast-non-reasoning"); + + // Grok 3 series + expect(ids).toContain("grok-3-beta"); + expect(ids).toContain("grok-3-mini-beta"); + + // Grok 2 series + expect(ids).toContain("grok-2-1212"); + expect(ids).toContain("grok-2-vision-1212"); + + // Specialized + expect(ids).toContain("grok-code-fast-1"); + }); + + it("marks reasoning models correctly", () => { + const reasoningModels = XAI_MODEL_CATALOG.filter((m) => m.reasoning); + const reasoningIds = reasoningModels.map((m) => m.id); + + expect(reasoningIds).toContain("grok-4-1-fast-reasoning"); + expect(reasoningIds).toContain("grok-4-fast-reasoning"); + expect(reasoningIds).toContain("grok-code-fast-1"); + + // Non-reasoning models should not be in this list + expect(reasoningIds).not.toContain("grok-4-1-fast-non-reasoning"); + expect(reasoningIds).not.toContain("grok-3-beta"); + }); + + it("marks vision models with image input", () => { + const visionModels = XAI_MODEL_CATALOG.filter((m) => m.input.includes("image")); + const visionIds = visionModels.map((m) => m.id); + + expect(visionIds).toContain("grok-4-1-fast-reasoning"); + expect(visionIds).toContain("grok-4-1-fast-non-reasoning"); + expect(visionIds).toContain("grok-2-vision-1212"); + + // Text-only models should not have image input + expect(visionIds).not.toContain("grok-3-beta"); + expect(visionIds).not.toContain("grok-4-07-09"); + }); +}); + +describe("buildXaiModelDefinition", () => { + it("builds a valid ModelDefinitionConfig from catalog entry", () => { + const entry = XAI_MODEL_CATALOG.find((m) => m.id === "grok-3-beta")!; + const config = buildXaiModelDefinition(entry); + + expect(config.id).toBe("grok-3-beta"); + expect(config.name).toBe("Grok 3"); + expect(config.reasoning).toBe(false); + expect(config.input).toEqual(["text"]); + expect(config.cost).toEqual(XAI_DEFAULT_COST); + expect(config.contextWindow).toBe(131072); + expect(config.maxTokens).toBe(8192); + }); + + it("preserves vision input for vision models", () => { + const entry = XAI_MODEL_CATALOG.find((m) => m.id === "grok-2-vision-1212")!; + const config = buildXaiModelDefinition(entry); + + expect(config.input).toEqual(["text", "image"]); + }); + + it("preserves reasoning flag for reasoning models", () => { + const entry = XAI_MODEL_CATALOG.find((m) => m.id === "grok-4-fast-reasoning")!; + const config = buildXaiModelDefinition(entry); + + expect(config.reasoning).toBe(true); + }); +}); + +describe("discoverXaiModels", () => { + it("returns static catalog in test environment", async () => { + // The function checks for test environment and returns static catalog + const models = await discoverXaiModels("test-api-key"); + + expect(models.length).toBe(XAI_MODEL_CATALOG.length); + expect(models.map((m) => m.id)).toContain("grok-3-beta"); + expect(models.map((m) => m.id)).toContain("grok-4-1-fast-reasoning"); + }); + + it("returns valid ModelDefinitionConfig objects", async () => { + const models = await discoverXaiModels("test-api-key"); + + for (const model of models) { + expect(model.id).toBeDefined(); + expect(model.name).toBeDefined(); + expect(typeof model.reasoning).toBe("boolean"); + expect(Array.isArray(model.input)).toBe(true); + expect(model.cost).toBeDefined(); + expect(typeof model.contextWindow).toBe("number"); + expect(typeof model.maxTokens).toBe("number"); + } + }); +}); + +describe("XAI constants", () => { + it("has correct base URL", () => { + expect(XAI_BASE_URL).toBe("https://api.x.ai/v1"); + }); + + it("has correct default model ID", () => { + expect(XAI_DEFAULT_MODEL_ID).toBe("grok-3-beta"); + }); + + it("has zero costs (credit-based)", () => { + expect(XAI_DEFAULT_COST).toEqual({ + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }); + }); +}); diff --git a/src/agents/xai-models.ts b/src/agents/xai-models.ts new file mode 100644 index 000000000..df3dba9fe --- /dev/null +++ b/src/agents/xai-models.ts @@ -0,0 +1,213 @@ +import type { ModelDefinitionConfig } from "../config/types.js"; + +export const XAI_BASE_URL = "https://api.x.ai/v1"; +export const XAI_DEFAULT_MODEL_ID = "grok-3-beta"; +export const XAI_DEFAULT_MODEL_REF = `xai/${XAI_DEFAULT_MODEL_ID}`; + +// xAI pricing varies by model; set to 0 as placeholder +export const XAI_DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +/** + * Complete catalog of xAI (Grok) models. + * + * This catalog serves as a fallback when the xAI API is unreachable + * or when no API key is configured. xAI's /v1/models endpoint requires + * authentication, unlike some other providers. + * + * Models organized by series, newest first. + */ +export const XAI_MODEL_CATALOG = [ + // Grok 4.1 Series (latest) + { + id: "grok-4-1-fast-reasoning", + name: "Grok 4.1 Fast Reasoning", + reasoning: true, + input: ["text", "image"] as const, + contextWindow: 2000000, + maxTokens: 16384, + }, + { + id: "grok-4-1-fast-non-reasoning", + name: "Grok 4.1 Fast", + reasoning: false, + input: ["text", "image"] as const, + contextWindow: 2000000, + maxTokens: 16384, + }, + + // Grok 4 Series + { + id: "grok-4-07-09", + name: "Grok 4", + reasoning: false, + input: ["text"] as const, + contextWindow: 256000, + maxTokens: 16384, + }, + { + id: "grok-4-fast-reasoning", + name: "Grok 4 Fast Reasoning", + reasoning: true, + input: ["text"] as const, + contextWindow: 2000000, + maxTokens: 16384, + }, + { + id: "grok-4-fast-non-reasoning", + name: "Grok 4 Fast", + reasoning: false, + input: ["text"] as const, + contextWindow: 2000000, + maxTokens: 16384, + }, + + // Grok 3 Series + { + id: "grok-3-beta", + name: "Grok 3", + reasoning: false, + input: ["text"] as const, + contextWindow: 131072, + maxTokens: 8192, + }, + { + id: "grok-3-mini-beta", + name: "Grok 3 Mini", + reasoning: false, + input: ["text"] as const, + contextWindow: 131072, + maxTokens: 8192, + }, + + // Grok 2 Series + { + id: "grok-2-1212", + name: "Grok 2", + reasoning: false, + input: ["text"] as const, + contextWindow: 32768, + maxTokens: 8192, + }, + { + id: "grok-2-vision-1212", + name: "Grok 2 Vision", + reasoning: false, + input: ["text", "image"] as const, + contextWindow: 32768, + maxTokens: 8192, + }, + + // Specialized + { + id: "grok-code-fast-1", + name: "Grok Code Fast 1", + reasoning: true, + input: ["text"] as const, + contextWindow: 256000, + maxTokens: 16384, + }, +] as const; + +export type XaiCatalogEntry = (typeof XAI_MODEL_CATALOG)[number]; + +/** + * Build a ModelDefinitionConfig from an xAI catalog entry. + */ +export function buildXaiModelDefinition(entry: XaiCatalogEntry): ModelDefinitionConfig { + return { + id: entry.id, + name: entry.name, + reasoning: entry.reasoning, + input: [...entry.input], + cost: XAI_DEFAULT_COST, + contextWindow: entry.contextWindow, + maxTokens: entry.maxTokens, + }; +} + +// xAI API response types (OpenAI-compatible format) +interface XaiModel { + id: string; + object: "model"; + created: number; + owned_by: string; +} + +interface XaiModelsResponse { + object: "list"; + data: XaiModel[]; +} + +/** + * Discover models from xAI API with fallback to static catalog. + * + * Unlike Venice, xAI's /v1/models endpoint requires authentication, + * so discovery only works when an API key is provided. + */ +export async function discoverXaiModels(apiKey: string): Promise { + // Skip API discovery in test environment + if (process.env.NODE_ENV === "test" || process.env.VITEST) { + return XAI_MODEL_CATALOG.map(buildXaiModelDefinition); + } + + try { + const response = await fetch(`${XAI_BASE_URL}/models`, { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + signal: AbortSignal.timeout(5000), + }); + + if (!response.ok) { + console.warn( + `[xai-models] Failed to discover models: HTTP ${response.status}, using static catalog`, + ); + return XAI_MODEL_CATALOG.map(buildXaiModelDefinition); + } + + const data = (await response.json()) as XaiModelsResponse; + if (!Array.isArray(data.data) || data.data.length === 0) { + console.warn("[xai-models] No models found from API, using static catalog"); + return XAI_MODEL_CATALOG.map(buildXaiModelDefinition); + } + + // Merge discovered models with catalog metadata + const catalogById = new Map(XAI_MODEL_CATALOG.map((m) => [m.id, m])); + const models: ModelDefinitionConfig[] = []; + + for (const apiModel of data.data) { + const catalogEntry = catalogById.get(apiModel.id); + if (catalogEntry) { + // Use catalog metadata for known models + models.push(buildXaiModelDefinition(catalogEntry)); + } else { + // Create definition for newly discovered models not in catalog + const idLower = apiModel.id.toLowerCase(); + const isReasoning = + idLower.includes("reasoning") || idLower.includes("think") || idLower.includes("r1"); + + const hasVision = idLower.includes("vision"); + + models.push({ + id: apiModel.id, + name: apiModel.id, // xAI API only returns id, not display names + reasoning: isReasoning, + input: hasVision ? ["text", "image"] : ["text"], + cost: XAI_DEFAULT_COST, + contextWindow: 131072, // Conservative default + maxTokens: 8192, + }); + } + } + + return models.length > 0 ? models : XAI_MODEL_CATALOG.map(buildXaiModelDefinition); + } catch (error) { + console.warn(`[xai-models] Discovery failed: ${String(error)}, using static catalog`); + return XAI_MODEL_CATALOG.map(buildXaiModelDefinition); + } +}