feat(xai): add dynamic model discovery with correct pricing

- Implemented dynamic model discovery for xAI provider using /v1/language-models endpoint
- Models are now fetched at runtime with real-time pricing and capabilities
- Fixed pricing conversion: XAI API returns fractional cents (basis points), divide by 10,000 not 100
- This fixes pricing display showing $20.00 instead of $0.20 per 1M tokens
- Removed static xAI models from MODELS_BY_PROVIDER to rely on dynamic discovery
- Enhanced error logging with detailed status and URL information
- Support dynamic model context window overrides from API
- Fixed parseApiPrice to handle zero values correctly (for free models)
- Provided complete ModelInfo fallback in useSelectedModel for UI type safety
- Added comprehensive test coverage for cost utilities and XAI fetcher
- Updated all tests to reflect correct pricing scale and dynamic model behavior
This commit is contained in:
Hannes Rudolph 2025-10-30 08:37:25 -06:00
parent 8e4b145681
commit a981925065
19 changed files with 619 additions and 91 deletions

View file

@ -21,7 +21,6 @@ import {
sambaNovaModels,
vertexModels,
vscodeLlmModels,
xaiModels,
internationalZAiModels,
minimaxModels,
} from "./providers/index.js"
@ -50,6 +49,7 @@ export const dynamicProviders = [
"glama",
"roo",
"chutes",
"xai",
] as const
export type DynamicProvider = (typeof dynamicProviders)[number]
@ -137,7 +137,6 @@ export const providerNames = [
"roo",
"sambanova",
"vertex",
"xai",
"zai",
] as const
@ -354,6 +353,7 @@ const fakeAiSchema = baseProviderSettingsSchema.extend({
const xaiSchema = apiModelIdProviderModelSchema.extend({
xaiApiKey: z.string().optional(),
xaiModelContextWindow: z.number().optional(),
})
const groqSchema = apiModelIdProviderModelSchema.extend({
@ -709,7 +709,7 @@ export const MODELS_BY_PROVIDER: Record<
label: "VS Code LM API",
models: Object.keys(vscodeLlmModels),
},
xai: { id: "xai", label: "xAI (Grok)", models: Object.keys(xaiModels) },
xai: { id: "xai", label: "xAI (Grok)", models: [] },
zai: { id: "zai", label: "Zai", models: Object.keys(internationalZAiModels) },
// Dynamic providers; models pulled from remote APIs.

View file

@ -3,93 +3,63 @@ import type { ModelInfo } from "../model.js"
// https://docs.x.ai/docs/api-reference
export type XAIModelId = keyof typeof xaiModels
export const xaiDefaultModelId: XAIModelId = "grok-code-fast-1"
export const xaiDefaultModelId: XAIModelId = "grok-4-fast-reasoning"
/**
* Partial ModelInfo for xAI static registry.
* Contains only fields not available from the xAI API:
* - contextWindow: Not provided by API
* - maxTokens: Not provided by API
* - description: User-friendly descriptions
* - supportsReasoningEffort: Special capability flag
*
* All other fields (pricing, supportsPromptCache, supportsImages) are fetched dynamically.
*/
type XAIStaticModelInfo = Pick<ModelInfo, "contextWindow" | "description"> & {
maxTokens?: number | null
supportsReasoningEffort?: boolean
}
export const xaiModels = {
"grok-code-fast-1": {
maxTokens: 16_384,
contextWindow: 262_144,
supportsImages: false,
supportsPromptCache: true,
inputPrice: 0.2,
outputPrice: 1.5,
cacheWritesPrice: 0.02,
cacheReadsPrice: 0.02,
contextWindow: 256_000,
description: "xAI's Grok Code Fast model with 256K context window",
},
"grok-4": {
maxTokens: 8192,
contextWindow: 256000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 0.75,
cacheReadsPrice: 0.75,
"grok-4-0709": {
maxTokens: 16_384,
contextWindow: 256_000,
description: "xAI's Grok-4 model with 256K context window",
},
"grok-4-fast-non-reasoning": {
maxTokens: 32_768,
contextWindow: 2_000_000,
description: "xAI's Grok-4 Fast Non-Reasoning model with 2M context window",
},
"grok-4-fast-reasoning": {
maxTokens: 32_768,
contextWindow: 2_000_000,
description: "xAI's Grok-4 Fast Reasoning model with 2M context window",
},
"grok-3": {
maxTokens: 8192,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: true,
inputPrice: 3.0,
outputPrice: 15.0,
cacheWritesPrice: 0.75,
cacheReadsPrice: 0.75,
contextWindow: 131_072,
description: "xAI's Grok-3 model with 128K context window",
},
"grok-3-fast": {
maxTokens: 8192,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: true,
inputPrice: 5.0,
outputPrice: 25.0,
cacheWritesPrice: 1.25,
cacheReadsPrice: 1.25,
description: "xAI's Grok-3 fast model with 128K context window",
},
"grok-3-mini": {
maxTokens: 8192,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: true,
inputPrice: 0.3,
outputPrice: 0.5,
cacheWritesPrice: 0.07,
cacheReadsPrice: 0.07,
contextWindow: 131_072,
description: "xAI's Grok-3 mini model with 128K context window",
supportsReasoningEffort: true,
},
"grok-3-mini-fast": {
maxTokens: 8192,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: true,
inputPrice: 0.6,
outputPrice: 4.0,
cacheWritesPrice: 0.15,
cacheReadsPrice: 0.15,
description: "xAI's Grok-3 mini fast model with 128K context window",
supportsReasoningEffort: true,
},
"grok-2-1212": {
maxTokens: 8192,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 2.0,
outputPrice: 10.0,
description: "xAI's Grok-2 model (version 1212) with 128K context window",
contextWindow: 32_768,
description: "xAI's Grok-2 model (version 1212) with 32K context window",
},
"grok-2-vision-1212": {
maxTokens: 8192,
contextWindow: 32768,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 2.0,
outputPrice: 10.0,
contextWindow: 32_768,
description: "xAI's Grok-2 Vision model (version 1212) with image support and 32K context window",
},
} as const satisfies Record<string, ModelInfo>
} as const satisfies Record<string, XAIStaticModelInfo>

View file

@ -57,7 +57,12 @@ describe("XAIHandler", () => {
it("should return default model when no model is specified", () => {
const model = handler.getModel()
expect(model.id).toBe(xaiDefaultModelId)
expect(model.info).toEqual(xaiModels[xaiDefaultModelId])
expect(model.info).toMatchObject({
contextWindow: xaiModels[xaiDefaultModelId].contextWindow,
maxTokens: xaiModels[xaiDefaultModelId].maxTokens,
description: xaiModels[xaiDefaultModelId].description,
})
expect(model.info.supportsPromptCache).toBe(false) // Placeholder until dynamic data loads
})
test("should return specified model when valid model is provided", () => {
@ -66,7 +71,12 @@ describe("XAIHandler", () => {
const model = handlerWithModel.getModel()
expect(model.id).toBe(testModelId)
expect(model.info).toEqual(xaiModels[testModelId])
expect(model.info).toMatchObject({
contextWindow: xaiModels[testModelId].contextWindow,
maxTokens: xaiModels[testModelId].maxTokens,
description: xaiModels[testModelId].description,
})
expect(model.info.supportsPromptCache).toBe(false) // Placeholder until dynamic data loads
})
it("should include reasoning_effort parameter for mini models", async () => {
@ -234,12 +244,13 @@ describe("XAIHandler", () => {
// Verify the usage data
expect(firstChunk.done).toBe(false)
expect(firstChunk.value).toEqual({
expect(firstChunk.value).toMatchObject({
type: "usage",
inputTokens: 10,
outputTokens: 20,
cacheReadTokens: 5,
cacheWriteTokens: 15,
totalCost: expect.any(Number),
})
})

View file

@ -0,0 +1,56 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import axios from "axios"
vi.mock("axios")
import { getXaiModels } from "../xai"
import { xaiModels } from "@roo-code/types"
describe("getXaiModels", () => {
const mockedAxios = axios as unknown as { get: ReturnType<typeof vi.fn> }
beforeEach(() => {
vi.clearAllMocks()
})
it("returns mapped models with pricing and modalities (augmenting static info when available)", async () => {
mockedAxios.get = vi.fn().mockResolvedValue({
data: {
models: [
{
id: "grok-3",
input_modalities: ["text"],
output_modalities: ["text"],
prompt_text_token_price: 2000, // 2000 fractional cents = $0.20 per 1M tokens
cached_prompt_text_token_price: 500, // 500 fractional cents = $0.05 per 1M tokens
completion_text_token_price: 10000, // 10000 fractional cents = $1.00 per 1M tokens
aliases: ["grok-3-latest"],
},
],
},
})
const result = await getXaiModels("key", "https://api.x.ai/v1")
expect(result["grok-3"]).toBeDefined()
expect(result["grok-3"]?.supportsImages).toBe(false)
expect(result["grok-3"]?.inputPrice).toBeCloseTo(0.2) // $0.20 per 1M tokens
expect(result["grok-3"]?.outputPrice).toBeCloseTo(1.0) // $1.00 per 1M tokens
expect(result["grok-3"]?.cacheReadsPrice).toBeCloseTo(0.05) // $0.05 per 1M tokens
// aliases are not added to avoid UI duplication
expect(result["grok-3-latest"]).toBeUndefined()
})
it("returns empty object on schema mismatches (graceful degradation)", async () => {
mockedAxios.get = vi.fn().mockResolvedValue({
data: { data: [{ bogus: true }] },
})
const result = await getXaiModels("key")
expect(result).toEqual({})
})
it("includes Authorization header when apiKey provided", async () => {
mockedAxios.get = vi.fn().mockResolvedValue({ data: { data: [] } })
await getXaiModels("secret")
expect((axios.get as any).mock.calls[0][1].headers.Authorization).toBe("Bearer secret")
})
})

View file

@ -26,6 +26,7 @@ import { getDeepInfraModels } from "./deepinfra"
import { getHuggingFaceModels } from "./huggingface"
import { getRooModels } from "./roo"
import { getChutesModels } from "./chutes"
import { getXaiModels } from "./xai"
const memoryCache = new NodeCache({ stdTTL: 5 * 60, checkperiod: 5 * 60 })
@ -101,6 +102,9 @@ export const getModels = async (options: GetModelsOptions): Promise<ModelRecord>
case "huggingface":
models = await getHuggingFaceModels()
break
case "xai":
models = await getXaiModels(options.apiKey, options.baseUrl)
break
case "roo": {
// Roo Code Cloud provider requires baseUrl and optional apiKey
const rooBaseUrl =
@ -121,7 +125,7 @@ export const getModels = async (options: GetModelsOptions): Promise<ModelRecord>
// Cache the fetched models (even if empty, to signify a successful fetch with no models).
memoryCache.set(provider, models)
await writeModels(provider, models).catch((err) =>
await writeModels(provider, models || {}).catch((err) =>
console.error(`[getModels] Error writing ${provider} models to file cache:`, err),
)

View file

@ -0,0 +1,107 @@
import axios from "axios"
import { z } from "zod"
import { type ModelInfo, xaiModels } from "@roo-code/types"
import { DEFAULT_HEADERS } from "../../providers/constants"
/**
* Schema for GET https://api.x.ai/v1/language-models
* This endpoint returns rich metadata including modalities and pricing.
*/
const xaiLanguageModelSchema = z.object({
id: z.string(),
input_modalities: z.array(z.string()).optional(),
output_modalities: z.array(z.string()).optional(),
prompt_text_token_price: z.number().optional(), // fractional cents (basis points) per 1M tokens
cached_prompt_text_token_price: z.number().optional(), // fractional cents per 1M tokens
prompt_image_token_price: z.number().optional(), // fractional cents per 1M tokens
completion_text_token_price: z.number().optional(), // fractional cents per 1M tokens
search_price: z.number().optional(),
aliases: z.array(z.string()).optional(),
})
const xaiLanguageModelsResponseSchema = z.object({
models: z.array(xaiLanguageModelSchema),
})
/**
* Fetch available xAI models for the authenticated account.
* - Uses Bearer Authorization header when apiKey is provided
* - Maps discovered IDs to ModelInfo using static catalog (xaiModels) when possible
* - For models not in static catalog, contextWindow and maxTokens remain undefined
*/
export async function getXaiModels(apiKey?: string, baseUrl?: string): Promise<Record<string, ModelInfo>> {
const models: Record<string, ModelInfo> = {}
// Build proper endpoint whether user passes https://api.x.ai or https://api.x.ai/v1
const base = baseUrl ? baseUrl.replace(/\/+$/, "") : "https://api.x.ai"
const url = base.endsWith("/v1") ? `${base}/language-models` : `${base}/v1/language-models`
try {
const resp = await axios.get(url, {
headers: {
...DEFAULT_HEADERS,
Accept: "application/json",
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
},
})
const parsed = xaiLanguageModelsResponseSchema.safeParse(resp.data)
const items = parsed.success
? parsed.data.models
: Array.isArray((resp.data as any)?.models)
? (resp.data as any)?.models
: []
if (!parsed.success) {
console.error("xAI language models response validation failed", parsed.error?.format?.() ?? parsed.error)
}
// Helper to convert fractional-cents-per-1M (basis points) to dollars-per-1M
// The API returns values in 1/100th of a cent, so divide by 10,000 to get dollars
const centsToDollars = (v?: number) => (typeof v === "number" ? v / 10_000 : undefined)
for (const m of items) {
const id = m.id
const staticInfo = xaiModels[id as keyof typeof xaiModels]
const supportsImages = Array.isArray(m.input_modalities) ? m.input_modalities.includes("image") : false
// Cache support is indicated by presence of cached_prompt_text_token_price field (even if 0)
const supportsPromptCache = typeof m.cached_prompt_text_token_price === "number"
const cacheReadsPrice = supportsPromptCache ? centsToDollars(m.cached_prompt_text_token_price) : undefined
const info: ModelInfo = {
maxTokens: staticInfo?.maxTokens ?? undefined,
contextWindow: staticInfo?.contextWindow ?? undefined,
supportsImages,
supportsPromptCache,
inputPrice: centsToDollars(m.prompt_text_token_price),
outputPrice: centsToDollars(m.completion_text_token_price),
cacheReadsPrice,
cacheWritesPrice: cacheReadsPrice, // xAI uses same price for reads and writes
description: staticInfo?.description,
supportsReasoningEffort:
staticInfo && "supportsReasoningEffort" in staticInfo
? staticInfo.supportsReasoningEffort
: undefined,
// leave other optional fields undefined unless available via static definitions
}
models[id] = info
// Aliases are not added to the model list to avoid duplication in UI
// Users should use the primary model ID; xAI API will handle alias resolution
}
} catch (error) {
// Avoid logging sensitive data like Authorization headers
if (axios.isAxiosError(error)) {
const status = error.response?.status
const statusText = error.response?.statusText
const url = (error as any)?.config?.url
console.error(`[xAI] models fetch failed: ${status ?? "unknown"} ${statusText ?? ""} ${url ?? ""}`.trim())
} else {
console.error("[xAI] models fetch failed.", error instanceof Error ? error.message : String(error))
}
throw error
}
return models
}

View file

@ -1,7 +1,7 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { type XAIModelId, xaiDefaultModelId, xaiModels } from "@roo-code/types"
import { type XAIModelId, xaiDefaultModelId, xaiModels, type ModelInfo } from "@roo-code/types"
import type { ApiHandlerOptions } from "../../shared/api"
@ -13,6 +13,9 @@ import { DEFAULT_HEADERS } from "./constants"
import { BaseProvider } from "./base-provider"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
import { handleOpenAIError } from "./utils/openai-error-handler"
import { calculateApiCostOpenAI } from "../../shared/cost"
import type { ModelRecord } from "../../shared/api"
import { getModels } from "./fetchers/modelCache"
const XAI_DEFAULT_TEMPERATURE = 0
@ -20,6 +23,7 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler
protected options: ApiHandlerOptions
private client: OpenAI
private readonly providerName = "xAI"
protected models: ModelRecord = {}
constructor(options: ApiHandlerOptions) {
super()
@ -35,21 +39,49 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler
}
override getModel() {
const id =
this.options.apiModelId && this.options.apiModelId in xaiModels
? (this.options.apiModelId as XAIModelId)
: xaiDefaultModelId
// Allow any model ID (dynamic discovery) and augment with static info when available
const id = this.options.apiModelId ?? xaiDefaultModelId
const staticInfo = (xaiModels as Record<string, any>)[id as any]
const dynamicInfo = this.models?.[id as any]
// Build complete ModelInfo using dynamic pricing/capabilities when available
const info: ModelInfo = {
contextWindow: this.options.xaiModelContextWindow ?? staticInfo?.contextWindow,
maxTokens: staticInfo?.maxTokens ?? undefined,
supportsPromptCache: dynamicInfo?.supportsPromptCache ?? false,
supportsImages: dynamicInfo?.supportsImages,
inputPrice: dynamicInfo?.inputPrice,
outputPrice: dynamicInfo?.outputPrice,
cacheReadsPrice: dynamicInfo?.cacheReadsPrice,
cacheWritesPrice: dynamicInfo?.cacheWritesPrice,
description: staticInfo?.description,
supportsReasoningEffort:
staticInfo && "supportsReasoningEffort" in staticInfo ? staticInfo.supportsReasoningEffort : undefined,
}
const info = xaiModels[id]
const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options })
return { id, info, ...params }
}
private async loadDynamicModels(): Promise<void> {
try {
this.models = await getModels({
provider: "xai",
apiKey: this.options.xaiApiKey,
baseUrl: (this.client as any).baseURL || "https://api.x.ai/v1",
})
} catch (error) {
console.error("[XAI] Error loading dynamic models:", error)
}
}
override async *createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
metadata?: ApiHandlerCreateMessageMetadata,
): ApiStream {
await this.loadDynamicModels()
const { id: modelId, info: modelInfo, reasoning } = this.getModel()
// Use the OpenAI-compatible API.
@ -98,12 +130,21 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler
const writeTokens =
"cache_creation_input_tokens" in chunk.usage ? (chunk.usage as any).cache_creation_input_tokens : 0
const totalCost = calculateApiCostOpenAI(
modelInfo,
chunk.usage.prompt_tokens || 0,
chunk.usage.completion_tokens || 0,
writeTokens || 0,
readTokens || 0,
)
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
cacheReadTokens: readTokens,
cacheWriteTokens: writeTokens,
totalCost,
}
}
}

View file

@ -2720,6 +2720,7 @@ describe("ClineProvider - Router Models", () => {
"vercel-ai-gateway": mockModels,
huggingface: {},
"io-intelligence": {},
xai: {},
},
values: undefined,
})
@ -2776,6 +2777,7 @@ describe("ClineProvider - Router Models", () => {
"vercel-ai-gateway": mockModels,
huggingface: {},
"io-intelligence": {},
xai: {},
},
values: undefined,
})
@ -2900,6 +2902,7 @@ describe("ClineProvider - Router Models", () => {
"vercel-ai-gateway": mockModels,
huggingface: {},
"io-intelligence": {},
xai: {},
},
values: undefined,
})

View file

@ -255,6 +255,7 @@ describe("webviewMessageHandler - requestRouterModels", () => {
"vercel-ai-gateway": mockModels,
huggingface: {},
"io-intelligence": {},
xai: {},
},
values: undefined,
})
@ -349,6 +350,7 @@ describe("webviewMessageHandler - requestRouterModels", () => {
"vercel-ai-gateway": mockModels,
huggingface: {},
"io-intelligence": {},
xai: {},
},
values: undefined,
})
@ -380,7 +382,7 @@ describe("webviewMessageHandler - requestRouterModels", () => {
type: "requestRouterModels",
})
// Verify error messages were sent for failed providers (these come first)
// Verify error messages were sent for failed providers
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
type: "singleRouterModelFetchResponse",
success: false,
@ -426,6 +428,7 @@ describe("webviewMessageHandler - requestRouterModels", () => {
"vercel-ai-gateway": mockModels,
huggingface: {},
"io-intelligence": {},
xai: {},
},
values: undefined,
})

View file

@ -777,6 +777,7 @@ export const webviewMessageHandler = async (
lmstudio: {},
roo: {},
chutes: {},
xai: {},
}
const safeGetModels = async (options: GetModelsOptions): Promise<ModelRecord> => {
@ -838,6 +839,14 @@ export const webviewMessageHandler = async (
})
}
// Add xAI if API key is provided.
if (apiConfiguration.xaiApiKey) {
candidates.push({
key: "xai",
options: { provider: "xai", apiKey: apiConfiguration.xaiApiKey, baseUrl: "https://api.x.ai/v1" },
})
}
// LiteLLM is conditional on baseUrl+apiKey
const litellmApiKey = apiConfiguration.litellmApiKey || message?.values?.litellmApiKey
const litellmBaseUrl = apiConfiguration.litellmBaseUrl || message?.values?.litellmBaseUrl

View file

@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest"
import { parseApiPrice, calculateApiCostAnthropic, calculateApiCostOpenAI } from "../cost"
import type { ModelInfo } from "@roo-code/types"
describe("parseApiPrice", () => {
it("should handle zero as a number", () => {
expect(parseApiPrice(0)).toBe(0)
})
it("should handle zero as a string", () => {
expect(parseApiPrice("0")).toBe(0)
})
it("should handle positive numbers", () => {
expect(parseApiPrice(0.0002)).toBe(200)
expect(parseApiPrice(0.00002)).toBe(20)
})
it("should handle positive number strings", () => {
expect(parseApiPrice("0.0002")).toBe(200)
expect(parseApiPrice("0.00002")).toBe(20)
})
it("should return undefined for null", () => {
expect(parseApiPrice(null)).toBeUndefined()
})
it("should return undefined for undefined", () => {
expect(parseApiPrice(undefined)).toBeUndefined()
})
it("should return undefined for empty string", () => {
expect(parseApiPrice("")).toBeUndefined()
})
})
describe("calculateApiCostAnthropic", () => {
const modelInfo: ModelInfo = {
maxTokens: 4096,
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 300,
outputPrice: 1500,
cacheWritesPrice: 375,
cacheReadsPrice: 30,
}
it("should calculate cost without caching", () => {
const cost = calculateApiCostAnthropic(modelInfo, 1000, 500)
expect(cost).toBeCloseTo(0.3 + 0.75, 10)
})
it("should calculate cost with cache creation", () => {
const cost = calculateApiCostAnthropic(modelInfo, 1000, 500, 2000)
expect(cost).toBeCloseTo(0.3 + 0.75 + 0.75, 10)
})
it("should calculate cost with cache reads", () => {
const cost = calculateApiCostAnthropic(modelInfo, 1000, 500, 0, 3000)
expect(cost).toBeCloseTo(0.3 + 0.75 + 0.09, 10)
})
it("should handle zero cost for free models", () => {
const freeModel: ModelInfo = {
maxTokens: 4096,
contextWindow: 200000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
}
const cost = calculateApiCostAnthropic(freeModel, 1000, 500)
expect(cost).toBe(0)
})
})
describe("calculateApiCostOpenAI", () => {
const modelInfo: ModelInfo = {
maxTokens: 4096,
contextWindow: 128000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 150,
outputPrice: 600,
cacheWritesPrice: 187.5,
cacheReadsPrice: 15,
}
it("should calculate cost without caching", () => {
const cost = calculateApiCostOpenAI(modelInfo, 1000, 500)
expect(cost).toBeCloseTo(0.15 + 0.3, 10)
})
it("should subtract cached tokens from input tokens", () => {
const cost = calculateApiCostOpenAI(modelInfo, 5000, 500, 2000, 1000)
// 5000 total - 2000 cache creation - 1000 cache read = 2000 non-cached
// Cost: (2000 * 0.00015) + (2000 * 0.0001875) + (1000 * 0.000015) + (500 * 0.0006)
expect(cost).toBeCloseTo(0.3 + 0.375 + 0.015 + 0.3, 10)
})
it("should handle zero cost for free models", () => {
const freeModel: ModelInfo = {
maxTokens: 4096,
contextWindow: 128000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
}
const cost = calculateApiCostOpenAI(freeModel, 1000, 500)
expect(cost).toBe(0)
})
})

View file

@ -165,6 +165,7 @@ const dynamicProviderExtras = {
lmstudio: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type
roo: {} as { apiKey?: string; baseUrl?: string },
chutes: {} as { apiKey?: string },
xai: {} as { apiKey?: string; baseUrl?: string },
} as const satisfies Record<RouterName, object>
// Build the dynamic options union from the map, intersected with CommonFetchParams

View file

@ -80,4 +80,8 @@ export function calculateApiCostOpenAI(
)
}
export const parseApiPrice = (price: any) => (price ? parseFloat(price) * 1_000_000 : undefined)
export const parseApiPrice = (price: any) => {
if (price == null) return undefined
const parsed = parseFloat(price)
return isNaN(parsed) ? undefined : parsed * 1_000_000
}

View file

@ -235,7 +235,8 @@ const ApiOptions = ({
} else if (
selectedProvider === "litellm" ||
selectedProvider === "deepinfra" ||
selectedProvider === "roo"
selectedProvider === "roo" ||
selectedProvider === "xai"
) {
vscode.postMessage({ type: "requestRouterModels" })
}
@ -252,6 +253,7 @@ const ApiOptions = ({
apiConfiguration?.litellmApiKey,
apiConfiguration?.deepInfraApiKey,
apiConfiguration?.deepInfraBaseUrl,
apiConfiguration?.xaiApiKey,
customHeaders,
],
)
@ -609,7 +611,14 @@ const ApiOptions = ({
)}
{selectedProvider === "xai" && (
<XAI apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} />
<XAI
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
routerModels={routerModels}
refetchRouterModels={refetchRouterModels}
organizationAllowList={organizationAllowList}
modelValidationError={modelValidationError}
/>
)}
{selectedProvider === "groq" && (
@ -700,7 +709,7 @@ const ApiOptions = ({
<Featherless apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} />
)}
{selectedProviderModels.length > 0 && (
{selectedProvider !== "xai" && selectedProviderModels.length > 0 && (
<>
<div>
<label className="block font-medium mb-1">{t("settings:providers.model")}</label>

View file

@ -553,6 +553,32 @@ describe("ApiOptions", () => {
expect(screen.getByTestId("litellm-refresh-models")).toBeInTheDocument()
})
it("hides generic Model picker when provider is xai", () => {
renderApiOptions({
apiConfiguration: {
apiProvider: "xai",
},
})
// The generic "Model" label should be absent for xai (uses provider-specific picker)
expect(screen.queryByText("Model")).not.toBeInTheDocument()
})
it("disables xAI refresh and hides ModelPicker when no API key", () => {
renderApiOptions({
apiConfiguration: {
apiProvider: "xai",
xaiApiKey: "",
},
})
// Generic Model picker should be hidden for xAI
expect(screen.queryByText("Model")).not.toBeInTheDocument()
// If the provider-specific refresh button is present, it should be disabled without a key
const btn = screen.queryByTestId("xai-refresh-models")
if (btn) {
expect(btn).toBeDisabled()
}
})
it("does not render LiteLLM component when other provider is selected", () => {
renderApiOptions({
apiConfiguration: {

View file

@ -12,7 +12,6 @@ import {
openAiNativeModels,
qwenCodeModels,
vertexModels,
xaiModels,
groqModels,
sambaNovaModels,
doubaoModels,
@ -35,7 +34,6 @@ export const MODELS_BY_PROVIDER: Partial<Record<ProviderName, Record<string, Mod
"openai-native": openAiNativeModels,
"qwen-code": qwenCodeModels,
vertex: vertexModels,
xai: xaiModels,
groq: groqModels,
sambanova: sambaNovaModels,
zai: internationalZAiModels,

View file

@ -1,20 +1,40 @@
import { useCallback } from "react"
import { useCallback, useState } from "react"
import { useEvent } from "react-use"
import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import type { ProviderSettings } from "@roo-code/types"
import type { ProviderSettings, OrganizationAllowList } from "@roo-code/types"
import { xaiDefaultModelId, xaiModels } from "@roo-code/types"
import type { RouterModels } from "@roo/api"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink"
import { Button } from "@src/components/ui"
import { inputEventTransform } from "../transforms"
import { ModelPicker } from "../ModelPicker"
import { vscode } from "@src/utils/vscode"
type XAIProps = {
apiConfiguration: ProviderSettings
setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void
routerModels?: RouterModels
refetchRouterModels?: () => void
organizationAllowList?: OrganizationAllowList
modelValidationError?: string
}
export const XAI = ({ apiConfiguration, setApiConfigurationField }: XAIProps) => {
export const XAI = ({
apiConfiguration,
setApiConfigurationField,
routerModels,
refetchRouterModels,
organizationAllowList,
modelValidationError,
}: XAIProps) => {
const { t } = useAppTranslation()
const [didRefetch, setDidRefetch] = useState<boolean>()
const [refreshError, setRefreshError] = useState<string>()
const handleInputChange = useCallback(
<K extends keyof ProviderSettings, E>(
@ -27,6 +47,56 @@ export const XAI = ({ apiConfiguration, setApiConfigurationField }: XAIProps) =>
[setApiConfigurationField],
)
const handleRefresh = useCallback(() => {
// Reset status and request fresh models
setDidRefetch(false)
setRefreshError(undefined)
// Flush xAI cache and request fresh models
vscode.postMessage({ type: "flushRouterModels", text: "xai" })
vscode.postMessage({ type: "requestRouterModels" })
// Allow consumer to refetch react-query if provided
refetchRouterModels?.()
}, [refetchRouterModels])
// Listen for router responses to determine success/failure
useEvent(
"message",
useCallback(
(event: MessageEvent) => {
const message: any = event.data
// Error channel: single provider failure
if (message?.type === "singleRouterModelFetchResponse" && message?.values?.provider === "xai") {
if (!message.success) {
setDidRefetch(false)
setRefreshError(
t("settings:providers.refreshModels.error") ||
"Failed to fetch xAI models. Please verify your API key and try again.",
)
}
}
// Success path: routerModels set with non-empty xai models
if (message?.type === "routerModels") {
const models = message.routerModels?.xai ?? {}
if (models && Object.keys(models).length > 0) {
setRefreshError(undefined)
setDidRefetch(true)
} else if (apiConfiguration?.xaiApiKey) {
// With a key provided, an empty set indicates failure/unavailable
setDidRefetch(false)
setRefreshError(
t("settings:providers.refreshModels.error") ||
"No xAI models found for this API key. Please verify your API key and try again.",
)
}
}
},
[apiConfiguration?.xaiApiKey, t],
),
)
return (
<>
<VSCodeTextField
@ -45,6 +115,81 @@ export const XAI = ({ apiConfiguration, setApiConfigurationField }: XAIProps) =>
{t("settings:providers.getXaiApiKey")}
</VSCodeButtonLink>
)}
{/* Refresh button is disabled without API key */}
<div className="flex justify-end mt-2">
<Button
variant="outline"
onClick={handleRefresh}
className="w-1/2 max-w-xs"
data-testid="xai-refresh-models"
disabled={!apiConfiguration?.xaiApiKey}>
<div className="flex items-center gap-2 justify-center">
<span className="codicon codicon-refresh" />
{t("settings:providers.refreshModels.label")}
</div>
</Button>
</div>
{/* Status messaging */}
{refreshError && <div className="flex items-center text-vscode-errorForeground mt-2">{refreshError}</div>}
{!refreshError && didRefetch && (
<div className="flex items-center text-vscode-charts-green mt-2">
{t("settings:providers.refreshModels.success")}
</div>
)}
{/* Hide ModelPicker until an API key is provided */}
{apiConfiguration?.xaiApiKey ? (
<>
<ModelPicker
apiConfiguration={apiConfiguration}
defaultModelId={xaiDefaultModelId}
models={routerModels?.xai ?? {}}
modelIdKey="apiModelId"
serviceName="xAI (Grok)"
serviceUrl="https://api.x.ai/docs"
setApiConfigurationField={setApiConfigurationField}
organizationAllowList={organizationAllowList as OrganizationAllowList}
errorMessage={modelValidationError}
/>
{/* Context Window Override - only show for models not in static registry or with undefined contextWindow */}
{(() => {
const selectedModelId = apiConfiguration?.apiModelId || xaiDefaultModelId
const staticModel = xaiModels[selectedModelId as keyof typeof xaiModels]
const hasStaticContextWindow = staticModel?.contextWindow !== undefined
if (!hasStaticContextWindow) {
return (
<>
<VSCodeTextField
value={apiConfiguration?.xaiModelContextWindow?.toString() || ""}
onInput={handleInputChange("xaiModelContextWindow", (e) => {
const target = e.target as HTMLInputElement
const value = target.value
return value ? parseInt(value, 10) : undefined
})}
placeholder="e.g., 256000"
className="w-full mt-4">
<label className="block font-medium mb-1">
Context Window Override (tokens)
</label>
</VSCodeTextField>
<div className="text-sm text-vscode-descriptionForeground -mt-2">
This model&apos;s context window is not known. Please enter it manually.
</div>
</>
)
}
return null
})()}
</>
) : (
<div className="text-sm text-vscode-descriptionForeground mt-2">
{t("settings:providers.refreshModels.missingConfig")}
</div>
)}
</>
)
}

View file

@ -182,8 +182,34 @@ function getSelectedModel({
}
case "xai": {
const id = apiConfiguration.apiModelId ?? xaiDefaultModelId
const info = xaiModels[id as keyof typeof xaiModels]
return info ? { id, info } : { id, info: undefined }
const dynamicInfo = routerModels.xai?.[id]
if (dynamicInfo) {
// If router-provided model lacks contextWindow, apply manual override when provided
const overrideCw = apiConfiguration.xaiModelContextWindow
const info =
dynamicInfo.contextWindow === undefined && typeof overrideCw === "number"
? { ...dynamicInfo, contextWindow: overrideCw }
: dynamicInfo
return { id, info }
}
const staticInfo = xaiModels[id as keyof typeof xaiModels]
// Build a complete ModelInfo fallback to satisfy UI expectations until dynamic models load
const info: ModelInfo = {
...openAiModelInfoSaneDefaults,
contextWindow:
apiConfiguration.xaiModelContextWindow ??
staticInfo?.contextWindow ??
openAiModelInfoSaneDefaults.contextWindow,
maxTokens: staticInfo?.maxTokens ?? openAiModelInfoSaneDefaults.maxTokens,
supportsPromptCache: false, // Placeholder; dynamic API will provide real value
supportsImages: false, // Placeholder; dynamic API will provide real value
description: staticInfo?.description,
supportsReasoningEffort:
staticInfo && "supportsReasoningEffort" in staticInfo
? staticInfo.supportsReasoningEffort
: undefined,
}
return { id, info }
}
case "groq": {
const id = apiConfiguration.apiModelId ?? groqDefaultModelId

View file

@ -45,6 +45,7 @@ describe("Model Validation Functions", () => {
huggingface: {},
roo: {},
chutes: {},
xai: {},
}
const allowAllOrganization: OrganizationAllowList = {