This commit is contained in:
Dan Guido 2026-01-30 11:25:01 -05:00 committed by GitHub
commit fc4b54a258
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 365 additions and 0 deletions

View File

@ -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<OpenClawConfig["models"]>;
export type ProviderConfig = NonNullable<ModelsConfig["providers"]>[string];
@ -379,6 +380,15 @@ async function buildVeniceProvider(): Promise<ProviderConfig> {
};
}
async function buildXaiProvider(apiKey: string): Promise<ProviderConfig> {
const models = await discoverXaiModels(apiKey);
return {
baseUrl: XAI_BASE_URL,
api: "openai-completions",
models,
};
}
async function buildOllamaProvider(): Promise<ProviderConfig> {
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"] = {

View File

@ -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,
});
});
});

213
src/agents/xai-models.ts Normal file
View File

@ -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<ModelDefinitionConfig[]> {
// 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<string, XaiCatalogEntry>(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);
}
}