diff --git a/packages/core/src/provider-generation-models.ts b/packages/core/src/provider-generation-models.ts index f196e649..5ac581f4 100644 --- a/packages/core/src/provider-generation-models.ts +++ b/packages/core/src/provider-generation-models.ts @@ -16,6 +16,7 @@ export type GenerationModelFamilyId = | "black-forest-labs" | "qwen-image" | "ideogram" + | "xai-grok-imagine-video" | "xai-grok-imagine"; export interface GenerationModelFamilyInference { @@ -111,6 +112,13 @@ const generationFamilyRules: readonly GenerationModelFamilyRule[] = [ capabilities: ["image_generate"], supportedParameters: ["size", "seed"], }, + { + familyId: "xai-grok-imagine-video", + modelPattern: /(?:^|[/_-])grok-imagine-video(?=$|[/_.-])/i, + ownedBy: "xai", + capabilities: ["video_generate"], + supportedParameters: ["ratio", "duration"], + }, { familyId: "xai-grok-imagine", modelPattern: /(?:^|[/_-])grok-imagine(?=$|[/_.-])/i, diff --git a/packages/core/test/provider-check.test.mjs b/packages/core/test/provider-check.test.mjs index 4dfe43f6..841e0fc4 100644 --- a/packages/core/test/provider-check.test.mjs +++ b/packages/core/test/provider-check.test.mjs @@ -127,6 +127,9 @@ test("generation model catalog recognizes public image families without marking ["qwen-image-edit", ["image_generate", "image_edit"]], ["ideogram-ai/ideogram-v3-quality", ["image_generate"]], ["xai/grok-imagine", ["image_generate"]], + ["xai/grok-imagine-video", ["video_generate"]], + ["grok-imagine-video-1.5", ["video_generate"]], + ["grok-imagine-video-1.5-lite", ["video_generate"]], ]; for (const [modelId, capabilities] of cases) { diff --git a/scripts/ci/provider-model-catalog-normalization.test.mjs b/scripts/ci/provider-model-catalog-normalization.test.mjs new file mode 100644 index 00000000..15ddc48d --- /dev/null +++ b/scripts/ci/provider-model-catalog-normalization.test.mjs @@ -0,0 +1,54 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { normalizeModelTokenLimits } from "../provider-model-catalog-normalization.mjs"; + +test("catalog token limits preserve output capacity that leaves room for model input", () => { + assert.deepEqual(normalizeModelTokenLimits({ context: 8_192, output: 4_096 }), { + contextWindow: 8_192, + maxTokens: 4_096, + }); +}); + +test("catalog token limits bound invalid output capacity to leave a usable input budget", () => { + for (const [context, output, expectedOutput] of [ + [8_192, 8_192, 6_656], + [8_192, 16_384, 6_656], + [8_192, 7_680, 6_656], + [1_048_576, 1_048_576, 16_384], + ]) { + assert.deepEqual(normalizeModelTokenLimits({ context, output }), { + contextWindow: context, + maxTokens: expectedOutput, + }); + } +}); + +test("catalog token limits omit an unusably small paired budget", () => { + assert.deepEqual(normalizeModelTokenLimits({ context: 512, output: 512 }), { + contextWindow: undefined, + maxTokens: undefined, + }); +}); + +test("catalog token limits retain valid standalone capacities", () => { + assert.deepEqual(normalizeModelTokenLimits({ output: 4_096 }), { + contextWindow: undefined, + maxTokens: 4_096, + }); + assert.deepEqual(normalizeModelTokenLimits({ output: 100_000 }), { + contextWindow: undefined, + maxTokens: 100_000, + }); + assert.deepEqual(normalizeModelTokenLimits({ output: 128_000 }), { + contextWindow: undefined, + maxTokens: 16_384, + }); + assert.deepEqual(normalizeModelTokenLimits({ context: 8_192 }), { + contextWindow: 8_192, + maxTokens: 6_656, + }); + assert.deepEqual(normalizeModelTokenLimits({ context: 128_000 }), { + contextWindow: 128_000, + maxTokens: undefined, + }); +}); diff --git a/scripts/provider-model-catalog-normalization.mjs b/scripts/provider-model-catalog-normalization.mjs new file mode 100644 index 00000000..9b7a6de7 --- /dev/null +++ b/scripts/provider-model-catalog-normalization.mjs @@ -0,0 +1,35 @@ +const RESERVED_RUNTIME_OVERHEAD_TOKENS = 512; +const MINIMUM_INPUT_BUDGET_TOKENS = 1_024; +const DEFAULT_CONTEXT_WINDOW_TOKENS = 128_000; +const DEFAULT_OUTPUT_LIMIT_TOKENS = 16_384; + +export function normalizeModelTokenLimits(limit) { + let contextWindow = positiveInteger(limit?.context); + const outputLimit = positiveInteger(limit?.output); + let maxTokens = outputLimit; + + if (contextWindow === undefined && outputLimit !== undefined) { + const fallbackSafeOutput = DEFAULT_CONTEXT_WINDOW_TOKENS + - RESERVED_RUNTIME_OVERHEAD_TOKENS + - MINIMUM_INPUT_BUDGET_TOKENS; + if (outputLimit > fallbackSafeOutput) { + maxTokens = Math.min(outputLimit, DEFAULT_OUTPUT_LIMIT_TOKENS, fallbackSafeOutput); + } + } else if (contextWindow !== undefined) { + const largestSafeOutput = contextWindow - RESERVED_RUNTIME_OVERHEAD_TOKENS - MINIMUM_INPUT_BUDGET_TOKENS; + if (largestSafeOutput <= 0) { + contextWindow = undefined; + maxTokens = undefined; + } else if (outputLimit === undefined) { + maxTokens = DEFAULT_OUTPUT_LIMIT_TOKENS > largestSafeOutput ? largestSafeOutput : undefined; + } else if (outputLimit > largestSafeOutput) { + maxTokens = Math.min(outputLimit, DEFAULT_OUTPUT_LIMIT_TOKENS, largestSafeOutput); + } + } + + return { contextWindow, maxTokens }; +} + +function positiveInteger(value) { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; +} diff --git a/scripts/sync-provider-model-catalog.mjs b/scripts/sync-provider-model-catalog.mjs index 7725172b..55033918 100644 --- a/scripts/sync-provider-model-catalog.mjs +++ b/scripts/sync-provider-model-catalog.mjs @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { readFile, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { normalizeModelTokenLimits } from "./provider-model-catalog-normalization.mjs"; const SOURCE_URL = "https://models.dev/api.json"; const TRUSTED_PROVIDER_IDS = [ @@ -96,6 +97,7 @@ function normalizeModel(providerId, value) { const thinkingModes = normalizeThinkingModes(value.reasoning_options); const limit = isRecord(value.limit) ? value.limit : undefined; + const tokenLimits = normalizeModelTokenLimits(limit); return omitUndefined({ providerId, id: value.id.trim(), @@ -103,8 +105,8 @@ function normalizeModel(providerId, value) { input: input.length > 0 ? input : undefined, capabilities, thinkingModes: thinkingModes.length > 0 ? thinkingModes : undefined, - contextWindow: positiveInteger(limit?.context), - maxTokens: positiveInteger(limit?.output), + contextWindow: tokenLimits.contextWindow, + maxTokens: tokenLimits.maxTokens, updatedAt: normalizedDate(value.last_updated), }); } @@ -165,10 +167,6 @@ function normalizedDate(value) { return value; } -function positiveInteger(value) { - return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined; -} - function stringArray(value) { return Array.isArray(value) ? value.filter((item) => typeof item === "string") : []; }