diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index 3e5f80841..0ea3cb284 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -143,6 +143,10 @@ - Requested `reasoning.encrypted_content` on first-party xAI Responses calls (`xai` and `xai-oauth`) via the `include` parameter. - Replayed xAI encrypted reasoning items on later Responses turns instead of stripping `type: "reasoning"` history. +### Fixed + +- Invalidated stale paid-xAI model-cache rows written under Chat Completions so the Responses migration takes effect immediately instead of waiting for TTL expiry. + ## [17.2.5] - 2026-08-03 ### Fixed diff --git a/packages/catalog/src/provider-models/openai-compat.ts b/packages/catalog/src/provider-models/openai-compat.ts index 59dd61514..8c9fe3384 100644 --- a/packages/catalog/src/provider-models/openai-compat.ts +++ b/packages/catalog/src/provider-models/openai-compat.ts @@ -1258,7 +1258,15 @@ export interface XaiModelManagerConfig { } export function xaiModelManagerOptions(config?: XaiModelManagerConfig): ModelManagerOptions<"openai-responses"> { - return createSimpleOpenAIResponsesOptions("xai", "https://api.x.ai/v1", config); + return { + ...createSimpleOpenAIResponsesOptions("xai", "https://api.x.ai/v1", config), + // Completions → Responses migration: a fresh authoritative cache written + // by the old resolver stores `api: "openai-completions"` for these ids. + // Without a drop list, `online-if-uncached` skips the network and + // `mergeDynamicModel` lets the cached api win over the new static + // Responses entries until TTL expiry. + dropCachedModelIdsOnStaticMismatch: getBundledModels("xai").map(model => model.id), + }; } export interface XaiOAuthModelManagerConfig { diff --git a/packages/catalog/test/xai-api-key-responses.test.ts b/packages/catalog/test/xai-api-key-responses.test.ts index 5f605eb5c..c11849cec 100644 --- a/packages/catalog/test/xai-api-key-responses.test.ts +++ b/packages/catalog/test/xai-api-key-responses.test.ts @@ -1,7 +1,37 @@ import { describe, expect, it } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { resolveProviderModels } from "@oh-my-pi/pi-catalog/model-manager"; import { getBundledModels } from "@oh-my-pi/pi-catalog/models"; import { CATALOG_PROVIDERS } from "@oh-my-pi/pi-catalog/provider-models/descriptors"; import { xaiModelManagerOptions } from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; +import type { ModelSpec } from "@oh-my-pi/pi-catalog/types"; + +const XAI_RESPONSES_SPEC: ModelSpec<"openai-responses"> = { + id: "grok-4.5", + name: "Grok 4.5", + api: "openai-responses", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text", "image"], + cost: { input: 2, output: 6, cacheRead: 0.3, cacheWrite: 0 }, + contextWindow: 500_000, + maxTokens: 500_000, +}; +const XAI_COMPLETIONS_SPEC: ModelSpec<"openai-completions"> = { + id: "grok-4.5", + name: "Grok 4.5", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text", "image"], + cost: { input: 2, output: 6, cacheRead: 0.3, cacheWrite: 0 }, + contextWindow: 500_000, + maxTokens: 500_000, +}; describe("paid xai (XAI_API_KEY) Responses contract", () => { it("registers xai on the catalog Responses discovery path", () => { @@ -12,6 +42,8 @@ describe("paid xai (XAI_API_KEY) Responses contract", () => { const options = xaiModelManagerOptions({ apiKey: "test-key" }); expect(options.providerId).toBe("xai"); expect(options.fetchDynamicModels, "live /v1/models overlay").toBeTypeOf("function"); + expect(options.dropCachedModelIdsOnStaticMismatch).toEqual(getBundledModels("xai").map(model => model.id)); + expect(options.dropCachedModelIdsOnStaticMismatch).toContain("grok-4.5"); }); it("bundles every paid xai chat model on openai-responses", () => { @@ -22,4 +54,50 @@ describe("paid xai (XAI_API_KEY) Responses contract", () => { expect(model.baseUrl).toBe("https://api.x.ai/v1"); } }); + + it("drops stale Chat Completions cache rows so Responses takes effect immediately", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-catalog-xai-completions-cache-")); + const dbPath = path.join(tempDir, "models.db"); + try { + await resolveProviderModels( + { + providerId: "xai", + staticModels: [XAI_COMPLETIONS_SPEC], + fetchDynamicModels: async () => [XAI_COMPLETIONS_SPEC], + cacheDbPath: dbPath, + }, + "online", + ); + + let fetches = 0; + const migrated = await resolveProviderModels( + { + ...xaiModelManagerOptions(), + staticModels: [XAI_RESPONSES_SPEC], + cacheDbPath: dbPath, + fetchDynamicModels: async () => { + fetches += 1; + return [XAI_RESPONSES_SPEC]; + }, + }, + "online-if-uncached", + ); + + expect(fetches).toBe(1); + expect(migrated.models.find(model => model.id === "grok-4.5")?.api).toBe("openai-responses"); + + const offline = await resolveProviderModels( + { + ...xaiModelManagerOptions(), + staticModels: [XAI_RESPONSES_SPEC], + cacheDbPath: dbPath, + fetchDynamicModels: async () => null, + }, + "offline", + ); + expect(offline.models.find(model => model.id === "grok-4.5")?.api).toBe("openai-responses"); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); });