Merge pull request #1813 from anomalyco/sync-xai

chore(sync): add xai model sync
This commit is contained in:
Aiden Cline
2026-05-20 16:02:38 -05:00
committed by GitHub
19 changed files with 257 additions and 221 deletions
+1
View File
@@ -3,6 +3,7 @@
.idea
dist
.DS_Store
.sync/
node_modules
data/tokenspeed-monitor.sqlite
data/tokenspeed-monitor.sqlite-shm
+4 -1
View File
@@ -7,6 +7,7 @@ import { z } from "zod";
import { AuthoredModel, AuthoredModelShape } from "../src/schema.js";
import { google } from "./sync/google.js";
import { openrouter } from "./sync/openrouter.js";
import { xai } from "./sync/xai.js";
const ExistingModel = AuthoredModelShape.partial()
.extend({
@@ -53,14 +54,16 @@ export interface SyncResult {
export const providers: {
google: SyncProvider<any>;
openrouter: SyncProvider<any>;
xai: SyncProvider<any>;
} = {
google,
openrouter,
xai,
};
export const groups = {
aggregators: ["openrouter"],
direct: ["google"],
direct: ["google", "xai"],
} as const;
type ProviderID = keyof typeof providers;
+205
View File
@@ -0,0 +1,205 @@
import { z } from "zod";
import type { ExistingModel, SyncProvider, SyncedModel } from "../sync-models.js";
const API_BASE = "https://api.x.ai/v1";
const XAIModel = z.object({
id: z.string(),
canonical_id: z.string().optional(),
created: z.number().int().nonnegative(),
aliases: z.array(z.string()).optional(),
input_modalities: z.array(z.string()).optional(),
output_modalities: z.array(z.string()).optional(),
prompt_text_token_price: z.number().int().nonnegative().optional(),
cached_prompt_text_token_price: z.number().int().nonnegative().optional(),
completion_text_token_price: z.number().int().nonnegative().optional(),
max_prompt_length: z.number().int().nonnegative().optional(),
}).passthrough();
const XAIModelList = z.object({
models: z.array(XAIModel),
}).passthrough();
const XAIResponse = z.object({
models: z.array(XAIModel),
});
const XAIAPIKey = z.object({
acls: z.array(z.string()),
}).passthrough();
type XAIModel = z.infer<typeof XAIModel>;
export const xai = {
id: "xai",
name: "xAI",
modelsDir: "providers/xai/models",
skipCreates: true,
sourceID(model) {
return model.id;
},
skippedNotice(ids) {
if (ids.length === 0) return [];
return [
`${ids.length} xAI models returned by the API were not created because the Models API does not provide enough authoritative metadata for the catalog, especially output token limits and some feature/capability flags. Existing models are still updated from API-authoritative fields.`,
`Skipped remote IDs: ${ids.map((id) => `\`${id}\``).join(", ")}`,
];
},
async fetchModels() {
const key = process.env.XAI_API_KEY;
if (key === undefined) throw new Error("xAI sync requires XAI_API_KEY");
await assertFullModelAccess(key);
const models = await Promise.all([
fetchTypedModels(key, "language-models"),
fetchTypedModels(key, "image-generation-models"),
fetchTypedModels(key, "video-generation-models"),
]);
return { models: models.flat() };
},
parseModels(raw) {
const models = XAIResponse.parse(raw).models;
const seen = new Set<string>();
const expanded: XAIModel[] = [];
for (const model of models) {
if (!seen.has(model.id)) {
seen.add(model.id);
expanded.push(model);
}
}
for (const model of models) {
for (const alias of model.aliases ?? []) {
if (seen.has(alias)) continue;
seen.add(alias);
expanded.push({ ...model, id: alias, canonical_id: model.id });
}
}
return expanded;
},
translateModel(model, context) {
const existing = context.existing(model.id);
if (existing === undefined) return undefined;
return {
id: model.id,
model: buildModel(model, existing),
};
},
} satisfies SyncProvider<XAIModel>;
async function assertFullModelAccess(key: string) {
const response = await fetch(`${API_BASE}/api-key`, {
headers: { Authorization: `Bearer ${key}` },
});
if (!response.ok) {
throw new Error(`xAI API key metadata request failed: ${response.status} ${response.statusText}`);
}
const apiKey = XAIAPIKey.parse(await response.json());
if (!apiKey.acls.includes("api-key:model:*")) {
throw new Error("xAI sync requires XAI_API_KEY to include api-key:model:* so the model list is not ACL-filtered");
}
}
async function fetchTypedModels(key: string, endpoint: string) {
const response = await fetch(`${API_BASE}/${endpoint}`, {
headers: { Authorization: `Bearer ${key}` },
});
if (!response.ok) {
throw new Error(`xAI ${endpoint} request failed: ${response.status} ${response.statusText}`);
}
return XAIModelList.parse(await response.json()).models;
}
function dateFromTimestamp(timestamp: number) {
return new Date(timestamp * 1000).toISOString().slice(0, 10);
}
type Modality = "text" | "audio" | "image" | "video" | "pdf";
function modalities(values: string[] | undefined, fallback: Modality[]) {
const allowed = new Set<Modality>(["text", "audio", "image", "video", "pdf"]);
const result = (values ?? [])
.map((value) => value.toLowerCase())
.filter((value): value is Modality => allowed.has(value as Modality));
return [...new Set(result.length > 0 ? result : fallback)];
}
function tokenPrice(value: number | undefined) {
if (value === undefined) return undefined;
return value / 10_000;
}
function cost(model: XAIModel, existing: ExistingModel) {
const input = tokenPrice(model.prompt_text_token_price);
const output = tokenPrice(model.completion_text_token_price);
if (input === undefined || output === undefined) return existing.cost;
return {
input,
output,
reasoning: existing.cost?.reasoning,
cache_read: tokenPrice(model.cached_prompt_text_token_price),
cache_write: existing.cost?.cache_write,
input_audio: existing.cost?.input_audio,
output_audio: existing.cost?.output_audio,
tiers: existing.cost?.tiers,
};
}
function buildModel(model: XAIModel, existing: ExistingModel): SyncedModel {
const name = existing.name;
const attachment = existing.attachment;
const reasoning = existing.reasoning;
const toolCall = existing.tool_call;
const openWeights = existing.open_weights;
const limit = existing.limit;
const releaseDate = existing.release_date;
const lastUpdated = existing.last_updated;
if (
name === undefined
|| attachment === undefined
|| reasoning === undefined
|| toolCall === undefined
|| openWeights === undefined
|| limit === undefined
|| (model.canonical_id !== undefined && releaseDate === undefined)
|| (model.canonical_id !== undefined && lastUpdated === undefined)
) {
throw new Error(`xAI model ${model.id} has incomplete local TOML metadata required for sync`);
}
const input = modalities(model.input_modalities, existing.modalities?.input ?? ["text"]);
const output = modalities(model.output_modalities, existing.modalities?.output ?? ["text"]);
const created = dateFromTimestamp(model.created);
return {
name,
family: existing.family,
release_date: model.canonical_id === undefined ? created : releaseDate!,
last_updated: model.canonical_id === undefined ? created : lastUpdated!,
attachment: input.some((value) => value !== "text"),
reasoning,
temperature: existing.temperature,
tool_call: toolCall,
structured_output: existing.structured_output,
knowledge: existing.knowledge,
open_weights: openWeights,
status: existing.status,
interleaved: existing.interleaved,
cost: cost(model, existing),
limit: {
input: limit.input,
context: model.max_prompt_length ?? limit.context,
output: limit.output,
},
modalities: { input, output },
};
}
-23
View File
@@ -1,23 +0,0 @@
name = "Grok 2 (1212)"
family = "grok"
release_date = "2024-12-12"
last_updated = "2024-12-12"
attachment = false
reasoning = false
temperature = true
knowledge = "2024-08"
tool_call = true
open_weights = false
[cost]
input = 2.00
output = 10.00
cache_read = 2.00
[limit]
context = 131_072
output = 8_192
[modalities]
input = ["text"]
output = ["text"]
-23
View File
@@ -1,23 +0,0 @@
name = "Grok 2 Latest"
family = "grok"
release_date = "2024-08-20"
last_updated = "2024-12-12"
attachment = false
reasoning = false
temperature = true
knowledge = "2024-08"
tool_call = true
open_weights = false
[cost]
input = 2.00
output = 10.00
cache_read = 2.00
[limit]
context = 131_072
output = 8_192
[modalities]
input = ["text"]
output = ["text"]
@@ -1,23 +0,0 @@
name = "Grok 2 Vision (1212)"
family = "grok"
release_date = "2024-08-20"
last_updated = "2024-12-12"
attachment = true
reasoning = false
temperature = true
knowledge = "2024-08"
tool_call = true
open_weights = false
[cost]
input = 2.00
output = 10.00
cache_read = 2.00
[limit]
context = 8_192
output = 4_096
[modalities]
input = ["text", "image"]
output = ["text"]
@@ -1,23 +0,0 @@
name = "Grok 2 Vision Latest"
family = "grok"
release_date = "2024-08-20"
last_updated = "2024-12-12"
attachment = true
reasoning = false
temperature = true
knowledge = "2024-08"
tool_call = true
open_weights = false
[cost]
input = 2.00
output = 10.00
cache_read = 2.00
[limit]
context = 8_192
output = 4_096
[modalities]
input = ["text", "image"]
output = ["text"]
-23
View File
@@ -1,23 +0,0 @@
name = "Grok 2 Vision"
family = "grok"
release_date = "2024-08-20"
last_updated = "2024-08-20"
attachment = true
reasoning = false
temperature = true
knowledge = "2024-08"
tool_call = true
open_weights = false
[cost]
input = 2.00
output = 10.00
cache_read = 2.00
[limit]
context = 8_192
output = 4_096
[modalities]
input = ["text", "image"]
output = ["text"]
-23
View File
@@ -1,23 +0,0 @@
name = "Grok 2"
family = "grok"
release_date = "2024-08-20"
last_updated = "2024-08-20"
attachment = false
reasoning = false
temperature = true
knowledge = "2024-08"
tool_call = true
open_weights = false
[cost]
input = 2.00
output = 10.00
cache_read = 2.00
[limit]
context = 131_072
output = 8_192
[modalities]
input = ["text"]
output = ["text"]
@@ -9,15 +9,15 @@ tool_call = true
open_weights = false
[cost]
input = 2.00
output = 6.00
cache_read = 0.20
input = 1.25
output = 2.5
cache_read = 0.2
[[cost.tiers]]
tier = { size = 200_000 }
input = 4.00
output = 12.00
cache_read = 0.40
input = 4
output = 12
cache_read = 0.4
[limit]
context = 2_000_000
@@ -9,15 +9,15 @@ tool_call = true
open_weights = false
[cost]
input = 2.00
output = 6.00
cache_read = 0.20
input = 1.25
output = 2.5
cache_read = 0.2
[[cost.tiers]]
tier = { size = 200_000 }
input = 4.00
output = 12.00
cache_read = 0.40
input = 4
output = 12
cache_read = 0.4
[limit]
context = 2_000_000
@@ -9,15 +9,15 @@ tool_call = false
open_weights = false
[cost]
input = 2.00
output = 6.00
cache_read = 0.20
input = 1.25
output = 2.5
cache_read = 0.2
[[cost.tiers]]
tier = { size = 200_000 }
input = 4.00
output = 12.00
cache_read = 0.40
input = 4
output = 12
cache_read = 0.4
[limit]
context = 2_000_000
+7 -7
View File
@@ -1,7 +1,7 @@
name = "Grok 4.3"
family = "grok"
release_date = "2026-05-01"
last_updated = "2026-05-01"
release_date = "2026-04-17"
last_updated = "2026-04-17"
attachment = true
reasoning = true
temperature = true
@@ -10,14 +10,14 @@ open_weights = false
[cost]
input = 1.25
output = 2.50
cache_read = 0.20
output = 2.5
cache_read = 0.2
[[cost.tiers]]
tier = { size = 200_000 }
input = 2.50
output = 5.00
cache_read = 0.40
input = 2.5
output = 5
cache_read = 0.4
[limit]
context = 1_000_000
-23
View File
@@ -1,23 +0,0 @@
name = "Grok Beta"
family = "grok-beta"
release_date = "2024-11-01"
last_updated = "2024-11-01"
attachment = false
reasoning = false
temperature = true
knowledge = "2024-08"
tool_call = true
open_weights = false
[cost]
input = 5.00
output = 15.00
cache_read = 5.00
[limit]
context = 131_072
output = 4_096
[modalities]
input = ["text"]
output = ["text"]
@@ -1,7 +1,7 @@
name = "Grok Imagine Image Quality"
family = "grok"
release_date = "2026-04"
last_updated = "2026-05-16"
release_date = "2026-04-03"
last_updated = "2026-04-03"
attachment = true
reasoning = false
temperature = false
@@ -9,7 +9,7 @@ tool_call = false
open_weights = false
[limit]
context = 1024
context = 8_000
output = 0
[modalities]
+3 -3
View File
@@ -1,7 +1,7 @@
name = "Grok Imagine Image"
family = "grok"
release_date = "2026-03"
last_updated = "2026-05-16"
release_date = "2026-01-28"
last_updated = "2026-01-28"
attachment = true
reasoning = false
temperature = false
@@ -9,7 +9,7 @@ tool_call = false
open_weights = false
[limit]
context = 1024
context = 8_000
output = 0
[modalities]
+4 -4
View File
@@ -1,7 +1,7 @@
name = "Grok Imagine Video"
family = "grok"
release_date = "2026-03"
last_updated = "2026-05-16"
release_date = "2026-01-28"
last_updated = "2026-01-28"
attachment = true
reasoning = false
temperature = false
@@ -9,9 +9,9 @@ tool_call = false
open_weights = false
[limit]
context = 1024
context = 1_024
output = 0
[modalities]
input = ["text", "image", "video"]
input = ["text", "image"]
output = ["video"]
@@ -1,23 +0,0 @@
name = "Grok Vision Beta"
family = "grok-vision"
release_date = "2024-11-01"
last_updated = "2024-11-01"
attachment = true
reasoning = false
temperature = true
knowledge = "2024-08"
tool_call = true
open_weights = false
[cost]
input = 5.00
output = 15.00
cache_read = 5.00
[limit]
context = 8_192
output = 4_096
[modalities]
input = ["text", "image"]
output = ["text"]
+12 -1
View File
@@ -4,7 +4,7 @@ TODO: delete
Model syncs are centralized in `packages/core/script/sync-models.ts`. The runner owns file IO, TOML formatting, validation, reporting, dry runs, and deletion behavior. Individual provider sync modules only fetch source data, parse it, and translate each source model into the catalog schema.
The grouped sync targets are `aggregators`, which runs OpenRouter, and `direct`, which runs direct provider APIs like Google.
The grouped sync targets are `aggregators`, which runs OpenRouter, and `direct`, which runs direct provider APIs like Google and xAI.
## Commands
@@ -12,6 +12,7 @@ The grouped sync targets are `aggregators`, which runs OpenRouter, and `direct`,
- `bun models:sync openrouter` syncs only OpenRouter.
- `bun models:sync direct` syncs every provider in the `direct` group.
- `bun models:sync google` syncs only Google.
- `bun models:sync xai` syncs only xAI.
- `bun models:sync aggregators --dry-run` prints changes without writing model files.
- `bun models:sync aggregators --new-only` creates new model files but skips updates and removals.
- `bun validate` validates the generated catalog after a sync.
@@ -121,6 +122,16 @@ Google is implemented in `packages/core/script/sync/google.ts`.
- Local Google models missing from the API response are removed.
- New Google API models are reported in `.sync/model-sync-report.md` but not created automatically because the API does not provide authoritative modalities, pricing, knowledge cutoff, release date, tool calling, or structured output metadata.
## xAI Notes
xAI is implemented in `packages/core/script/sync/xai.ts`.
- Source endpoints: `https://api.x.ai/v1/language-models`, `https://api.x.ai/v1/image-generation-models`, and `https://api.x.ai/v1/video-generation-models`.
- Required auth: `XAI_API_KEY`.
- The richer typed endpoints provide model IDs, creation timestamps, modalities, pricing for language models, and prompt/input limits where available.
- Existing xAI models are updated from API-authoritative fields while local metadata is preserved for fields the API does not expose, especially output token limits and some feature/capability flags.
- New xAI API models are reported in `.sync/model-sync-report.md` but not created automatically because the API does not provide enough authoritative metadata for complete catalog entries.
## Vercel Status
Vercel is intentionally not wired into `bun models:sync` right now. Keep using the existing `vercel:generate` script until Vercel sync behavior is redesigned and reviewed separately.