Merge pull request #1813 from anomalyco/sync-xai
chore(sync): add xai model sync
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
.idea
|
||||
dist
|
||||
.DS_Store
|
||||
.sync/
|
||||
node_modules
|
||||
data/tokenspeed-monitor.sqlite
|
||||
data/tokenspeed-monitor.sqlite-shm
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user