From 9e60f0d12cd83f6c6f33fb35c861c66d560b1654 Mon Sep 17 00:00:00 2001 From: Povilas Kanapickas Date: Mon, 28 Sep 2026 22:43:04 +0000 Subject: [PATCH 1/2] feat(fireworks): fetch model list from the Fireworks API Replace the hardcoded Fireworks model picker with the account-scoped list returned by https://api.fireworks.ai/inference/v1/models. - Add getFireworksModels fetcher: known models keep curated static specs (incl. pricing); unknown models are built from the API's context_length and supports_image_input fields without pricing; non-chat models (embeddings/rerankers) are skipped. - Register fireworks as a dynamic provider; key-scoped model cache. - Fetch in requestRouterModels when a key is available; refresh flushes the cache using the key from the settings form. - FireworksHandler resolves info from static specs, then cached API metadata, then sane defaults, always honoring the configured id. - Fireworks settings: ModelPicker + Refresh Models button, falling back to the static list until a catalog is fetched. - useSelectedModel prefers static info, then router info. - Send a request identifier with each Fireworks router-model request and echo it on the response. The settings page accepts a response only when the identifier and the provider match its current request. - Drop a superseded identified request in the handler, so an older response cannot publish its catalog over a newer one. --- .../__tests__/provider-identifiers.test.ts | 1 + packages/types/src/provider-settings.ts | 1 + src/api/providers/__tests__/fireworks.spec.ts | 75 +- .../fetchers/__tests__/fireworks.spec.ts | 157 ++++ .../fetchers/__tests__/modelCache.spec.ts | 34 + src/api/providers/fetchers/fireworks.ts | 112 +++ src/api/providers/fireworks.ts | 47 +- .../webview/__tests__/ClineProvider.spec.ts | 3 + ...webviewMessageHandler.routerModels.spec.ts | 333 ++++++++ .../__tests__/webviewMessageHandler.spec.ts | 3 + src/core/webview/webviewMessageHandler.ts | 148 +++- src/shared/api.ts | 1 + .../src/components/settings/ApiOptions.tsx | 3 + .../settings/providers/Fireworks.tsx | 246 +++++- .../providers/__tests__/Fireworks.spec.tsx | 715 ++++++++++++++++++ .../__tests__/IOIntelligence.spec.tsx | 1 + .../providers/__tests__/NanoGPT.spec.tsx | 1 + .../settings/utils/providerModelConfig.ts | 1 + .../hooks/__tests__/useSelectedModel.spec.ts | 54 ++ .../components/ui/hooks/useSelectedModel.ts | 25 +- .../src/utils/__tests__/validate.spec.ts | 1 + 21 files changed, 1933 insertions(+), 29 deletions(-) create mode 100644 src/api/providers/fetchers/__tests__/fireworks.spec.ts create mode 100644 src/api/providers/fetchers/fireworks.ts create mode 100644 webview-ui/src/components/settings/providers/__tests__/Fireworks.spec.tsx diff --git a/packages/types/src/__tests__/provider-identifiers.test.ts b/packages/types/src/__tests__/provider-identifiers.test.ts index af4de232f9..f9b50f0731 100644 --- a/packages/types/src/__tests__/provider-identifiers.test.ts +++ b/packages/types/src/__tests__/provider-identifiers.test.ts @@ -109,6 +109,7 @@ describe("provider identifiers", () => { providerIdentifiers.poe, providerIdentifiers.deepseek, providerIdentifiers.moonshot, + providerIdentifiers.fireworks, providerIdentifiers.opencodeGo, providerIdentifiers.kenari, providerIdentifiers.nanogpt, diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index ebb48b041b..236d5804f5 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -97,6 +97,7 @@ export const dynamicProviders = [ providerIdentifiers.poe, providerIdentifiers.deepseek, providerIdentifiers.moonshot, + providerIdentifiers.fireworks, providerIdentifiers.opencodeGo, providerIdentifiers.kenari, providerIdentifiers.nanogpt, diff --git a/src/api/providers/__tests__/fireworks.spec.ts b/src/api/providers/__tests__/fireworks.spec.ts index 480f965894..db4a35cbea 100644 --- a/src/api/providers/__tests__/fireworks.spec.ts +++ b/src/api/providers/__tests__/fireworks.spec.ts @@ -3,15 +3,26 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { type FireworksModelId, fireworksDefaultModelId, fireworksModels } from "@roo-code/types" +import { + type FireworksModelId, + fireworksDefaultModelId, + fireworksModels, + openAiModelInfoSaneDefaults, + providerIdentifiers, +} from "@roo-code/types" import { FireworksHandler } from "../fireworks" +import { getModelsFromCache } from "../fetchers/modelCache" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" import { clearAllMocks } from "../../../test-utils/reset" // Create mock functions const mockCreate = vi.fn() +vi.mock("../fetchers/modelCache", () => ({ + getModelsFromCache: vi.fn(() => undefined), +})) + // Mock OpenAI module vi.mock("openai", () => ({ default: vi.fn(function () { @@ -98,7 +109,67 @@ describe("FireworksHandler", () => { expect(model.info).toEqual(expect.objectContaining(fireworksModels[testModelId])) }) - it("should omit max_tokens for a custom model", async () => { + it("should use cached API metadata for a dynamically fetched model", async () => { + const dynamicId = "accounts/fireworks/models/glm-5p3" + vi.mocked(getModelsFromCache).mockReturnValue({ + [dynamicId]: { + maxTokens: 16384, + contextWindow: 1_048_576, + supportsImages: true, + supportsPromptCache: false, + }, + }) + + const handlerWithModel = new FireworksHandler({ + apiModelId: dynamicId, + fireworksApiKey: "test-fireworks-api-key", + }) + const model = handlerWithModel.getModel() + + expect(getModelsFromCache).toHaveBeenCalledWith( + { + provider: providerIdentifiers.fireworks, + apiKey: "test-fireworks-api-key", + }, + handlerWithModel, + ) + expect(model.id).toBe(dynamicId) + expect(model.info.contextWindow).toBe(1_048_576) + expect(model.info.supportsImages).toBe(true) + + await collectStream(handlerWithModel.createMessage("system prompt", [])) + expect(mockCreate.mock.calls[0][0].model).toBe(dynamicId) + expect(mockCreate.mock.calls[0][0].max_tokens).toBe(16384) + }) + + it("should prefer static metadata over cached metadata for known models", () => { + const knownId: FireworksModelId = "accounts/fireworks/models/kimi-k2-instruct" + vi.mocked(getModelsFromCache).mockReturnValue({ + [knownId]: { contextWindow: 1, supportsPromptCache: false }, + }) + + const model = new FireworksHandler({ apiModelId: knownId, fireworksApiKey: "k" }).getModel() + + expect(model.info).toEqual(fireworksModels[knownId]) + }) + + it("should fall back to sane defaults when an unknown model is not in the cache", () => { + vi.mocked(getModelsFromCache).mockReturnValue({}) + + const model = new FireworksHandler({ + apiModelId: "accounts/fireworks/models/not-cached", + fireworksApiKey: "k", + }).getModel() + + expect(model.id).toBe("accounts/fireworks/models/not-cached") + // Sane defaults intentionally omit maxTokens so no output limit is sent. + expect(model.info).toEqual(openAiModelInfoSaneDefaults) + expect(model.info.maxTokens).toBeUndefined() + }) + + it("should omit max_tokens for a custom model because Fireworks rejects -1", async () => { + // Regression: custom models get sane-default metadata with maxTokens: -1, which + // Fireworks rejects with `400 max_tokens must be non-negative`. const handlerWithCustomModel = new FireworksHandler({ apiModelId: "accounts/fireworks/models/deepseek-v4p1-flash", fireworksApiKey: "test-fireworks-api-key", diff --git a/src/api/providers/fetchers/__tests__/fireworks.spec.ts b/src/api/providers/fetchers/__tests__/fireworks.spec.ts new file mode 100644 index 0000000000..c3bb4a5f21 --- /dev/null +++ b/src/api/providers/fetchers/__tests__/fireworks.spec.ts @@ -0,0 +1,157 @@ +// npx vitest run api/providers/fetchers/__tests__/fireworks.spec.ts + +import { fireworksModels } from "@roo-code/types" + +import { FIREWORKS_MODELS_URL, getFireworksModels } from "../fireworks" + +const mockFetchJson = (body: unknown) => + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify(body), { status: 200 })) + +describe("getFireworksModels", () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it("requests the OpenAI-compatible models endpoint with the API key", async () => { + const fetchSpy = mockFetchJson({ data: [] }) + + await getFireworksModels("fw-key") + + expect(fetchSpy).toHaveBeenCalledWith( + FIREWORKS_MODELS_URL, + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "Bearer fw-key" }), + }), + ) + expect(FIREWORKS_MODELS_URL).toBe("https://api.fireworks.ai/inference/v1/models") + }) + + it("omits the Authorization header when no API key is provided", async () => { + const fetchSpy = mockFetchJson({ data: [] }) + + await getFireworksModels(undefined) + + const init = fetchSpy.mock.calls[0][1] as RequestInit + expect((init.headers as Record)["Authorization"]).toBeUndefined() + }) + + it("keeps curated static specs (including pricing) for known models", async () => { + const knownId = "accounts/fireworks/models/kimi-k2p6" + mockFetchJson({ + data: [ + { + id: knownId, + supports_chat: true, + supports_image_input: false, + supports_tools: true, + context_length: 1, + }, + ], + }) + + const models = await getFireworksModels("fw-key") + + expect(models[knownId]).toEqual(fireworksModels[knownId]) + }) + + it("builds model info from API metadata for unknown models without pricing", async () => { + mockFetchJson({ + data: [ + { + id: "accounts/fireworks/models/glm-5p3", + supports_chat: true, + supports_image_input: true, + supports_tools: true, + context_length: 1_048_576, + }, + ], + }) + + const models = await getFireworksModels("fw-key") + + expect(models["accounts/fireworks/models/glm-5p3"]).toEqual({ + maxTokens: 16_384, + contextWindow: 1_048_576, + supportsImages: true, + supportsPromptCache: false, + description: "Fireworks model: accounts/fireworks/models/glm-5p3", + }) + expect(models["accounts/fireworks/models/glm-5p3"].inputPrice).toBeUndefined() + }) + + it("uses fallbacks when context length is missing and caps maxTokens to small windows", async () => { + mockFetchJson({ + data: [ + { id: "accounts/fireworks/routers/no-ctx", supports_chat: true }, + { id: "accounts/fireworks/models/tiny", supports_chat: true, context_length: 8192 }, + ], + }) + + const models = await getFireworksModels("fw-key") + + expect(models["accounts/fireworks/routers/no-ctx"]).toMatchObject({ + contextWindow: 128_000, + maxTokens: 16_384, + supportsImages: false, + }) + expect(models["accounts/fireworks/models/tiny"]).toMatchObject({ contextWindow: 8192, maxTokens: 8192 }) + }) + + it("skips non-chat models and entries without a valid id", async () => { + mockFetchJson({ + data: [ + { id: "accounts/fireworks/models/qwen3-embedding-8b", supports_chat: false }, + { id: "" }, + { id: 123 }, + null, + { id: "accounts/fireworks/models/ok", supports_chat: true, context_length: 32_000 }, + ], + }) + + const models = await getFireworksModels("fw-key") + + expect(Object.keys(models)).toEqual(["accounts/fireworks/models/ok"]) + }) + + it("throws for HTTP errors", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response('{"error":"bad key"}', { status: 401, statusText: "Unauthorized" }), + ) + vi.spyOn(console, "error").mockImplementation(() => {}) + + await expect(getFireworksModels("bad")).rejects.toThrow("HTTP 401: Unauthorized") + }) + + it("throws on unexpected response format", async () => { + mockFetchJson({ data: "nope" }) + vi.spyOn(console, "error").mockImplementation(() => {}) + + await expect(getFireworksModels("fw-key")).rejects.toThrow("Unexpected response format") + }) + + it("passes the caller's abort signal to the request", async () => { + const fetchSpy = mockFetchJson({ data: [] }) + const controller = new AbortController() + + await getFireworksModels("fw-key", { signal: controller.signal }) + + expect(fetchSpy).toHaveBeenCalledWith( + FIREWORKS_MODELS_URL, + expect.objectContaining({ signal: controller.signal }), + ) + }) + + it("rejects with the abort reason when the caller aborts a pending request", async () => { + const controller = new AbortController() + vi.spyOn(globalThis, "fetch").mockImplementation((_input, init) => { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(init.signal?.reason), { once: true }) + }) + }) + + const fetchPromise = getFireworksModels("fw-key", { signal: controller.signal }) + controller.abort(new Error("caller canceled")) + + await expect(fetchPromise).rejects.toThrow("caller canceled") + }) +}) diff --git a/src/api/providers/fetchers/__tests__/modelCache.spec.ts b/src/api/providers/fetchers/__tests__/modelCache.spec.ts index 3081b5f3eb..8f27639265 100644 --- a/src/api/providers/fetchers/__tests__/modelCache.spec.ts +++ b/src/api/providers/fetchers/__tests__/modelCache.spec.ts @@ -78,6 +78,7 @@ const LITELLM_SCOPE: ModelCacheScope = { urlScoped: true, keyScoped: true, authS const REQUESTY_SCOPE: ModelCacheScope = { urlScoped: true, keyScoped: true, authScoped: false } const NANOGPT_SCOPE: ModelCacheScope = { urlScoped: false, keyScoped: true, authScoped: false } const IOINTELLIGENCE_SCOPE: ModelCacheScope = { urlScoped: false, keyScoped: true, authScoped: false } +const FIREWORKS_SCOPE: ModelCacheScope = { urlScoped: false, keyScoped: true, authScoped: false } const ZOO_SCOPE: ModelCacheScope = { urlScoped: true, keyScoped: true, authScoped: true } const MOONSHOT_SCOPE: ModelCacheScope = { urlScoped: true, keyScoped: true, authScoped: false } @@ -289,6 +290,39 @@ describe("getModels with new GetModelsOptions", () => { expect(result).toEqual(mockModels) }) + it("dispatches Fireworks through the handler's fetchModels with the API key", async () => { + const mockModels = { + "accounts/fireworks/models/glm-5p3": { + maxTokens: 16384, + contextWindow: 1048576, + supportsPromptCache: false, + }, + } + const fetchModels = vi.fn().mockResolvedValue(mockModels) + const handler = makeHandler(FIREWORKS_SCOPE, fetchModels) + + const options = { provider: providerIdentifiers.fireworks, apiKey: "fw-key" } as const + const result = await getModels(options, handler) + + expect(fetchModels).toHaveBeenCalledWith(options, expect.any(AbortSignal)) + expect(result).toEqual(mockModels) + }) + + it("scopes the Fireworks cache per API key", async () => { + const fetchModels = vi + .fn() + .mockResolvedValueOnce({ "a/model": { contextWindow: 1, supportsPromptCache: false } }) + .mockResolvedValueOnce({ "b/model": { contextWindow: 2, supportsPromptCache: false } }) + const handler = makeHandler(FIREWORKS_SCOPE, fetchModels) + + const first = await getModels({ provider: providerIdentifiers.fireworks, apiKey: "fw-key-a" }, handler) + const second = await getModels({ provider: providerIdentifiers.fireworks, apiKey: "fw-key-b" }, handler) + + expect(fetchModels).toHaveBeenCalledTimes(2) + expect(first).toHaveProperty("a/model") + expect(second).toHaveProperty("b/model") + }) + it("rejects when the handler does not expose a dynamic model catalog", async () => { // A handler without fetchModels (e.g. a non-catalog provider) must fail with a // clear message instead of silently returning an empty catalog. diff --git a/src/api/providers/fetchers/fireworks.ts b/src/api/providers/fetchers/fireworks.ts new file mode 100644 index 0000000000..fcf0bbee3d --- /dev/null +++ b/src/api/providers/fetchers/fireworks.ts @@ -0,0 +1,112 @@ +import type { ModelInfo, ModelRecord } from "@roo-code/types" +import { fireworksModels } from "@roo-code/types" + +import { DEFAULT_HEADERS } from "../constants" +import { throwIfAborted } from "../utils/abort-signal" + +export const FIREWORKS_MODELS_URL = "https://api.fireworks.ai/inference/v1/models" + +// Fallbacks used for models that are not in the curated static map and whose +// API entry omits the corresponding field. +const FIREWORKS_FALLBACK_CONTEXT_WINDOW = 128_000 +const FIREWORKS_FALLBACK_MAX_TOKENS = 16_384 + +type FireworksApiModel = { + id?: unknown + supports_chat?: unknown + supports_image_input?: unknown + supports_tools?: unknown + context_length?: unknown +} + +const isPositiveNumber = (value: unknown): value is number => + typeof value === "number" && Number.isFinite(value) && value > 0 + +/** + * Build ModelInfo for a model that is not in the curated static map, using the + * capability fields returned by the Fireworks OpenAI-compatible /models endpoint. + * Pricing is intentionally omitted: the endpoint does not return it, and guessing + * would produce misleading cost reports. + */ +function parseFireworksModel(modelId: string, model: FireworksApiModel): ModelInfo { + const contextWindow = isPositiveNumber(model.context_length) + ? model.context_length + : FIREWORKS_FALLBACK_CONTEXT_WINDOW + + return { + maxTokens: Math.min(FIREWORKS_FALLBACK_MAX_TOKENS, contextWindow), + contextWindow, + supportsImages: model.supports_image_input === true, + supportsPromptCache: false, + description: `Fireworks model: ${modelId}`, + } +} + +/** + * Fetches the models available to the given API key from the Fireworks API. + * + * The Fireworks /inference/v1/models endpoint returns model IDs together with + * capability metadata (context length, image and tool support) but no pricing. + * Known models keep their curated static specs (including pricing); models that + * are not in the static map are built from the API metadata. Non-chat models + * (embeddings, rerankers) are skipped because they cannot back a chat task. + */ +export async function getFireworksModels(apiKey?: string, opts?: { signal?: AbortSignal }): Promise { + const headers: Record = { + "Content-Type": "application/json", + ...DEFAULT_HEADERS, + } + + if (apiKey) { + headers["Authorization"] = `Bearer ${apiKey}` + } + + const response = await fetch(FIREWORKS_MODELS_URL, { + headers, + signal: opts?.signal, + }) + + if (!response.ok) { + throwIfAborted(opts?.signal) + + let errorBody = "" + try { + errorBody = await response.text() + } catch { + errorBody = "(unable to read response body)" + } + + console.error(`[getFireworksModels] HTTP error:`, { + status: response.status, + statusText: response.statusText, + url: FIREWORKS_MODELS_URL, + body: errorBody, + }) + + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + const data = await response.json() + + if (!data?.data || !Array.isArray(data.data)) { + console.error("[getFireworksModels] Unexpected response format:", data) + throw new Error("Failed to fetch Fireworks models: Unexpected response format.") + } + + // Use null-prototype object to prevent prototype pollution + const models: ModelRecord = Object.create(null) + + for (const model of data.data as FireworksApiModel[]) { + const modelId = typeof model?.id === "string" && model.id ? model.id : null + if (!modelId) continue + + // Embedding / reranker models report supports_chat: false. + if (model.supports_chat === false) continue + + const knownSpecs = fireworksModels[modelId as keyof typeof fireworksModels] + + models[modelId] = knownSpecs ? { ...knownSpecs } : parseFireworksModel(modelId, model) + } + + return models +} diff --git a/src/api/providers/fireworks.ts b/src/api/providers/fireworks.ts index db29e7bf3f..89c9cdeae0 100644 --- a/src/api/providers/fireworks.ts +++ b/src/api/providers/fireworks.ts @@ -1,8 +1,18 @@ -import { type FireworksModelId, fireworksDefaultModelId, fireworksModels } from "@roo-code/types" +import { + type FireworksModelId, + fireworksDefaultModelId, + fireworksModels, + providerIdentifiers, + type ModelRecord, +} from "@roo-code/types" -import type { ApiHandlerOptions } from "../../shared/api" +import type { ApiHandlerOptions, GetModelsOptions } from "../../shared/api" import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" +import { getModelsFromCache } from "./fetchers/modelCache" +import type { ModelCacheScope } from "../index" + +import { getFireworksModels } from "./fetchers/fireworks" export class FireworksHandler extends BaseOpenAiCompatibleProvider { constructor(options: ApiHandlerOptions) { @@ -16,4 +26,37 @@ export class FireworksHandler extends BaseOpenAiCompatibleProvider { + const fetchOpts: [] | [{ signal: AbortSignal }] = signal ? [{ signal }] : [] + return getFireworksModels(options.apiKey, ...fetchOpts) + } } diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index d0155955d0..0e43cf12f7 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -6561,6 +6561,7 @@ describe("ClineProvider - Router Models", () => { poe: {}, deepseek: {}, moonshot: {}, + fireworks: {}, "opencode-go": mockModels, kenari: mockModels, "io-intelligence": mockModels, @@ -6619,6 +6620,7 @@ describe("ClineProvider - Router Models", () => { poe: {}, deepseek: {}, moonshot: {}, + fireworks: {}, "opencode-go": mockModels, kenari: mockModels, "io-intelligence": mockModels, @@ -6725,6 +6727,7 @@ describe("ClineProvider - Router Models", () => { poe: {}, deepseek: {}, moonshot: {}, + fireworks: {}, "opencode-go": mockModels, kenari: mockModels, "io-intelligence": mockModels, diff --git a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts index 2107a85a9c..3b9a79f0dc 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts @@ -57,9 +57,11 @@ vi.mock("vscode", () => ({ // Mock modelCache getModels/flushModels used by the handler const getModelsMock = vi.fn() const flushModelsMock = vi.fn() +const getModelsFromCacheMock = vi.fn() vi.mock("../../../api/providers/fetchers/modelCache", () => ({ getModels: (...args: any[]) => getModelsMock(...args), flushModels: (...args: any[]) => flushModelsMock(...args), + getModelsFromCache: (...args: unknown[]) => getModelsFromCacheMock(...args), })) // Real handlers eagerly fetch models in their constructors, so use lightweight doubles. @@ -112,6 +114,8 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { beforeEach(() => { vi.clearAllMocks() handlersByProvider.clear() + // No catalog is cached unless a test seeds one. + getModelsFromCacheMock.mockReturnValue(undefined) mockProvider = { // Only methods used by this code path @@ -184,6 +188,335 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { expect.objectContaining({ provider: providerIdentifiers.moonshot }), expect.anything(), ) + expect(routerModels).toHaveProperty(providerIdentifiers.fireworks) + expect(routerModels.fireworks).toEqual({}) + expect(getModelsMock).not.toHaveBeenCalledWith( + expect.objectContaining({ provider: providerIdentifiers.fireworks }), + ) + }) + + it("fetches Fireworks models with the stored API key without flushing", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { fireworksApiKey: "stored-fw-key" }, + }) + getModelsMock.mockImplementation(async (options: { provider?: string }) => + options?.provider === providerIdentifiers.fireworks + ? { "accounts/fireworks/models/glm-5p3": { contextWindow: 1_048_576, supportsPromptCache: false } } + : {}, + ) + + await webviewMessageHandler(mockProvider, { type: RouterModelsMessageType.requestRouterModels }) + + expect(getModelsMock).toHaveBeenCalledWith( + { + provider: providerIdentifiers.fireworks, + apiKey: "stored-fw-key", + }, + handlerForProvider(providerIdentifiers.fireworks), + ) + expect(flushModelsMock).not.toHaveBeenCalledWith( + expect.objectContaining({ provider: providerIdentifiers.fireworks }), + true, + ) + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ + type: RouterModelsMessageType.routerModels, + routerModels: expect.objectContaining({ + [providerIdentifiers.fireworks]: { + "accounts/fireworks/models/glm-5p3": { contextWindow: 1_048_576, supportsPromptCache: false }, + }, + }), + }), + ) + }) + + it("flushes and fetches Fireworks models with an explicit key from the refresh button", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { fireworksApiKey: "stored-fw-key" }, + }) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + values: { provider: providerIdentifiers.fireworks, fireworksApiKey: "new-fw-key" }, + }) + + const options = { provider: providerIdentifiers.fireworks, apiKey: "new-fw-key" } + const handler = handlerForProvider(providerIdentifiers.fireworks) + expect(flushModelsMock).toHaveBeenCalledWith(options, handler, true) + expect(getModelsMock).toHaveBeenCalledWith(options, handler) + expect(getModelsMock).not.toHaveBeenCalledWith(expect.objectContaining({ apiKey: "stored-fw-key" })) + }) + + it("echoes the request identifier on Fireworks router-model responses", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { fireworksApiKey: "stored-fw-key" }, + }) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + requestId: "fireworks-router-models-7", + values: { provider: providerIdentifiers.fireworks, fireworksApiKey: "new-fw-key" }, + }) + + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ + type: RouterModelsMessageType.routerModels, + requestId: "fireworks-router-models-7", + }), + ) + }) + + it("echoes the request identifier on Fireworks failure responses", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { fireworksApiKey: "stored-fw-key" }, + }) + getModelsMock.mockImplementation(async (options: { provider?: string }) => { + if (options?.provider === providerIdentifiers.fireworks) { + throw new Error("HTTP 401: Unauthorized") + } + return {} + }) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + requestId: "fireworks-router-models-8", + values: { provider: providerIdentifiers.fireworks, fireworksApiKey: "bad-fw-key" }, + }) + + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ + type: RouterModelsMessageType.singleRouterModelFetchResponse, + success: false, + requestId: "fireworks-router-models-8", + values: { provider: providerIdentifiers.fireworks }, + }), + ) + }) + + it("does not publish a superseded Fireworks request's catalog", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { fireworksApiKey: "stored-fw-key" }, + }) + + const catalogs: Record> = { + "old-fw-key": { + "accounts/fireworks/models/old-key-model": { contextWindow: 8192, supportsPromptCache: false }, + }, + "new-fw-key": { + "accounts/fireworks/models/new-key-model": { contextWindow: 8192, supportsPromptCache: false }, + }, + } + + let releaseOldRequest: (() => void) | undefined + const oldRequestGate = new Promise((resolve) => { + releaseOldRequest = resolve + }) + + getModelsMock.mockImplementation(async (options: { provider?: string; apiKey?: string }) => { + if (options?.provider !== providerIdentifiers.fireworks) { + return {} + } + + // The older request finishes only after the newer one has already published. + if (options.apiKey === "old-fw-key") { + await oldRequestGate + } + + return catalogs[options.apiKey ?? ""] + }) + + const oldRequest = webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + requestId: "fireworks-router-models-1", + values: { provider: providerIdentifiers.fireworks, fireworksApiKey: "old-fw-key" }, + }) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + requestId: "fireworks-router-models-2", + values: { provider: providerIdentifiers.fireworks, fireworksApiKey: "new-fw-key" }, + }) + + releaseOldRequest?.() + await oldRequest + + const published = mockProvider.postMessageToWebview.mock.calls + .map((call) => call[0]) + .filter((posted) => posted?.type === RouterModelsMessageType.routerModels) + + expect(published).toHaveLength(1) + expect(published[0]).toEqual( + expect.objectContaining({ + requestId: "fireworks-router-models-2", + routerModels: { [providerIdentifiers.fireworks]: catalogs["new-fw-key"] }, + }), + ) + }) + + it("orders requests by arrival, not by when getState resolves", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { fireworksApiKey: "stored-fw-key" }, + }) + + const catalogs: Record> = { + "old-fw-key": { + "accounts/fireworks/models/old-key-model": { contextWindow: 8192, supportsPromptCache: false }, + }, + "new-fw-key": { + "accounts/fireworks/models/new-key-model": { contextWindow: 8192, supportsPromptCache: false }, + }, + } + + let releaseOldState: (() => void) | undefined + const oldStateGate = new Promise((resolve) => { + releaseOldState = resolve + }) + mockProvider.getState.mockImplementation(async () => { + // The older request reaches `getState` first but resolves it last. + await oldStateGate + return { apiConfiguration: { fireworksApiKey: "stored-fw-key" } } + }) + + getModelsMock.mockImplementation(async (options: { provider?: string; apiKey?: string }) => + options?.provider === providerIdentifiers.fireworks ? catalogs[options.apiKey ?? ""] : {}, + ) + + const oldRequest = webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + requestId: "fireworks-router-models-1", + values: { provider: providerIdentifiers.fireworks, fireworksApiKey: "old-fw-key" }, + }) + + // The newer request arrives second and resolves `getState` first. + mockProvider.getState.mockResolvedValue({ apiConfiguration: { fireworksApiKey: "stored-fw-key" } }) + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + requestId: "fireworks-router-models-2", + values: { provider: providerIdentifiers.fireworks, fireworksApiKey: "new-fw-key" }, + }) + + releaseOldState?.() + await oldRequest + + const published = mockProvider.postMessageToWebview.mock.calls + .map((call) => call[0]) + .filter((posted) => posted?.type === RouterModelsMessageType.routerModels) + + // The newer request owns the catalog even though the older one recorded later. + expect(published).toHaveLength(1) + expect(published[0]).toEqual( + expect.objectContaining({ + requestId: "fireworks-router-models-2", + routerModels: { [providerIdentifiers.fireworks]: catalogs["new-fw-key"] }, + }), + ) + }) + + it("does not let an anonymous aggregate response replace a newer Fireworks catalog", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { fireworksApiKey: "stored-fw-key" }, + }) + + let releaseAggregate: (() => void) | undefined + const aggregateGate = new Promise((resolve) => { + releaseAggregate = resolve + }) + + getModelsMock.mockImplementation(async (options: { provider?: string; apiKey?: string }) => { + if (options?.provider === providerIdentifiers.fireworks) { + return { "accounts/fireworks/models/fresh": { contextWindow: 8192, supportsPromptCache: false } } + } + + // The anonymous aggregate request is still fetching while the newer one publishes. + await aggregateGate + return { "openrouter/stale": { contextWindow: 8192, supportsPromptCache: false } } + }) + + const aggregateRequest = webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + }) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + requestId: "fireworks-router-models-2", + values: { provider: providerIdentifiers.fireworks, fireworksApiKey: "edited-fw-key" }, + }) + + releaseAggregate?.() + await aggregateRequest + + const published = mockProvider.postMessageToWebview.mock.calls + .map((call) => call[0]) + .filter((posted) => posted?.type === RouterModelsMessageType.routerModels) + + // The stale aggregate response must not publish at all: it carries a Fireworks entry + // that would replace the newer catalog in extension state and the query cache. + expect(published).toHaveLength(1) + expect(published[0].requestId).toBe("fireworks-router-models-2") + }) + + it("keeps the same key's cached catalog when a Fireworks refresh fails", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { fireworksApiKey: "stored-fw-key" }, + }) + getModelsMock.mockImplementation(async (options: { provider?: string }) => { + if (options?.provider === providerIdentifiers.fireworks) { + throw new Error("HTTP 500: Internal Server Error") + } + return {} + }) + const cachedCatalog = { + "accounts/fireworks/models/cached": { contextWindow: 8192, supportsPromptCache: false }, + } + getModelsFromCacheMock.mockReturnValue(cachedCatalog) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + requestId: "fireworks-router-models-3", + values: { provider: providerIdentifiers.fireworks, fireworksApiKey: "stored-fw-key" }, + }) + + expect(getModelsFromCacheMock).toHaveBeenCalledWith( + { provider: providerIdentifiers.fireworks, apiKey: "stored-fw-key" }, + handlerForProvider(providerIdentifiers.fireworks), + ) + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ + type: RouterModelsMessageType.routerModels, + routerModels: { [providerIdentifiers.fireworks]: cachedCatalog }, + }), + ) + }) + + it("clears the catalog when a Fireworks refresh fails for a different key", async () => { + mockProvider.getState.mockResolvedValue({ + apiConfiguration: { fireworksApiKey: "stored-fw-key" }, + }) + getModelsMock.mockImplementation(async (options: { provider?: string }) => { + if (options?.provider === providerIdentifiers.fireworks) { + throw new Error("HTTP 401: Unauthorized") + } + return {} + }) + // Only the saved key has a cached catalog; the edited key has none. + getModelsFromCacheMock.mockImplementation((options: { apiKey?: string }) => + options.apiKey === "stored-fw-key" + ? { "accounts/fireworks/models/stored": { contextWindow: 8192, supportsPromptCache: false } } + : undefined, + ) + + await webviewMessageHandler(mockProvider, { + type: RouterModelsMessageType.requestRouterModels, + requestId: "fireworks-router-models-4", + values: { provider: providerIdentifiers.fireworks, fireworksApiKey: "edited-fw-key" }, + }) + + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith( + expect.objectContaining({ + type: RouterModelsMessageType.routerModels, + routerModels: { [providerIdentifiers.fireworks]: {} }, + }), + ) }) it("fetches DeepSeek models when stored DeepSeek credentials exist", async () => { diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 685a7b11c1..4dd5e59c19 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -1206,6 +1206,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { poe: {}, deepseek: {}, moonshot: {}, + fireworks: {}, "opencode-go": mockModels, kenari: mockModels, "io-intelligence": mockModels, @@ -1481,6 +1482,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { poe: {}, deepseek: {}, moonshot: {}, + fireworks: {}, "opencode-go": mockModels, kenari: mockModels, "io-intelligence": mockModels, @@ -1548,6 +1550,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { poe: {}, deepseek: {}, moonshot: {}, + fireworks: {}, "opencode-go": mockModels, kenari: mockModels, "io-intelligence": mockModels, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 231acdd448..7f44cb8413 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -24,6 +24,7 @@ import { getCompletionCheckpoint, providerIdentifiers, retiredProviderIdentifiers, + allRouterModelsProvider, LmStudioModelsMessageType, OllamaModelsMessageType, OpenAiModelsMessageType, @@ -94,7 +95,7 @@ import { getWorkspacePath } from "../../utils/path" import { isPathOutsideWorkspace, decodeUntrustedPathToStable, isRealPathOutsideWorkspace } from "../../utils/pathUtils" import { Mode, defaultModeSlug } from "../../shared/modes" import { buildApiHandler, type ApiHandler } from "../../api" -import { getModels, flushModels } from "../../api/providers/fetchers/modelCache" +import { getModels, getModelsFromCache, flushModels } from "../../api/providers/fetchers/modelCache" import { GetModelsOptions } from "../../shared/api" import { generateSystemPrompt } from "./generateSystemPrompt" import { resolveDefaultSaveUri, saveLastExportPath } from "../../utils/export" @@ -130,6 +131,80 @@ import { const handlerFor = (apiConfiguration: ProviderSettings, provider: RouterName): ApiHandler => buildApiHandler({ ...apiConfiguration, apiProvider: provider }) +// Router-model requests per provider, ordered by arrival. A caller that supplies a request +// identifier (the Fireworks settings page does) can have several requests in flight at once; +// publishing a superseded one would replace the newer catalog in every webview consumer, +// including the extension-state copy the model picker renders. Anonymous requests are ordered +// too, so a stale aggregate response cannot replace a catalog that a newer request published. +type RouterModelRequest = { + /** Arrival order across every request for this provider. */ + sequence: number + /** Caller-supplied identifier, absent for requests that cannot be attributed. */ + requestId?: string +} + +type RouterModelRequestLog = { + /** Arrival order of the newest request per key, identified or not. */ + latestSequenceByKey: Map + /** Identifier of the newest identified request per key. */ + latestIdentifiedByKey: Map +} + +const routerModelRequestLogs = new WeakMap() +let routerModelRequestSequence = 0 + +const recordRouterModelRequest = ( + provider: object, + requestKey: string, + requestId: string | undefined, +): RouterModelRequest => { + let log = routerModelRequestLogs.get(provider) + if (!log) { + log = { latestSequenceByKey: new Map(), latestIdentifiedByKey: new Map() } + routerModelRequestLogs.set(provider, log) + } + + const request = { sequence: ++routerModelRequestSequence, requestId } + log.latestSequenceByKey.set(requestKey, request.sequence) + if (requestId !== undefined) { + log.latestIdentifiedByKey.set(requestKey, requestId) + } + + return request +} + +// An aggregate request carries every provider, so it overlaps every provider-scoped request. +const routerModelRequestKeysOverlap = (a: string, b: string) => + a === b || a === allRouterModelsProvider || b === allRouterModelsProvider + +/** + * True when a request that arrived after `request` owns the same catalog. + * + * An identified request is superseded only by a newer identified request for its own key: its + * caller waits for that identifier, so suppressing it on behalf of an anonymous request would + * leave the caller waiting forever. An anonymous response has no caller waiting on it, so any + * newer overlapping request owns the catalog. + */ +const isSupersededRouterModelRequest = (provider: object, requestKey: string, request: RouterModelRequest): boolean => { + const log = routerModelRequestLogs.get(provider) + if (!log) { + return false + } + + if (request.requestId !== undefined) { + const latestIdentified = log.latestIdentifiedByKey.get(requestKey) + return latestIdentified !== undefined && latestIdentified !== request.requestId + } + + for (const [key, sequence] of log.latestSequenceByKey) { + if (sequence > request.sequence && routerModelRequestKeysOverlap(key, requestKey)) { + return true + } + } + + return false +} + export const webviewMessageHandler = async ( provider: ClineProvider, message: WebviewMessage, @@ -1134,12 +1209,18 @@ export const webviewMessageHandler = async ( break } case RouterModelsMessageType.requestRouterModels: { - const { apiConfiguration } = await provider.getState() - // Optional single provider filter from webview const requestedProvider = message?.values?.provider const providerFilter = requestedProvider ? toRouterName(requestedProvider) : undefined + // Record the request before the first await so arrival order decides which request + // owns the catalog. Recording after `getState()` would let a slower, older request + // claim the newest slot and publish its stale catalog over the newer one. + const requestKey = providerFilter ?? allRouterModelsProvider + const routerModelRequest = recordRouterModelRequest(provider, requestKey, message.requestId) + + const { apiConfiguration } = await provider.getState() + // Optional refresh flag to flush cache before fetching (useful for providers requiring credentials) const shouldRefresh = message?.values?.refresh === true @@ -1157,6 +1238,7 @@ export const webviewMessageHandler = async ( [providerIdentifiers.poe]: {}, [providerIdentifiers.deepseek]: {}, [providerIdentifiers.moonshot]: {}, + [providerIdentifiers.fireworks]: {}, [providerIdentifiers.opencodeGo]: {}, [providerIdentifiers.kenari]: {}, [providerIdentifiers.ioIntelligence]: {}, @@ -1300,6 +1382,25 @@ export const webviewMessageHandler = async ( }) } + // Fireworks is conditional on apiKey (its /models endpoint requires auth and the + // returned list is scoped to the account). + const fireworksApiKey = message?.values?.fireworksApiKey ?? apiConfiguration.fireworksApiKey + + if (fireworksApiKey) { + if (message?.values?.fireworksApiKey) { + await flushModels( + { provider: providerIdentifiers.fireworks, apiKey: fireworksApiKey }, + handlerFor(apiConfiguration, providerIdentifiers.fireworks), + true, + ) + } + + candidates.push({ + key: providerIdentifiers.fireworks, + options: { provider: providerIdentifiers.fireworks, apiKey: fireworksApiKey }, + }) + } + // Opencode Go's /models endpoint is public — it returns the full model list with no // Authorization header — so it's fetched unconditionally like openrouter/vercel-ai-gateway // above. Gating it behind a key meant the picker stayed empty (and fell back to the default @@ -1421,22 +1522,39 @@ export const webviewMessageHandler = async ( const errorMessage = result.reason instanceof Error ? result.reason.message : String(result.reason) console.error(`Error fetching models for ${routerName}:`, result.reason) - routerModels[routerName] = {} // Ensure it's an empty object in the main routerModels message. + // Keep the catalog the same key already loaded: a transient failure must not + // empty the picker. A different key must clear it, because the previous + // account's models may not be usable with the edited key. + const failedOptions = modelFetchPromises[index].options + const retainedModels = getModelsFromCache(failedOptions, handlerFor(apiConfiguration, routerName)) - void provider.postMessageToWebview({ - type: RouterModelsMessageType.singleRouterModelFetchResponse, - success: false, - error: errorMessage, - values: { provider: routerName }, - }) + routerModels[routerName] = retainedModels ?? {} + + if (!isSupersededRouterModelRequest(provider, requestKey, routerModelRequest)) { + void provider.postMessageToWebview({ + type: RouterModelsMessageType.singleRouterModelFetchResponse, + success: false, + error: errorMessage, + values: { provider: routerName }, + // Echo the caller's identifier so it can discard responses from + // requests it has already superseded. + requestId: message.requestId, + }) + } } }) - await provider.postMessageToWebview({ - type: RouterModelsMessageType.routerModels, - routerModels, - values: providerFilter ? { provider: requestedProvider } : undefined, - }) + // A superseded request must not publish: its catalog belongs to an older key and + // would replace the newer one in extension state and the query cache. + if (!isSupersededRouterModelRequest(provider, requestKey, routerModelRequest)) { + await provider.postMessageToWebview({ + type: RouterModelsMessageType.routerModels, + routerModels, + values: providerFilter ? { provider: requestedProvider } : undefined, + // Echo the caller's identifier so overlapping requests can be told apart. + requestId: message.requestId, + }) + } break } case OllamaModelsMessageType.requestOllamaModels: { diff --git a/src/shared/api.ts b/src/shared/api.ts index 065d32a7ea..ec40431f68 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -184,6 +184,7 @@ const _dynamicProviderExtras = { [providerIdentifiers.poe]: {} as { apiKey?: string; baseUrl?: string }, [providerIdentifiers.deepseek]: {} as { apiKey?: string; baseUrl?: string }, [providerIdentifiers.moonshot]: {} as { apiKey?: string; baseUrl?: string }, + [providerIdentifiers.fireworks]: {} as { apiKey?: string }, [providerIdentifiers.opencodeGo]: {} as { apiKey?: string }, [providerIdentifiers.kenari]: {} as { apiKey?: string }, [providerIdentifiers.ioIntelligence]: {} as { apiKey?: string }, diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 6e22859618..09aaae00c6 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -746,6 +746,9 @@ const ApiOptions = ({ )} diff --git a/webview-ui/src/components/settings/providers/Fireworks.tsx b/webview-ui/src/components/settings/providers/Fireworks.tsx index 39dbbd29ff..45898875a4 100644 --- a/webview-ui/src/components/settings/providers/Fireworks.tsx +++ b/webview-ui/src/components/settings/providers/Fireworks.tsx @@ -1,20 +1,191 @@ -import { useCallback } from "react" +import { useCallback, useState, useEffect, useRef } from "react" import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" +import { useQueryClient } from "@tanstack/react-query" +import { useDebounce } from "react-use" -import type { ProviderSettings } from "@roo-code/types" +import { + type OrganizationAllowList, + type ProviderSettings, + type ExtensionMessage, + type RouterModels, + fireworksDefaultModelId, + fireworksModels, + providerIdentifiers, + allRouterModelsProvider, + RouterModelsMessageType, +} from "@roo-code/types" + +import { RouterName } from "@roo/api" import { useAppTranslation } from "@src/i18n/TranslationContext" +import { useExtensionState } from "@src/context/ExtensionStateContext" import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" +import { vscode } from "@src/utils/vscode" +import { Button } from "@src/components/ui" +import { ModelPicker } from "../ModelPicker" +import { handleModelChangeSideEffects } from "../utils/providerModelConfig" import { inputEventTransform } from "../transforms" type FireworksProps = { apiConfiguration: ProviderSettings setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void + organizationAllowList?: OrganizationAllowList + modelValidationError?: string + simplifySettings?: boolean +} + +enum RefreshStatus { + Idle = "idle", + Loading = "loading", + Success = "success", + Error = "error", +} + +// Module-scoped so identifiers stay unique across component instances: a remount must not +// reuse an identifier that an in-flight request from the previous instance still carries. +let fireworksRouterModelsRequestCount = 0 + +const nextFireworksRouterModelsRequestId = () => { + fireworksRouterModelsRequestCount += 1 + return `fireworks-router-models-${fireworksRouterModelsRequestCount}` } -export const Fireworks = ({ apiConfiguration, setApiConfigurationField }: FireworksProps) => { +export const Fireworks = ({ + apiConfiguration, + setApiConfigurationField, + organizationAllowList, + modelValidationError, + simplifySettings, +}: FireworksProps) => { const { t } = useAppTranslation() + const { routerModels } = useExtensionState() + const queryClient = useQueryClient() + const [refreshStatus, setRefreshStatus] = useState(RefreshStatus.Idle) + const [refreshError, setRefreshError] = useState() + const fireworksApiKey = apiConfiguration.fireworksApiKey + const fireworksErrorJustReceived = useRef(false) + // Identifies the Fireworks request this component is waiting on. Responses echo it so an + // older request that finishes last cannot overwrite the catalog of the newer key. + const currentRequestId = useRef(undefined) + // The key the last accepted catalog was fetched with. A failed refresh may keep that + // catalog only while the key is unchanged; a different key must clear it. + const catalogApiKey = useRef(undefined) + + const nextRequestId = useCallback(() => { + const requestId = nextFireworksRouterModelsRequestId() + currentRequestId.current = requestId + return requestId + }, []) + + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const message = event.data + // A response without an identifier cannot be attributed to this component's + // request, and a mismatched one belongs to a superseded request. + const isCurrentRequest = message.requestId !== undefined && message.requestId === currentRequestId.current + + if (message.type === RouterModelsMessageType.singleRouterModelFetchResponse && !message.success) { + const providerName = message.values?.provider as RouterName + if ( + providerName === providerIdentifiers.fireworks && + refreshStatus === RefreshStatus.Loading && + isCurrentRequest + ) { + fireworksErrorJustReceived.current = true + setRefreshStatus(RefreshStatus.Error) + setRefreshError(message.error) + } + } else if (message.type === RouterModelsMessageType.routerModels) { + // Aggregate responses carry no provider and may belong to an unrelated + // request, so only a Fireworks-scoped response can complete this refresh. + if ((message.values?.provider as RouterName | undefined) !== providerIdentifiers.fireworks) { + return + } + + // Two key-specific requests can overlap. Accepting an older response that + // arrives last would replace the newer key's catalog, leaving the picker + // listing models the saved key cannot use. + if (!isCurrentRequest) { + return + } + + const incoming = message.routerModels + + if (incoming) { + // A failed fetch publishes an empty catalog. Keep the last catalog when it was + // loaded with the same key, so a transient failure does not empty the picker. + // A different key must clear it: the previous account's models may not be + // usable with the edited key. + const sameKeyCatalog = catalogApiKey.current === fireworksApiKey + const models = incoming[providerIdentifiers.fireworks] + const catalog = models && Object.keys(models).length > 0 ? models : sameKeyCatalog ? undefined : {} + + if (catalog) { + catalogApiKey.current = fireworksApiKey + // Write the explicit-key catalog straight into the query cache. Invalidating + // these keys would refetch with the saved key and could replace the catalog + // of the account the user just refreshed. + queryClient.setQueryData( + [RouterModelsMessageType.routerModels, providerIdentifiers.fireworks], + (current) => + current ? { ...current, [providerIdentifiers.fireworks]: catalog } : incoming, + ) + // The aggregate entry holds every provider, so only merge into an existing + // one: creating it from this partial payload would hide the other providers. + queryClient.setQueryData( + [RouterModelsMessageType.routerModels, allRouterModelsProvider], + (current) => + current ? { ...current, [providerIdentifiers.fireworks]: catalog } : undefined, + ) + } + } + + if (refreshStatus === RefreshStatus.Loading && !fireworksErrorJustReceived.current) { + setRefreshStatus(RefreshStatus.Success) + } + } + } + + window.addEventListener("message", handleMessage) + return () => { + window.removeEventListener("message", handleMessage) + } + }, [refreshStatus, queryClient, fireworksApiKey]) + + // A profile switch leaves the previous key's catalog in extension state. Request the + // catalog with the new key so the picker cannot show or persist models that key cannot + // use. The initial mount is skipped: the shared router-model query already loads the + // saved key's catalog, and re-requesting here would flush that cache on every mount. + // Debounced so typing a key does not fire one request per keystroke. + const previousApiKey = useRef(fireworksApiKey) + + useDebounce( + () => { + const keyChanged = previousApiKey.current !== fireworksApiKey + previousApiKey.current = fireworksApiKey + + if (!keyChanged || !fireworksApiKey) { + return + } + + // The key-change request supersedes any refresh in flight, so that refresh's + // response is discarded. Restart the status for the new request: a stale error + // would otherwise stay visible, and a superseded refresh would leave the status + // stuck on loading. + fireworksErrorJustReceived.current = false + setRefreshStatus(RefreshStatus.Loading) + setRefreshError(undefined) + + vscode.postMessage({ + type: RouterModelsMessageType.requestRouterModels, + requestId: nextRequestId(), + values: { provider: providerIdentifiers.fireworks, fireworksApiKey }, + }) + }, + 250, + [fireworksApiKey, nextRequestId], + ) const handleInputChange = useCallback( ( @@ -27,6 +198,34 @@ export const Fireworks = ({ apiConfiguration, setApiConfigurationField }: Firewo [setApiConfigurationField], ) + const handleRefreshModels = useCallback(() => { + fireworksErrorJustReceived.current = false + setRefreshStatus(RefreshStatus.Loading) + setRefreshError(undefined) + + const key = apiConfiguration.fireworksApiKey + + if (!key) { + setRefreshStatus(RefreshStatus.Error) + setRefreshError(t("settings:providers.refreshModels.missingConfig")) + return + } + + vscode.postMessage({ + type: RouterModelsMessageType.requestRouterModels, + requestId: nextRequestId(), + values: { + provider: providerIdentifiers.fireworks, + fireworksApiKey: key, + }, + }) + }, [apiConfiguration, nextRequestId, t]) + + // Until the API list has been fetched (e.g. no key yet, or fetch failed), fall + // back to the curated static list so the picker is never empty. + const fetchedModels = routerModels?.[providerIdentifiers.fireworks] + const models = fetchedModels && Object.keys(fetchedModels).length > 0 ? fetchedModels : fireworksModels + return ( <> )} + + handleModelChangeSideEffects(providerIdentifiers.fireworks, modelId, setApiConfigurationField) + } + /> + + {refreshStatus === RefreshStatus.Loading && ( +
+ {t("settings:providers.refreshModels.loading")} +
+ )} + {refreshStatus === RefreshStatus.Success && ( +
{t("settings:providers.refreshModels.success")}
+ )} + {refreshStatus === RefreshStatus.Error && ( +
+ {refreshError || t("settings:providers.refreshModels.error")} +
+ )} ) } diff --git a/webview-ui/src/components/settings/providers/__tests__/Fireworks.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/Fireworks.spec.tsx new file mode 100644 index 0000000000..955678f37d --- /dev/null +++ b/webview-ui/src/components/settings/providers/__tests__/Fireworks.spec.tsx @@ -0,0 +1,715 @@ +// npx vitest src/components/settings/providers/__tests__/Fireworks.spec.tsx + +import React from "react" +import { + act, + createTestQueryClient, + fireEvent, + render, + renderWithExtensionState, + screen, + waitFor, +} from "@/utils/test-utils" +import { + allRouterModelsProvider, + fireworksDefaultModelId, + fireworksModels, + providerIdentifiers, + type ModelInfo, + type ProviderSettings, +} from "@roo-code/types" + +import { Fireworks } from "../Fireworks" + +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock("@vscode/webview-ui-toolkit/react", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + VSCodeTextField: ({ children, value, onInput, type, placeholder }: any) => ( +
+ {children} + onInput?.(e)} + placeholder={placeholder} + data-testid="fireworks-api-key-input" + /> +
+ ), + } +}) + +vi.mock("../../ModelPicker", () => ({ + ModelPicker: function MockModelPicker({ + models, + defaultModelId, + onModelChange, + organizationAllowList, + errorMessage, + }: { + models: Record + defaultModelId: string + onModelChange?: (modelId: string) => void + organizationAllowList?: unknown + errorMessage?: string + }) { + return React.createElement( + "div", + { "data-testid": "model-picker" }, + React.createElement("span", { "data-testid": "model-picker-default" }, defaultModelId), + React.createElement("span", { "data-testid": "model-picker-ids" }, Object.keys(models).join(",")), + React.createElement( + "span", + { "data-testid": "model-picker-allow-list" }, + organizationAllowList ? "allow-list" : "no-allow-list", + ), + React.createElement("span", { "data-testid": "model-picker-error" }, errorMessage ?? ""), + React.createElement( + "button", + { + "data-testid": "change-model", + onClick: () => onModelChange?.("accounts/fireworks/models/glm-5p3"), + }, + "Change model", + ), + ) + }, +})) + +vi.mock("@src/utils/vscode", () => { + const mockPostMessage = vi.fn() + return { + vscode: { postMessage: mockPostMessage }, + } +}) + +vi.mock("@src/context/ExtensionStateContext", async (importOriginal) => ({ + ...(await importOriginal()), + useExtensionState: vi.fn(), +})) + +vi.mock("@src/components/ui", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + Button: ({ children, onClick, disabled, variant }: any) => ( + + ), + } +}) + +vi.mock("@src/components/common/VSCodeButtonLink", () => ({ + VSCodeButtonLink: ({ href, children }: any) => ( + + {children} + + ), +})) + +import { useExtensionState } from "@src/context/ExtensionStateContext" +import { vscode } from "@src/utils/vscode" + +const mockUseExtensionState = useExtensionState as ReturnType + +const getRefreshButton = () => + screen.getAllByTestId("button").find((button) => button.getAttribute("data-variant") === "outline")! + +/** The identifier echoed by the extension on the response to the most recent request. */ +const getLastRequestId = () => { + const calls = vi.mocked(vscode.postMessage).mock.calls + const last = calls[calls.length - 1]?.[0] as { requestId?: string } | undefined + return last?.requestId +} + +describe("Fireworks Component", () => { + const mockSetApiConfigurationField = vi.fn() + + const createApiConfiguration = (overrides?: Partial): ProviderSettings => ({ + apiProvider: providerIdentifiers.fireworks, + ...overrides, + }) + + beforeEach(() => { + vi.clearAllMocks() + mockUseExtensionState.mockReturnValue({ routerModels: {} }) + }) + + it("shows API-fetched models in the picker when available", () => { + mockUseExtensionState.mockReturnValue({ + routerModels: { + [providerIdentifiers.fireworks]: { + "accounts/fireworks/models/glm-5p3": { contextWindow: 1_048_576, supportsPromptCache: false }, + }, + }, + }) + + render( + , + ) + + expect(screen.getByTestId("model-picker-ids")).toHaveTextContent("accounts/fireworks/models/glm-5p3") + expect(screen.getByTestId("model-picker-default")).toHaveTextContent(fireworksDefaultModelId) + }) + + it("falls back to the static model list when nothing has been fetched", () => { + render( + , + ) + + expect(screen.getByTestId("model-picker-ids")).toHaveTextContent(Object.keys(fireworksModels).join(",")) + }) + + it("disables refresh without an API key and shows the get-key link", () => { + render( + , + ) + + expect(getRefreshButton()).toBeDisabled() + expect(screen.getByTestId("vscode-button-link")).toBeInTheDocument() + }) + + it("sends a Fireworks-scoped refresh request with the current key", () => { + render( + , + ) + + expect(screen.queryByTestId("vscode-button-link")).not.toBeInTheDocument() + fireEvent.click(getRefreshButton()) + + expect(getRefreshButton()).toBeDisabled() + expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument() + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "requestRouterModels", + requestId: expect.any(String), + values: { provider: providerIdentifiers.fireworks, fireworksApiKey: "fw-key" }, + }) + }) + + it("shows success and seeds the Fireworks + shared router-model caches from the response", async () => { + const queryClient = createTestQueryClient() + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries") + const setQueryData = vi.spyOn(queryClient, "setQueryData") + const refreshedModels = { + "accounts/fireworks/models/glm-5p3": { contextWindow: 1_048_576, supportsPromptCache: false }, + } + + renderWithExtensionState( + , + { queryClient }, + ) + + fireEvent.click(getRefreshButton()) + const requestId = getLastRequestId() + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "routerModels", + requestId, + routerModels: { [providerIdentifiers.fireworks]: refreshedModels }, + values: { provider: providerIdentifiers.fireworks }, + }, + }), + ) + }) + + await waitFor(() => { + expect(screen.getByText("settings:providers.refreshModels.success")).toBeInTheDocument() + // Seeding avoids a refetch that would use the saved key instead of the edited one. + expect(setQueryData).toHaveBeenCalledWith( + ["routerModels", providerIdentifiers.fireworks], + expect.any(Function), + ) + expect(setQueryData).toHaveBeenCalledWith(["routerModels", allRouterModelsProvider], expect.any(Function)) + expect(invalidateQueries).not.toHaveBeenCalled() + }) + }) + + it("ignores a superseded request's response so a stale catalog cannot replace the newer one", async () => { + const queryClient = createTestQueryClient() + const setQueryData = vi.spyOn(queryClient, "setQueryData") + + const { rerender } = renderWithExtensionState( + , + { queryClient }, + ) + + fireEvent.click(getRefreshButton()) + const staleRequestId = getLastRequestId() + + // A key edit starts a second request while the first is still in flight. + rerender( + , + ) + await waitFor(() => + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "requestRouterModels", + values: { provider: providerIdentifiers.fireworks, fireworksApiKey: "new-key" }, + }), + ), + ) + const currentRequestId = getLastRequestId() + expect(currentRequestId).not.toBe(staleRequestId) + + // The older request finishes last: its catalog belongs to the previous key. + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "routerModels", + requestId: staleRequestId, + routerModels: { [providerIdentifiers.fireworks]: { "accounts/fireworks/models/stale": {} } }, + values: { provider: providerIdentifiers.fireworks }, + }, + }), + ) + }) + + expect(setQueryData).not.toHaveBeenCalled() + expect(screen.queryByText("settings:providers.refreshModels.success")).not.toBeInTheDocument() + + // The response to the current request is still accepted. + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "routerModels", + requestId: currentRequestId, + routerModels: { [providerIdentifiers.fireworks]: { "accounts/fireworks/models/fresh": {} } }, + values: { provider: providerIdentifiers.fireworks }, + }, + }), + ) + }) + + await waitFor(() => { + expect(screen.getByText("settings:providers.refreshModels.success")).toBeInTheDocument() + expect(setQueryData).toHaveBeenCalledWith( + ["routerModels", providerIdentifiers.fireworks], + expect.any(Function), + ) + }) + }) + + it("ignores a Fireworks-scoped response that carries no request identifier", async () => { + const queryClient = createTestQueryClient() + const setQueryData = vi.spyOn(queryClient, "setQueryData") + + renderWithExtensionState( + , + { queryClient }, + ) + + fireEvent.click(getRefreshButton()) + await waitFor(() => expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument()) + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "routerModels", + routerModels: { [providerIdentifiers.fireworks]: { "accounts/fireworks/models/other": {} } }, + values: { provider: providerIdentifiers.fireworks }, + }, + }), + ) + }) + + expect(setQueryData).not.toHaveBeenCalled() + expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument() + }) + + it("does not create the aggregate cache entry from a Fireworks-only payload", async () => { + const queryClient = createTestQueryClient() + const setQueryData = vi.spyOn(queryClient, "setQueryData") + + renderWithExtensionState( + , + { queryClient }, + ) + + fireEvent.click(getRefreshButton()) + const requestId = getLastRequestId() + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "routerModels", + requestId, + routerModels: { [providerIdentifiers.fireworks]: {} }, + values: { provider: providerIdentifiers.fireworks }, + }, + }), + ) + }) + + await waitFor(() => expect(setQueryData).toHaveBeenCalled()) + // The updater must leave an absent aggregate entry absent rather than seeding it + // with a payload that only contains Fireworks. + const aggregateCall = setQueryData.mock.calls.find( + ([key]) => Array.isArray(key) && key[1] === allRouterModelsProvider, + ) + expect(aggregateCall).toBeDefined() + const updater = aggregateCall![1] as (current: unknown) => unknown + expect(updater(undefined)).toBeUndefined() + }) + + it("ignores an aggregate routerModels response while loading", async () => { + render( + , + ) + + fireEvent.click(getRefreshButton()) + await waitFor(() => expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument()) + + act(() => { + window.dispatchEvent(new MessageEvent("message", { data: { type: "routerModels" } })) + }) + + expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument() + expect(screen.queryByText("settings:providers.refreshModels.success")).not.toBeInTheDocument() + }) + + it("shows the error and does not flip to success when a Fireworks routerModels response arrives afterwards", async () => { + render( + , + ) + + fireEvent.click(getRefreshButton()) + await waitFor(() => expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument()) + const requestId = getLastRequestId() + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "singleRouterModelFetchResponse", + requestId, + success: false, + error: "HTTP 401: Unauthorized", + values: { provider: providerIdentifiers.fireworks }, + }, + }), + ) + }) + await waitFor(() => expect(screen.getByText("HTTP 401: Unauthorized")).toBeInTheDocument()) + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "routerModels", + requestId, + routerModels: { [providerIdentifiers.fireworks]: {} }, + values: { provider: providerIdentifiers.fireworks }, + }, + }), + ) + }) + + expect(screen.getByText("HTTP 401: Unauthorized")).toBeInTheDocument() + expect(screen.queryByText("settings:providers.refreshModels.success")).not.toBeInTheDocument() + }) + + it("ignores another provider's failure while loading", async () => { + render( + , + ) + + fireEvent.click(getRefreshButton()) + await waitFor(() => expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument()) + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "singleRouterModelFetchResponse", + success: false, + error: "OpenRouter unavailable", + values: { provider: providerIdentifiers.openrouter }, + }, + }), + ) + }) + + expect(screen.queryByText("OpenRouter unavailable")).not.toBeInTheDocument() + expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument() + }) + + it("keeps the last catalog when a refresh fails for the same key", async () => { + const queryClient = createTestQueryClient() + const setQueryData = vi.spyOn(queryClient, "setQueryData") + const cachedModels = { + "accounts/fireworks/models/glm-5p3": { contextWindow: 1_048_576, supportsPromptCache: false }, + } + + renderWithExtensionState( + , + { queryClient }, + ) + + // A successful refresh records the key the catalog belongs to. + fireEvent.click(getRefreshButton()) + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "routerModels", + requestId: getLastRequestId(), + routerModels: { [providerIdentifiers.fireworks]: cachedModels }, + values: { provider: providerIdentifiers.fireworks }, + }, + }), + ) + }) + await waitFor(() => expect(screen.getByText("settings:providers.refreshModels.success")).toBeInTheDocument()) + + // A later refresh with the same key fails and publishes an empty catalog. + setQueryData.mockClear() + fireEvent.click(getRefreshButton()) + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "routerModels", + requestId: getLastRequestId(), + routerModels: { [providerIdentifiers.fireworks]: {} }, + values: { provider: providerIdentifiers.fireworks }, + }, + }), + ) + }) + + // The cached catalog stays in place instead of being replaced with an empty one. + expect(setQueryData).not.toHaveBeenCalled() + }) + + it("clears the catalog when a refresh fails for a different key", async () => { + const queryClient = createTestQueryClient() + const setQueryData = vi.spyOn(queryClient, "setQueryData") + + const { rerender } = renderWithExtensionState( + , + { queryClient }, + ) + + fireEvent.click(getRefreshButton()) + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "routerModels", + requestId: getLastRequestId(), + routerModels: { + [providerIdentifiers.fireworks]: { + "accounts/fireworks/models/old-key-model": { contextWindow: 8192 }, + }, + }, + values: { provider: providerIdentifiers.fireworks }, + }, + }), + ) + }) + await waitFor(() => expect(screen.getByText("settings:providers.refreshModels.success")).toBeInTheDocument()) + + // The key changes, so the previous account's catalog must not survive a failure. + rerender( + , + ) + await waitFor(() => + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "requestRouterModels", + values: { provider: providerIdentifiers.fireworks, fireworksApiKey: "new-key" }, + }), + ), + ) + + setQueryData.mockClear() + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "routerModels", + requestId: getLastRequestId(), + routerModels: { [providerIdentifiers.fireworks]: {} }, + values: { provider: providerIdentifiers.fireworks }, + }, + }), + ) + }) + + await waitFor(() => { + const fireworksCall = setQueryData.mock.calls.find( + ([key]) => Array.isArray(key) && key[1] === providerIdentifiers.fireworks, + ) + expect(fireworksCall).toBeDefined() + const updater = fireworksCall![1] as (current: unknown) => unknown + expect(updater({ [providerIdentifiers.fireworks]: { stale: {} } })).toEqual({ + [providerIdentifiers.fireworks]: {}, + }) + }) + }) + + it("clears a stale refresh error when the key changes", async () => { + const { rerender } = render( + , + ) + + fireEvent.click(getRefreshButton()) + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: "singleRouterModelFetchResponse", + requestId: getLastRequestId(), + success: false, + error: "HTTP 401: Unauthorized", + values: { provider: providerIdentifiers.fireworks }, + }, + }), + ) + }) + await waitFor(() => expect(screen.getByText("HTTP 401: Unauthorized")).toBeInTheDocument()) + + rerender( + , + ) + + await waitFor(() => expect(screen.queryByText("HTTP 401: Unauthorized")).not.toBeInTheDocument()) + expect(screen.getByText("settings:providers.refreshModels.loading")).toBeInTheDocument() + }) + + it("requests the catalog with the new key when the key changes", async () => { + const { rerender } = render( + , + ) + + rerender( + , + ) + + await waitFor(() => + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "requestRouterModels", + requestId: expect.any(String), + values: { provider: providerIdentifiers.fireworks, fireworksApiKey: "new-key" }, + }), + ) + }) + + it("does not request the catalog on mount or when the key is cleared", async () => { + const { rerender } = render( + , + ) + + rerender( + , + ) + + await new Promise((resolve) => setTimeout(resolve, 300)) + + expect(vscode.postMessage).not.toHaveBeenCalled() + }) + + it("threads the organization allow list and validation error into the picker", () => { + render( + , + ) + + expect(screen.getByTestId("model-picker-allow-list")).toHaveTextContent("allow-list") + expect(screen.getByTestId("model-picker-error")).toHaveTextContent("model not allowed") + }) + + it("resets model-specific settings when the selected model changes", () => { + render( + , + ) + + fireEvent.click(screen.getByTestId("change-model")) + + expect(mockSetApiConfigurationField).toHaveBeenCalledWith("reasoningEffort", undefined) + expect(mockSetApiConfigurationField).toHaveBeenCalledWith("modelMaxTokens", undefined) + }) +}) diff --git a/webview-ui/src/components/settings/providers/__tests__/IOIntelligence.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/IOIntelligence.spec.tsx index ae6f7bc63a..575e038df5 100644 --- a/webview-ui/src/components/settings/providers/__tests__/IOIntelligence.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/IOIntelligence.spec.tsx @@ -76,6 +76,7 @@ describe("IOIntelligence", () => { poe: {}, deepseek: {}, moonshot: {}, + fireworks: {}, "opencode-go": {}, kenari: {}, nanogpt: {}, diff --git a/webview-ui/src/components/settings/providers/__tests__/NanoGPT.spec.tsx b/webview-ui/src/components/settings/providers/__tests__/NanoGPT.spec.tsx index 698fa90e29..01f32af89a 100644 --- a/webview-ui/src/components/settings/providers/__tests__/NanoGPT.spec.tsx +++ b/webview-ui/src/components/settings/providers/__tests__/NanoGPT.spec.tsx @@ -100,6 +100,7 @@ describe("NanoGPT", () => { poe: {}, deepseek: {}, moonshot: {}, + fireworks: {}, "opencode-go": {}, kenari: {}, nanogpt: { "openai/test": { contextWindow: 1, maxTokens: 1, supportsPromptCache: false } }, diff --git a/webview-ui/src/components/settings/utils/providerModelConfig.ts b/webview-ui/src/components/settings/utils/providerModelConfig.ts index 1bda52cf6f..f0d40efb5f 100644 --- a/webview-ui/src/components/settings/utils/providerModelConfig.ts +++ b/webview-ui/src/components/settings/utils/providerModelConfig.ts @@ -231,6 +231,7 @@ export const PROVIDERS_WITH_CUSTOM_MODEL_UI: ProviderName[] = [ providerIdentifiers.vscodeLm, providerIdentifiers.githubCopilot, providerIdentifiers.moonshot, // Moonshot has custom ModelPicker inside Moonshot.tsx + providerIdentifiers.fireworks, // Fireworks has custom ModelPicker (API-fetched list) inside Fireworks.tsx ] /** diff --git a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index 4955c3c031..6bf4b6637d 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -302,6 +302,59 @@ describe("useSelectedModel", () => { }, ) + describe("Fireworks model selection", () => { + beforeEach(() => { + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + }) + + it("uses API-fetched metadata for a model missing from the static list", () => { + const modelId = "accounts/fireworks/models/glm-5p3" + const modelInfo: ModelInfo = { contextWindow: 1_048_576, supportsPromptCache: false, supportsImages: true } + mockUseRouterModels.mockReturnValue( + createRouterModelsResult({ [providerIdentifiers.fireworks]: { [modelId]: modelInfo } }), + ) + + const { result } = renderHook( + () => useSelectedModel({ apiProvider: providerIdentifiers.fireworks, apiModelId: modelId }), + { wrapper: createWrapper() }, + ) + + expect(result.current.id).toBe(modelId) + expect(result.current.info).toEqual(modelInfo) + }) + + it("prefers curated static metadata (with pricing) for known models", () => { + mockUseRouterModels.mockReturnValue( + createRouterModelsResult({ + [providerIdentifiers.fireworks]: { + [fireworksDefaultModelId]: { contextWindow: 1, supportsPromptCache: false }, + }, + }), + ) + + const { result } = renderHook(() => useSelectedModel({ apiProvider: providerIdentifiers.fireworks }), { + wrapper: createWrapper(), + }) + + expect(result.current.id).toBe(fireworksDefaultModelId) + expect(result.current.info).toEqual(fireworksModels[fireworksDefaultModelId]) + }) + + it("keeps an unknown configured id with sane default info when absent from both lists", () => { + mockUseRouterModels.mockReturnValue(createRouterModelsResult({ [providerIdentifiers.fireworks]: {} })) + + const { result } = renderHook( + () => useSelectedModel({ apiProvider: providerIdentifiers.fireworks, apiModelId: "custom/model" }), + { wrapper: createWrapper() }, + ) + + // Mirrors FireworksHandler.getModel(), which falls back to sane defaults for + // custom ids so the UI shows sensible limits instead of an unknown model. + expect(result.current.id).toBe("custom/model") + expect(result.current.info).toEqual(openAiModelInfoSaneDefaults) + }) + }) + it("uses router data for Poe", () => { const modelInfo: ModelInfo = { contextWindow: 42_000, supportsPromptCache: false } mockUseRouterModels.mockReturnValue( @@ -2152,6 +2205,7 @@ describe("useSelectedModel", () => { requesty: {}, litellm: {}, moonshot: {}, + fireworks: {}, }, isLoading: false, isError: false, diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 9eca0cc0f3..d89ba43f41 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -126,14 +126,18 @@ export const useSelectedModel = (apiConfiguration: ProviderSettings) => { const needLmStudio = typeof lmStudioModelId !== "undefined" const needOllama = typeof ollamaModelId !== "undefined" - // LiteLLM and IO Intelligence may legitimately have no entry in the router - // payload (partial listing, failed fetch, renamed deployment) even though - // the configured ID is a valid selection, so they only need the fetch to - // settle. Other dynamic providers require a populated provider entry - // before the selection is resolved. + // LiteLLM, IO Intelligence and Fireworks may legitimately have no entry in + // the router payload (partial listing, failed fetch, renamed deployment, or + // no API key yet) even though the configured ID is a valid selection, so + // they only need the fetch to settle. Fireworks additionally has a curated + // static catalog, so it can resolve before any catalog arrives. Other + // dynamic providers require a populated provider entry before the selection + // is resolved. const hasValidRouterData = needRouterModels && dynamicProvider - ? dynamicProvider === providerIdentifiers.litellm || dynamicProvider === providerIdentifiers.ioIntelligence + ? dynamicProvider === providerIdentifiers.litellm || + dynamicProvider === providerIdentifiers.ioIntelligence || + dynamicProvider === providerIdentifiers.fireworks ? !routerModels.isLoading : routerModels.data && routerModels.data[dynamicProvider] !== undefined && @@ -510,7 +514,14 @@ function getSelectedModel({ case providerIdentifiers.sambanova: return getStaticCatalogModel(apiConfiguration.apiModelId, sambaNovaModels, defaultModelId) case providerIdentifiers.fireworks: - return getStaticCatalogModel(apiConfiguration.apiModelId, fireworksModels, defaultModelId) + // Curated static specs (with pricing) win over API-fetched metadata, which only + // fills in models the static map does not know. A configured id absent from both + // is still honoured (the backend sends it as-is) with sane defaults. + return getStaticCatalogModel( + apiConfiguration.apiModelId, + { ...routerModels[providerIdentifiers.fireworks], ...fireworksModels }, + defaultModelId, + ) case providerIdentifiers.friendli: { const id = apiConfiguration.apiModelId ?? defaultModelId const info = friendliModels[id as keyof typeof friendliModels] diff --git a/webview-ui/src/utils/__tests__/validate.spec.ts b/webview-ui/src/utils/__tests__/validate.spec.ts index bf25faf7f2..6417122510 100644 --- a/webview-ui/src/utils/__tests__/validate.spec.ts +++ b/webview-ui/src/utils/__tests__/validate.spec.ts @@ -64,6 +64,7 @@ describe("Model Validation Functions", () => { "zoo-gateway": {}, "kimi-code": {}, moonshot: {}, + fireworks: {}, } const allowAllOrganization: OrganizationAllowList = { From aa7f894c659c4eafe287ad991e12df64f4151145 Mon Sep 17 00:00:00 2001 From: Povilas Kanapickas Date: Mon, 28 Sep 2026 23:03:43 +0000 Subject: [PATCH 2/2] feat(fireworks): fetch pricing from the API and drop the hardcoded catalog Fireworks model metadata and pricing now come entirely from the API: - Model list and capabilities: GET /inference/v1/models. - Pricing: GET /v1/accounts/{account_id}/serverlessModels, with the account id resolved via GET /v1/accounts. The RPC has no API reference page, but its pricing field is documented in the Model schema on the List/Get Model docs (serverlessModes[].skuInfos: "only populated by ListServerlessModels"). The default mode prices the model id; fast modes price their usageIdentifier router id; priority tier is skipped. - Pricing is best-effort: on any failure the model list still loads and cost is left unknown. Models without a price are kept. - Embedding/reranker models are filtered by kind, since they report supports_chat: true. Removes the hardcoded fireworksModels catalog and FireworksModelId type. The settings picker shows only API-fetched models (no static fallback), and the handler/useSelectedModel read model info from the fetched list. --- .../src/__tests__/deepseek-v4-pro.test.ts | 33 +- packages/types/src/provider-settings.ts | 8 +- packages/types/src/providers/fireworks.ts | 341 +-------- src/api/providers/__tests__/fireworks.spec.ts | 708 ++++-------------- .../fetchers/__tests__/fireworks.spec.ts | 424 +++++++++-- src/api/providers/fetchers/fireworks.ts | 271 ++++++- src/api/providers/fireworks.ts | 75 +- .../src/components/settings/constants.ts | 2 - .../settings/providers/Fireworks.tsx | 8 +- .../providers/__tests__/Fireworks.spec.tsx | 5 +- .../hooks/__tests__/useSelectedModel.spec.ts | 83 +- .../components/ui/hooks/useSelectedModel.ts | 47 +- 12 files changed, 881 insertions(+), 1124 deletions(-) diff --git a/packages/types/src/__tests__/deepseek-v4-pro.test.ts b/packages/types/src/__tests__/deepseek-v4-pro.test.ts index 6fa40d28eb..19cc99822e 100644 --- a/packages/types/src/__tests__/deepseek-v4-pro.test.ts +++ b/packages/types/src/__tests__/deepseek-v4-pro.test.ts @@ -1,4 +1,4 @@ -import { basetenModels, deepSeekModels, fireworksModels, opencodeGoModels } from "../providers/index.js" +import { basetenModels, deepSeekModels, opencodeGoModels } from "../providers/index.js" describe("DeepSeek V4 Pro 0813 provider catalogs", () => { it.each([ @@ -55,23 +55,19 @@ describe("DeepSeek V4 Pro 0813 provider catalogs", () => { }) // Self-hosted providers retain separate IDs for the preview weights and 0813 checkpoint. - it.each([ - ["Fireworks AI", fireworksModels["accounts/fireworks/models/deepseek-v4-pro"]], - ["Baseten", basetenModels["deepseek-ai/DeepSeek-V4-Pro"]], - ])("does not apply the API checkpoint label to %s", (_provider, model) => { - expect(model).toBeDefined() - expect("displayName" in model && typeof model.displayName === "string" ? model.displayName : "").not.toContain( - "0813", - ) - expect(model?.contextWindow).toBeGreaterThanOrEqual(1_000_000) - }) + // (Fireworks models, including pricing, are fetched dynamically from the Fireworks API.) + it.each([["Baseten", basetenModels["deepseek-ai/DeepSeek-V4-Pro"]]])( + "does not apply the API checkpoint label to %s", + (_provider, model) => { + expect(model).toBeDefined() + expect( + "displayName" in model && typeof model.displayName === "string" ? model.displayName : "", + ).not.toContain("0813") + expect(model?.contextWindow).toBeGreaterThanOrEqual(1_000_000) + }, + ) it.each([ - [ - "Fireworks AI", - fireworksModels["accounts/fireworks/models/deepseek-v4-pro-0813"], - { inputPrice: 1.32, outputPrice: 3.96, cacheReadsPrice: 0.044 }, - ], [ "Baseten", basetenModels["deepseek-ai/DeepSeek-V4-Pro-0813"], @@ -85,11 +81,6 @@ describe("DeepSeek V4 Pro 0813 provider catalogs", () => { }) it("keeps preview pricing separate and omits unverified cache-write prices", () => { - expect(fireworksModels["accounts/fireworks/models/deepseek-v4-pro"]).toMatchObject({ - inputPrice: 1.74, - outputPrice: 3.48, - cacheReadsPrice: 0.145, - }) expect(basetenModels["deepseek-ai/DeepSeek-V4-Pro"]).toMatchObject({ inputPrice: 1.74, outputPrice: 3.48, diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 236d5804f5..fe3cf0b009 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -30,7 +30,6 @@ import { basetenModels, bedrockModels, deepSeekModels, - fireworksModels, friendliModels, geminiModels, mistralModels, @@ -458,11 +457,8 @@ export const MODELS_BY_PROVIDER: Record< label: "DeepSeek", models: Object.keys(deepSeekModels), }, - [providerIdentifiers.fireworks]: { - id: providerIdentifiers.fireworks, - label: "Fireworks", - models: Object.keys(fireworksModels), - }, + // Fireworks models are fetched dynamically from the Fireworks API. + [providerIdentifiers.fireworks]: { id: providerIdentifiers.fireworks, label: "Fireworks", models: [] }, [providerIdentifiers.friendli]: { id: providerIdentifiers.friendli, label: "Friendli", diff --git a/packages/types/src/providers/fireworks.ts b/packages/types/src/providers/fireworks.ts index 819ee20fd0..717bd9960a 100644 --- a/packages/types/src/providers/fireworks.ts +++ b/packages/types/src/providers/fireworks.ts @@ -1,3 +1,9 @@ +// Fireworks AI: https://fireworks.ai +// +// There is intentionally no hardcoded model catalog here. The model list, +// capabilities and pricing are all fetched at runtime from the Fireworks API +// (see src/api/providers/fetchers/fireworks.ts). + import { z } from "zod" import { type ModelInfo } from "../model.js" @@ -8,327 +14,26 @@ import { createModelIdAccessor, createProviderDefinition, } from "./common.js" +import { openAiModelInfoSaneDefaults } from "./openai.js" -export type FireworksModelId = - | "accounts/fireworks/models/kimi-k2-instruct" - | "accounts/fireworks/models/kimi-k2-instruct-0905" - | "accounts/fireworks/models/kimi-k2-thinking" - | "accounts/fireworks/models/kimi-k2p5" - | "accounts/fireworks/models/kimi-k2p6" - | "accounts/fireworks/models/kimi-k2p7-code" - | "accounts/fireworks/models/minimax-m2" - | "accounts/fireworks/models/minimax-m2p1" - | "accounts/fireworks/models/qwen3-235b-a22b-instruct-2507" - | "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct" - | "accounts/fireworks/models/deepseek-r1-0528" - | "accounts/fireworks/models/deepseek-v3" - | "accounts/fireworks/models/deepseek-v3p1" - | "accounts/fireworks/models/deepseek-v3p2" - | "accounts/fireworks/models/deepseek-v4-pro" - | "accounts/fireworks/models/deepseek-v4-pro-0813" - | "accounts/fireworks/models/deepseek-v4-flash-vision-exp" - | "accounts/fireworks/models/glm-4p5" - | "accounts/fireworks/models/glm-4p5-air" - | "accounts/fireworks/models/glm-4p6" - | "accounts/fireworks/models/glm-4p7" - | "accounts/fireworks/models/glm-5p1" - | "accounts/fireworks/models/gpt-oss-20b" - | "accounts/fireworks/models/gpt-oss-120b" - | "accounts/fireworks/models/llama-v3p3-70b-instruct" - | "accounts/fireworks/models/llama4-maverick-instruct-basic" - | "accounts/fireworks/models/llama4-scout-instruct-basic" +export const FIREWORKS_API_BASE_URL = "https://api.fireworks.ai" +export const FIREWORKS_INFERENCE_BASE_URL = `${FIREWORKS_API_BASE_URL}/inference/v1` -export const fireworksDefaultModelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-instruct-0905" +// Must stay a model Fireworks serves on serverless: the model picker writes this ID +// into new Fireworks profiles, so a retired model would fail the first chat request. +export const fireworksDefaultModelId = "accounts/fireworks/models/kimi-k3" -export const fireworksModels = { - "accounts/fireworks/models/kimi-k2-instruct-0905": { - maxTokens: 16384, - contextWindow: 262144, - supportsImages: false, - supportsPromptCache: true, - inputPrice: 0.6, - outputPrice: 2.5, - cacheReadsPrice: 0.15, - description: - "Kimi K2 model gets a new version update: Agentic coding: more accurate, better generalization across scaffolds. Frontend coding: improved aesthetics and functionalities on web, 3d, and other tasks. Context length: extended from 128k to 256k, providing better long-horizon support.", - }, - "accounts/fireworks/models/kimi-k2-instruct": { - maxTokens: 16384, - contextWindow: 128000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.6, - outputPrice: 2.5, - description: - "Kimi K2 is a state-of-the-art mixture-of-experts (MoE) language model with 32 billion activated parameters and 1 trillion total parameters. Trained with the Muon optimizer, Kimi K2 achieves exceptional performance across frontier knowledge, reasoning, and coding tasks while being meticulously optimized for agentic capabilities.", - }, - "accounts/fireworks/models/kimi-k2-thinking": { - maxTokens: 16000, - contextWindow: 256000, - supportsImages: false, - supportsPromptCache: true, - supportsTemperature: true, - preserveReasoning: true, - defaultTemperature: 1.0, - inputPrice: 0.6, - outputPrice: 2.5, - cacheReadsPrice: 0.15, - description: - "The kimi-k2-thinking model is a general-purpose agentic reasoning model developed by Moonshot AI. Thanks to its strength in deep reasoning and multi-turn tool use, it can solve even the hardest problems.", - }, - "accounts/fireworks/models/kimi-k2p5": { - maxTokens: 16384, - contextWindow: 262144, - supportsImages: true, - supportsPromptCache: true, - inputPrice: 0.6, - outputPrice: 3.0, - cacheReadsPrice: 0.1, - description: - "Kimi K2.5 is Moonshot AI's flagship agentic model and a new SOTA open model. It unifies vision and text, thinking and non-thinking modes, and single-agent and multi-agent execution into one model. Fireworks enables users to control the reasoning behavior and inspect its reasoning history for greater transparency.", - }, - "accounts/fireworks/models/minimax-m2": { - maxTokens: 4096, - contextWindow: 204800, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.3, - outputPrice: 1.2, - description: - "MiniMax M2 is a high-performance language model with 204.8K context window, optimized for long-context understanding and generation tasks.", - }, - "accounts/fireworks/models/qwen3-235b-a22b-instruct-2507": { - maxTokens: 32768, - contextWindow: 256000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.22, - outputPrice: 0.88, - description: "Latest Qwen3 thinking model, competitive against the best closed source models in Jul 2025.", - }, - "accounts/fireworks/models/qwen3-coder-480b-a35b-instruct": { - maxTokens: 32768, - contextWindow: 256000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.45, - outputPrice: 1.8, - description: "Qwen3's most agentic code model to date.", - }, - "accounts/fireworks/models/deepseek-r1-0528": { - maxTokens: 20480, - contextWindow: 160000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 3, - outputPrice: 8, - description: - "05/28 updated checkpoint of Deepseek R1. Its overall performance is now approaching that of leading models, such as O3 and Gemini 2.5 Pro. Compared to the previous version, the upgraded model shows significant improvements in handling complex reasoning tasks, and this version also offers a reduced hallucination rate, enhanced support for function calling, and better experience for vibe coding. Note that fine-tuning for this model is only available through contacting fireworks at https://fireworks.ai/company/contact-us.", - }, - "accounts/fireworks/models/deepseek-v3": { - maxTokens: 16384, - contextWindow: 128000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.9, - outputPrice: 0.9, - description: - "A strong Mixture-of-Experts (MoE) language model with 671B total parameters with 37B activated for each token from Deepseek. Note that fine-tuning for this model is only available through contacting fireworks at https://fireworks.ai/company/contact-us.", - }, - "accounts/fireworks/models/deepseek-v3p1": { - maxTokens: 16384, - contextWindow: 163840, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.56, - outputPrice: 1.68, - description: - "DeepSeek v3.1 is an improved version of the v3 model with enhanced performance, better reasoning capabilities, and improved code generation. This Mixture-of-Experts (MoE) model maintains the same 671B total parameters with 37B activated per token.", - }, - "accounts/fireworks/models/glm-4p5": { - maxTokens: 16384, - contextWindow: 128000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.55, - outputPrice: 2.19, - description: - "Z.ai GLM-4.5 with 355B total parameters and 32B active parameters. Features unified reasoning, coding, and intelligent agent capabilities.", - }, - "accounts/fireworks/models/glm-4p5-air": { - maxTokens: 16384, - contextWindow: 128000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.55, - outputPrice: 2.19, - description: - "Z.ai GLM-4.5-Air with 106B total parameters and 12B active parameters. Features unified reasoning, coding, and intelligent agent capabilities.", - }, - "accounts/fireworks/models/glm-4p6": { - maxTokens: 25344, - contextWindow: 198000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.55, - outputPrice: 2.19, - description: - "Z.ai GLM-4.6 is an advanced coding model with exceptional performance on complex programming tasks. Features improved reasoning capabilities and enhanced code generation quality, making it ideal for software development workflows.", - }, - "accounts/fireworks/models/gpt-oss-20b": { - maxTokens: 16384, - contextWindow: 128000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.07, - outputPrice: 0.3, - description: - "OpenAI gpt-oss-20b: Compact model for local/edge deployments. Optimized for low-latency and resource-constrained environments with chain-of-thought output, adjustable reasoning, and agentic workflows.", - }, - "accounts/fireworks/models/gpt-oss-120b": { - maxTokens: 16384, - contextWindow: 128000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.15, - outputPrice: 0.6, - description: - "OpenAI gpt-oss-120b: Production-grade, general-purpose model that fits on a single H100 GPU. Features complex reasoning, configurable effort, full chain-of-thought transparency, and supports function calling, tool use, and structured outputs.", - }, - "accounts/fireworks/models/minimax-m2p1": { - maxTokens: 4096, - contextWindow: 204800, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.3, - outputPrice: 1.2, - description: - "MiniMax M2.1 is an upgraded version of M2 with improved performance on complex reasoning, coding, and long-context understanding tasks.", - }, - "accounts/fireworks/models/deepseek-v3p2": { - maxTokens: 16384, - contextWindow: 163840, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.56, - outputPrice: 1.68, - description: - "DeepSeek V3.2 is the latest iteration of the V3 model family with enhanced reasoning capabilities, improved code generation, and better instruction following.", - }, - "accounts/fireworks/models/glm-4p7": { - maxTokens: 25344, - contextWindow: 198000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.55, - outputPrice: 2.19, - description: - "Z.ai GLM-4.7 is the latest coding model with exceptional performance on complex programming tasks. Features improved reasoning capabilities and enhanced code generation quality.", - }, - "accounts/fireworks/models/llama-v3p3-70b-instruct": { - maxTokens: 16384, - contextWindow: 131072, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.9, - outputPrice: 0.9, - description: - "Meta Llama 3.3 70B Instruct is a highly capable instruction-tuned model with strong reasoning, coding, and general task performance.", - }, - "accounts/fireworks/models/llama4-maverick-instruct-basic": { - maxTokens: 16384, - contextWindow: 131072, - supportsImages: true, - supportsPromptCache: false, - inputPrice: 0.22, - outputPrice: 0.88, - description: - "Llama 4 Maverick is Meta's latest multimodal model with vision capabilities, optimized for instruction following and coding tasks.", - }, - "accounts/fireworks/models/llama4-scout-instruct-basic": { - maxTokens: 16384, - contextWindow: 131072, - supportsImages: true, - supportsPromptCache: false, - inputPrice: 0.15, - outputPrice: 0.6, - description: - "Llama 4 Scout is a smaller, faster variant of Llama 4 with multimodal capabilities, ideal for quick iterations and cost-effective deployments.", - }, - "accounts/fireworks/models/kimi-k2p6": { - maxTokens: 16384, - contextWindow: 262144, - supportsImages: true, - supportsPromptCache: true, - inputPrice: 0.95, - outputPrice: 4.0, - cacheReadsPrice: 0.16, - description: - "Kimi K2.6 is Moonshot AI's latest flagship agentic model, building on K2.5 with stronger long-horizon reasoning, multi-step tool use, and unified vision/text understanding.", - }, - "accounts/fireworks/models/deepseek-v4-pro": { - maxTokens: 16384, - contextWindow: 1048576, - supportsImages: false, - supportsPromptCache: true, - inputPrice: 1.74, - outputPrice: 3.48, - cacheReadsPrice: 0.145, - description: - "DeepSeek V4 Pro is the latest iteration of the DeepSeek model family, with improved reasoning, code generation, and instruction following over the V3 series.", - }, - "accounts/fireworks/models/deepseek-v4-pro-0813": { - displayName: "DeepSeek V4 Pro 0813", - maxTokens: 384_000, - contextWindow: 1_000_000, - supportsImages: false, - supportsPromptCache: true, - supportsMaxTokens: true, - inputPrice: 1.32, - outputPrice: 3.96, - cacheReadsPrice: 0.044, - description: - "DeepSeek V4 Pro 0813 is DeepSeek's production checkpoint for advanced reasoning, coding, and long-context agentic workloads.", - }, - "accounts/fireworks/models/deepseek-v4-flash-vision-exp": { - displayName: "DeepSeek V4 Flash Vision Exp", - maxTokens: 384_000, - contextWindow: 1_048_576, - supportsImages: true, - supportsPromptCache: true, - supportsMaxTokens: true, - inputPrice: 0.22, - outputPrice: 0.66, - cacheReadsPrice: 0.007, - description: - "DeepSeek V4 Flash Vision Exp is an experimental multimodal model with text and image input, function calling, and long-context support.", - }, - "accounts/fireworks/models/kimi-k2p7-code": { - maxTokens: 16384, - contextWindow: 262144, - supportsImages: true, - supportsPromptCache: true, - preserveReasoning: true, - supportsTemperature: true, - defaultTemperature: 1.0, - inputPrice: 0.95, - outputPrice: 4.0, - cacheReadsPrice: 0.19, - description: - "Kimi K2.7 Code is Moonshot AI's latest coding-focused model, building on K2.6 with enhanced code generation, reasoning, and unified vision/text understanding for software development workflows.", - }, - "accounts/fireworks/models/glm-5p1": { - maxTokens: 25344, - contextWindow: 202752, - supportsImages: false, - supportsPromptCache: true, - inputPrice: 1.4, - outputPrice: 4.4, - cacheReadsPrice: 0.26, - description: - "Z.ai GLM-5.1 is the latest coding-focused model in the GLM family, with exceptional performance on complex programming tasks and enhanced reasoning and code generation quality.", - }, -} as const satisfies Record +/** + * Metadata for a model that is not (yet) in the fetched catalog. Pricing is left + * undefined (no guessed rate), so no cost is attributed to the request. Shared by + * the backend handler and the webview so both report the same limits. + */ +export const fireworksUnknownModelInfo: ModelInfo = { + ...openAiModelInfoSaneDefaults, + supportsImages: false, + inputPrice: undefined, + outputPrice: undefined, +} export const fireworksProviderDefinition = createProviderDefinition({ apiProvider: providerIdentifiers.fireworks, diff --git a/src/api/providers/__tests__/fireworks.spec.ts b/src/api/providers/__tests__/fireworks.spec.ts index db4a35cbea..2daaf378ff 100644 --- a/src/api/providers/__tests__/fireworks.spec.ts +++ b/src/api/providers/__tests__/fireworks.spec.ts @@ -3,27 +3,15 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { - type FireworksModelId, - fireworksDefaultModelId, - fireworksModels, - openAiModelInfoSaneDefaults, - providerIdentifiers, -} from "@roo-code/types" +import { type ModelInfo, fireworksDefaultModelId, providerIdentifiers } from "@roo-code/types" import { FireworksHandler } from "../fireworks" -import { getModelsFromCache } from "../fetchers/modelCache" +import { getModels, getModelsFromCache } from "../fetchers/modelCache" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" import { clearAllMocks } from "../../../test-utils/reset" -// Create mock functions const mockCreate = vi.fn() -vi.mock("../fetchers/modelCache", () => ({ - getModelsFromCache: vi.fn(() => undefined), -})) - -// Mock OpenAI module vi.mock("openai", () => ({ default: vi.fn(function () { return { @@ -36,37 +24,45 @@ vi.mock("openai", () => ({ }), })) +vi.mock("../fetchers/modelCache", () => ({ + getModels: vi.fn(async () => ({})), + getModelsFromCache: vi.fn(() => undefined), +})) + +const GLM_ID = "accounts/fireworks/models/glm-5p3" + +const pricedGlm: ModelInfo = { + maxTokens: 16384, + contextWindow: 1_048_576, + supportsImages: false, + supportsPromptCache: true, + inputPrice: 1.4, + outputPrice: 4.4, + cacheReadsPrice: 0.26, +} + +const unpricedModel: ModelInfo = { + maxTokens: 8192, + contextWindow: 8192, + supportsImages: true, + supportsPromptCache: false, +} + +const usageStream = (usage: Record) => + asyncStreamFrom([ + { choices: [{ delta: { content: "hi" }, index: 0 }], usage: null }, + { choices: [{ delta: {}, index: 0 }], usage }, + ]) + describe("FireworksHandler", () => { let handler: FireworksHandler beforeEach(() => { clearAllMocks() - // Set up default mock implementation + vi.mocked(getModelsFromCache).mockReturnValue(undefined) + vi.mocked(getModels).mockResolvedValue({}) mockCreate.mockImplementation(async () => - asyncStreamFrom([ - { - choices: [ - { - delta: { content: "Test response" }, - index: 0, - }, - ], - usage: null, - }, - { - choices: [ - { - delta: {}, - index: 0, - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - }, - ]), + usageStream({ prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }), ) handler = new FireworksHandler({ fireworksApiKey: "test-key" }) }) @@ -92,84 +88,83 @@ describe("FireworksHandler", () => { expect(() => new FireworksHandler({})).toThrow("API key is required") }) - it("should return default model when no model is specified", () => { + it("should read model info from the key-scoped model cache", () => { + vi.mocked(getModelsFromCache).mockReturnValue({ [GLM_ID]: pricedGlm }) + + const handlerWithModel = new FireworksHandler({ apiModelId: GLM_ID, fireworksApiKey: "fw-key" }) + const model = handlerWithModel.getModel() + + expect(getModelsFromCache).toHaveBeenCalledWith( + { provider: providerIdentifiers.fireworks, apiKey: "fw-key" }, + handlerWithModel, + ) + expect(model).toEqual({ id: GLM_ID, info: pricedGlm }) + }) + + it("should use the default model id when none is configured", () => { + vi.mocked(getModelsFromCache).mockReturnValue({ [fireworksDefaultModelId]: pricedGlm }) + const model = handler.getModel() + expect(model.id).toBe(fireworksDefaultModelId) - expect(model.info).toEqual(expect.objectContaining(fireworksModels[fireworksDefaultModelId])) + expect(model.info).toEqual(pricedGlm) }) - it("should return specified model when valid model is provided", () => { - const testModelId: FireworksModelId = "accounts/fireworks/models/qwen3-235b-a22b-instruct-2507" - const handlerWithModel = new FireworksHandler({ - apiModelId: testModelId, - fireworksApiKey: "test-fireworks-api-key", - }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual(expect.objectContaining(fireworksModels[testModelId])) - }) + describe("ensureModelFetched", () => { + it("loads the keyed catalog when the cache does not describe the selection", async () => { + vi.mocked(getModels).mockResolvedValue({ [GLM_ID]: pricedGlm }) + const handlerWithModel = new FireworksHandler({ apiModelId: GLM_ID, fireworksApiKey: "fw-key" }) - it("should use cached API metadata for a dynamically fetched model", async () => { - const dynamicId = "accounts/fireworks/models/glm-5p3" - vi.mocked(getModelsFromCache).mockReturnValue({ - [dynamicId]: { - maxTokens: 16384, - contextWindow: 1_048_576, - supportsImages: true, - supportsPromptCache: false, - }, - }) + await handlerWithModel.ensureModelFetched() - const handlerWithModel = new FireworksHandler({ - apiModelId: dynamicId, - fireworksApiKey: "test-fireworks-api-key", + expect(getModels).toHaveBeenCalledWith( + { provider: providerIdentifiers.fireworks, apiKey: "fw-key" }, + handlerWithModel, + ) }) - const model = handlerWithModel.getModel() - expect(getModelsFromCache).toHaveBeenCalledWith( - { - provider: providerIdentifiers.fireworks, - apiKey: "test-fireworks-api-key", - }, - handlerWithModel, - ) - expect(model.id).toBe(dynamicId) - expect(model.info.contextWindow).toBe(1_048_576) - expect(model.info.supportsImages).toBe(true) + it("skips the network when the cache already describes the selection", async () => { + vi.mocked(getModelsFromCache).mockReturnValue({ [GLM_ID]: pricedGlm }) + const handlerWithModel = new FireworksHandler({ apiModelId: GLM_ID, fireworksApiKey: "fw-key" }) - await collectStream(handlerWithModel.createMessage("system prompt", [])) - expect(mockCreate.mock.calls[0][0].model).toBe(dynamicId) - expect(mockCreate.mock.calls[0][0].max_tokens).toBe(16384) - }) + await handlerWithModel.ensureModelFetched() - it("should prefer static metadata over cached metadata for known models", () => { - const knownId: FireworksModelId = "accounts/fireworks/models/kimi-k2-instruct" - vi.mocked(getModelsFromCache).mockReturnValue({ - [knownId]: { contextWindow: 1, supportsPromptCache: false }, + expect(getModels).not.toHaveBeenCalled() }) - const model = new FireworksHandler({ apiModelId: knownId, fireworksApiKey: "k" }).getModel() + it("rejects with the caller's abort reason when already aborted", async () => { + const controller = new AbortController() + controller.abort(new Error("caller canceled")) - expect(model.info).toEqual(fireworksModels[knownId]) - }) + await expect(handler.ensureModelFetched(controller.signal)).rejects.toThrow("caller canceled") + expect(getModels).not.toHaveBeenCalled() + }) + + it("detaches the waiter when the signal aborts during the fetch", async () => { + vi.mocked(getModels).mockReturnValue(new Promise(() => {})) + const controller = new AbortController() - it("should fall back to sane defaults when an unknown model is not in the cache", () => { - vi.mocked(getModelsFromCache).mockReturnValue({}) + const pending = handler.ensureModelFetched(controller.signal) + controller.abort() + await expect(pending).rejects.toThrow("The Fireworks request was aborted") + }) + }) + + it("should keep the configured id with unpriced defaults when the model is not cached", () => { const model = new FireworksHandler({ apiModelId: "accounts/fireworks/models/not-cached", fireworksApiKey: "k", }).getModel() expect(model.id).toBe("accounts/fireworks/models/not-cached") - // Sane defaults intentionally omit maxTokens so no output limit is sent. - expect(model.info).toEqual(openAiModelInfoSaneDefaults) expect(model.info.maxTokens).toBeUndefined() + expect(model.info.supportsImages).toBe(false) + expect(model.info.inputPrice).toBeUndefined() + expect(model.info.outputPrice).toBeUndefined() }) - it("should omit max_tokens for a custom model because Fireworks rejects -1", async () => { - // Regression: custom models get sane-default metadata with maxTokens: -1, which - // Fireworks rejects with `400 max_tokens must be non-negative`. + it("should omit max_tokens for an uncached model because Fireworks rejects -1", async () => { const handlerWithCustomModel = new FireworksHandler({ apiModelId: "accounts/fireworks/models/deepseek-v4p1-flash", fireworksApiKey: "test-fireworks-api-key", @@ -181,361 +176,62 @@ describe("FireworksHandler", () => { expect(mockCreate.mock.calls[0][0].max_tokens).toBeUndefined() }) - it("should still send a positive max_tokens for a known model", async () => { - const handlerWithModel = new FireworksHandler({ - apiModelId: "accounts/fireworks/models/kimi-k2-instruct", - fireworksApiKey: "test-fireworks-api-key", - }) - - await collectStream(handlerWithModel.createMessage("system prompt", [])) - - expect(mockCreate.mock.calls[0][0].max_tokens).toBe(16384) - }) - - it.each([ - { - modelId: "accounts/fireworks/models/glm-5p1" as const, - contextWindow: 202752, - inputPrice: 1.4, - outputPrice: 4.4, - cacheReadsPrice: 0.26, - }, - { - modelId: "accounts/fireworks/models/kimi-k2p6" as const, - contextWindow: 262144, - inputPrice: 0.95, - outputPrice: 4.0, - cacheReadsPrice: 0.16, - }, - { - modelId: "accounts/fireworks/models/deepseek-v4-pro" as const, - contextWindow: 1048576, - inputPrice: 1.74, - outputPrice: 3.48, - cacheReadsPrice: 0.145, - }, - { - modelId: "accounts/fireworks/models/deepseek-v4-pro-0813" as const, - contextWindow: 1_000_000, - inputPrice: 1.32, - outputPrice: 3.96, - cacheReadsPrice: 0.044, - }, - { - modelId: "accounts/fireworks/models/deepseek-v4-flash-vision-exp" as const, - contextWindow: 1_048_576, - inputPrice: 0.22, - outputPrice: 0.66, - cacheReadsPrice: 0.007, - }, - ])( - "should expose newly added model $modelId", - ({ modelId, contextWindow, inputPrice, outputPrice, cacheReadsPrice }) => { - expect(fireworksModels[modelId]).toBeDefined() - const info = fireworksModels[modelId] - expect(info.maxTokens).toBeGreaterThan(0) - expect(info.contextWindow).toBe(contextWindow) - expect(info.inputPrice).toBe(inputPrice) - expect(info.outputPrice).toBe(outputPrice) - expect(info.cacheReadsPrice).toBe(cacheReadsPrice) - expect(info.description).toBeTruthy() - - const handlerWithModel = new FireworksHandler({ - apiModelId: modelId, - fireworksApiKey: "test-fireworks-api-key", - }) - expect(handlerWithModel.getModel().id).toBe(modelId) - }, - ) - - it("should expose vision support for DeepSeek V4 Flash Vision Exp", () => { - const model = fireworksModels["accounts/fireworks/models/deepseek-v4-flash-vision-exp"] - - expect(model).toMatchObject({ - supportsImages: true, - supportsPromptCache: true, - supportsMaxTokens: true, - }) - }) - - it("should return Kimi K2 Instruct model with correct configuration", () => { - const testModelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-instruct" - const handlerWithModel = new FireworksHandler({ - apiModelId: testModelId, - fireworksApiKey: "test-fireworks-api-key", - }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual( - expect.objectContaining({ - maxTokens: 16384, - contextWindow: 128000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.6, - outputPrice: 2.5, - description: expect.stringContaining("Kimi K2 is a state-of-the-art mixture-of-experts"), - }), - ) - }) - - it("should return Kimi K2 Thinking model with correct configuration", () => { - const testModelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-thinking" - const handlerWithModel = new FireworksHandler({ - apiModelId: testModelId, - fireworksApiKey: "test-fireworks-api-key", - }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual( - expect.objectContaining({ - maxTokens: 16000, - contextWindow: 256000, - supportsImages: false, - supportsPromptCache: true, - supportsTemperature: true, - preserveReasoning: true, - defaultTemperature: 1.0, - inputPrice: 0.6, - outputPrice: 2.5, - cacheReadsPrice: 0.15, - }), - ) - }) + it("should pass the model, capped max_tokens and default temperature for a cached model", async () => { + vi.mocked(getModelsFromCache).mockReturnValue({ [GLM_ID]: pricedGlm }) + const handlerWithModel = new FireworksHandler({ apiModelId: GLM_ID, fireworksApiKey: "k" }) - it("should return Kimi K2.7 code model with correct configuration", () => { - const testModelId: FireworksModelId = "accounts/fireworks/models/kimi-k2p7-code" - const handlerWithModel = new FireworksHandler({ - apiModelId: testModelId, - fireworksApiKey: "test-fireworks-api-key", - }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual( - expect.objectContaining({ - maxTokens: 16384, - contextWindow: 262144, - supportsImages: true, - supportsPromptCache: true, - supportsTemperature: true, - preserveReasoning: true, - defaultTemperature: 1.0, - inputPrice: 0.95, - outputPrice: 4.0, - cacheReadsPrice: 0.19, - }), - ) - }) + const systemPrompt = "Test system prompt for Fireworks" + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message for Fireworks" }] + await collectStream(handlerWithModel.createMessage(systemPrompt, messages)) - it("should return MiniMax M2 model with correct configuration", () => { - const testModelId: FireworksModelId = "accounts/fireworks/models/minimax-m2" - const handlerWithModel = new FireworksHandler({ - apiModelId: testModelId, - fireworksApiKey: "test-fireworks-api-key", - }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual( + expect(mockCreate).toHaveBeenCalledWith( expect.objectContaining({ - maxTokens: 4096, - contextWindow: 204800, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.3, - outputPrice: 1.2, - description: expect.stringContaining("MiniMax M2 is a high-performance language model"), + model: GLM_ID, + max_tokens: 16384, + temperature: 0.5, + messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]), + stream: true, + stream_options: { include_usage: true }, }), + undefined, ) }) - it("should return Qwen3 235B model with correct configuration", () => { - const testModelId: FireworksModelId = "accounts/fireworks/models/qwen3-235b-a22b-instruct-2507" - const handlerWithModel = new FireworksHandler({ - apiModelId: testModelId, - fireworksApiKey: "test-fireworks-api-key", - }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual( - expect.objectContaining({ - maxTokens: 32768, - contextWindow: 256000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.22, - outputPrice: 0.88, - description: - "Latest Qwen3 thinking model, competitive against the best closed source models in Jul 2025.", - }), - ) - }) + it("should use user-specified temperature over the provider default", async () => { + const handlerWithTemp = new FireworksHandler({ fireworksApiKey: "k", modelTemperature: 0.7 }) - it("should return DeepSeek R1 model with correct configuration", () => { - const testModelId: FireworksModelId = "accounts/fireworks/models/deepseek-r1-0528" - const handlerWithModel = new FireworksHandler({ - apiModelId: testModelId, - fireworksApiKey: "test-fireworks-api-key", - }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual( - expect.objectContaining({ - maxTokens: 20480, - contextWindow: 160000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 3, - outputPrice: 8, - description: expect.stringContaining("05/28 updated checkpoint of Deepseek R1"), - }), - ) - }) + await collectStream(handlerWithTemp.createMessage("system", [])) - it("should return DeepSeek V3 model with correct configuration", () => { - const testModelId: FireworksModelId = "accounts/fireworks/models/deepseek-v3" - const handlerWithModel = new FireworksHandler({ - apiModelId: testModelId, - fireworksApiKey: "test-fireworks-api-key", - }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual( - expect.objectContaining({ - maxTokens: 16384, - contextWindow: 128000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.9, - outputPrice: 0.9, - description: expect.stringContaining("strong Mixture-of-Experts (MoE) language model"), - }), - ) + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ temperature: 0.7 }), undefined) }) - it("should return DeepSeek V3.1 model with correct configuration", () => { - const testModelId: FireworksModelId = "accounts/fireworks/models/deepseek-v3p1" - const handlerWithModel = new FireworksHandler({ - apiModelId: testModelId, - fireworksApiKey: "test-fireworks-api-key", - }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual( - expect.objectContaining({ - maxTokens: 16384, - contextWindow: 163840, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.56, - outputPrice: 1.68, - description: expect.stringContaining("DeepSeek v3.1 is an improved version"), + it("should compute cost from API pricing, including cached input", async () => { + vi.mocked(getModelsFromCache).mockReturnValue({ [GLM_ID]: pricedGlm }) + mockCreate.mockImplementationOnce(async () => + usageStream({ + prompt_tokens: 1_000_000, + completion_tokens: 1_000_000, + prompt_tokens_details: { cached_tokens: 500_000 }, }), ) - }) + const handlerWithModel = new FireworksHandler({ apiModelId: GLM_ID, fireworksApiKey: "k" }) - it("should return GLM-4.5 model with correct configuration", () => { - const testModelId: FireworksModelId = "accounts/fireworks/models/glm-4p5" - const handlerWithModel = new FireworksHandler({ - apiModelId: testModelId, - fireworksApiKey: "test-fireworks-api-key", - }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual( - expect.objectContaining({ - maxTokens: 16384, - contextWindow: 128000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.55, - outputPrice: 2.19, - description: expect.stringContaining("Z.ai GLM-4.5 with 355B total parameters"), - }), - ) - }) + const chunks = await collectStream(handlerWithModel.createMessage("system", [])) + const usage = chunks.find((chunk) => chunk.type === "usage") - it("should return GLM-4.5-Air model with correct configuration", () => { - const testModelId: FireworksModelId = "accounts/fireworks/models/glm-4p5-air" - const handlerWithModel = new FireworksHandler({ - apiModelId: testModelId, - fireworksApiKey: "test-fireworks-api-key", - }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual( - expect.objectContaining({ - maxTokens: 16384, - contextWindow: 128000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.55, - outputPrice: 2.19, - description: expect.stringContaining("Z.ai GLM-4.5-Air with 106B total parameters"), - }), - ) + // 500k uncached @ $1.40 + 500k cached @ $0.26 + 1M output @ $4.40 + expect(usage).toMatchObject({ type: "usage", cacheReadTokens: 500_000 }) + expect(usage?.type === "usage" && usage.totalCost).toBeCloseTo(0.7 + 0.13 + 4.4, 6) }) - it("should return GLM-4.6 model with correct configuration", () => { - const testModelId: FireworksModelId = "accounts/fireworks/models/glm-4p6" - const handlerWithModel = new FireworksHandler({ - apiModelId: testModelId, - fireworksApiKey: "test-fireworks-api-key", - }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual( - expect.objectContaining({ - maxTokens: 25344, - contextWindow: 198000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.55, - outputPrice: 2.19, - description: expect.stringContaining("Z.ai GLM-4.6 is an advanced coding model"), - }), - ) - }) + it("should not attribute a cost to models without API pricing", async () => { + vi.mocked(getModelsFromCache).mockReturnValue({ [GLM_ID]: unpricedModel }) + const handlerWithModel = new FireworksHandler({ apiModelId: GLM_ID, fireworksApiKey: "k" }) - it("should return gpt-oss-20b model with correct configuration", () => { - const testModelId: FireworksModelId = "accounts/fireworks/models/gpt-oss-20b" - const handlerWithModel = new FireworksHandler({ - apiModelId: testModelId, - fireworksApiKey: "test-fireworks-api-key", - }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual( - expect.objectContaining({ - maxTokens: 16384, - contextWindow: 128000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.07, - outputPrice: 0.3, - description: expect.stringContaining("OpenAI gpt-oss-20b: Compact model for local/edge deployments"), - }), - ) - }) + const chunks = await collectStream(handlerWithModel.createMessage("system", [])) + const usage = chunks.find((chunk) => chunk.type === "usage") - it("should return gpt-oss-120b model with correct configuration", () => { - const testModelId: FireworksModelId = "accounts/fireworks/models/gpt-oss-120b" - const handlerWithModel = new FireworksHandler({ - apiModelId: testModelId, - fireworksApiKey: "test-fireworks-api-key", - }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual( - expect.objectContaining({ - maxTokens: 16384, - contextWindow: 128000, - supportsImages: false, - supportsPromptCache: false, - inputPrice: 0.15, - outputPrice: 0.6, - description: expect.stringContaining("OpenAI gpt-oss-120b: Production-grade, general-purpose model"), - }), - ) + expect(usage).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 5, totalCost: 0 }) }) it("completePrompt method should return text from Fireworks API", async () => { @@ -553,122 +249,6 @@ describe("FireworksHandler", () => { ) }) - it("createMessage should yield text content from stream", async () => { - const testContent = "This is test content from Fireworks stream" - - mockCreate.mockImplementationOnce(() => asyncStreamFrom([{ choices: [{ delta: { content: testContent } }] }])) - - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() - - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ type: "text", text: testContent }) - }) - - it("createMessage should yield usage data from stream", async () => { - mockCreate.mockImplementationOnce(() => - asyncStreamFrom([{ choices: [{ delta: {} }], usage: { prompt_tokens: 10, completion_tokens: 20 } }]), - ) - - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() - - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 20 }) - }) - - it("createMessage should pass correct parameters to Fireworks client", async () => { - const modelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-instruct" - const modelInfo = fireworksModels[modelId] - const handlerWithModel = new FireworksHandler({ - apiModelId: modelId, - fireworksApiKey: "test-fireworks-api-key", - }) - - mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) - - const systemPrompt = "Test system prompt for Fireworks" - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message for Fireworks" }] - - const messageGenerator = handlerWithModel.createMessage(systemPrompt, messages) - await messageGenerator.next() - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: modelId, - max_tokens: modelInfo.maxTokens, - temperature: 0.5, - messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]), - stream: true, - stream_options: { include_usage: true }, - }), - undefined, - ) - }) - - it("should use provider default temperature of 0.5 for models without defaultTemperature", async () => { - const modelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-instruct" - const handlerWithModel = new FireworksHandler({ - apiModelId: modelId, - fireworksApiKey: "test-fireworks-api-key", - }) - - mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) - - const messageGenerator = handlerWithModel.createMessage("system", []) - await messageGenerator.next() - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - temperature: 0.5, - }), - undefined, - ) - }) - - it("should use model defaultTemperature (1.0) over provider default (0.5) for kimi-k2-thinking", async () => { - const modelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-thinking" - const handlerWithModel = new FireworksHandler({ - apiModelId: modelId, - fireworksApiKey: "test-fireworks-api-key", - }) - - mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) - - const messageGenerator = handlerWithModel.createMessage("system", []) - await messageGenerator.next() - - // Model's defaultTemperature (1.0) should take precedence over provider's default (0.5) - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - temperature: 1.0, - }), - undefined, - ) - }) - - it("should use user-specified temperature over model and provider defaults", async () => { - const modelId: FireworksModelId = "accounts/fireworks/models/kimi-k2-thinking" - const handlerWithModel = new FireworksHandler({ - apiModelId: modelId, - fireworksApiKey: "test-fireworks-api-key", - modelTemperature: 0.7, - }) - - mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) - - const messageGenerator = handlerWithModel.createMessage("system", []) - await messageGenerator.next() - - // User-specified temperature should take precedence over everything - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - temperature: 0.7, - }), - undefined, - ) - }) - it("should handle empty response in completePrompt", async () => { mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: null } }] }) const result = await handler.completePrompt("test prompt") @@ -684,45 +264,17 @@ describe("FireworksHandler", () => { it("createMessage should handle stream with multiple chunks", async () => { mockCreate.mockImplementationOnce(async () => asyncStreamFrom([ + { choices: [{ delta: { content: "Hello" }, index: 0 }], usage: null }, + { choices: [{ delta: { content: " world" }, index: 0 }], usage: null }, { - choices: [ - { - delta: { content: "Hello" }, - index: 0, - }, - ], - usage: null, - }, - { - choices: [ - { - delta: { content: " world" }, - index: 0, - }, - ], - usage: null, - }, - { - choices: [ - { - delta: {}, - index: 0, - }, - ], - usage: { - prompt_tokens: 5, - completion_tokens: 10, - total_tokens: 15, - }, + choices: [{ delta: {}, index: 0 }], + usage: { prompt_tokens: 5, completion_tokens: 10, total_tokens: 15 }, }, ]), ) - const systemPrompt = "You are a helpful assistant." const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - - const stream = handler.createMessage(systemPrompt, messages) - const chunks = await collectStream(stream) + const chunks = await collectStream(handler.createMessage("You are a helpful assistant.", messages)) expect(chunks[0]).toEqual({ type: "text", text: "Hello" }) expect(chunks[1]).toEqual({ type: "text", text: " world" }) diff --git a/src/api/providers/fetchers/__tests__/fireworks.spec.ts b/src/api/providers/fetchers/__tests__/fireworks.spec.ts index c3bb4a5f21..b4bcd4dd62 100644 --- a/src/api/providers/fetchers/__tests__/fireworks.spec.ts +++ b/src/api/providers/fetchers/__tests__/fireworks.spec.ts @@ -1,144 +1,406 @@ // npx vitest run api/providers/fetchers/__tests__/fireworks.spec.ts +// Response fixtures mirror real payloads from /inference/v1/models, /v1/accounts and +// /v1/accounts/{id}/serverlessModels. + +import { + FIREWORKS_ACCOUNTS_URL, + FIREWORKS_MODELS_URL, + getFireworksModels, + parseFireworksServerlessPricing, +} from "../fireworks" + +const ACCOUNT = "accounts/acct-123" +const SERVERLESS_URL_PREFIX = `https://api.fireworks.ai/v1/${ACCOUNT}/serverlessModels` + +const usd = (value: number) => ({ + currencyCode: "USD", + units: String(Math.trunc(value)), + nanos: Math.round((value - Math.trunc(value)) * 1e9), +}) + +const sku = (name: string, value: number) => ({ sku: name, amount: usd(value), unit: "1M tokens" }) + +const pricedMode = ( + name: string, + prices: { input: number; output: number; cached?: number }, + extra: Record = {}, +) => ({ + name, + serviceTier: "", + usageIdentifier: "", + skuInfos: [ + sku("LLM output tokens", prices.output), + ...(prices.cached !== undefined ? [sku("LLM input tokens (cached)", prices.cached)] : []), + sku("LLM input tokens (uncached)", prices.input), + ], + ...extra, +}) -import { fireworksModels } from "@roo-code/types" +const glmModel = { + id: "accounts/fireworks/models/glm-5p3", + supports_chat: true, + supports_image_input: false, + supports_tools: true, + context_length: 1_048_576, +} + +const serverlessModelsBody = { + models: [ + { + name: "accounts/fireworks/models/glm-5p3", + serverlessModes: [ + pricedMode("accounts/fireworks/models/glm-5p3/serverlessModes/default", { + input: 1.4, + output: 4.4, + cached: 0.26, + }), + pricedMode( + "accounts/fireworks/models/glm-5p3/serverlessModes/priority", + { input: 1.75, output: 5.5, cached: 0.325 }, + { serviceTier: "priority" }, + ), + pricedMode( + "accounts/fireworks/models/glm-5p3/serverlessModes/fast", + { input: 2.1, output: 6.6, cached: 0.39 }, + { usageIdentifier: "accounts/fireworks/routers/glm-5p3-fast" }, + ), + ], + }, + ], +} + +type RouteMap = Record Response> + +const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }) + +/** + * Route fetch calls by URL prefix (longest prefix wins, since the accounts URL is a + * prefix of the serverlessModels URL); unmatched URLs return 404. + */ +const mockFetchRoutes = (routes: RouteMap) => + vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { + const url = String(input) + const match = Object.keys(routes) + .filter((prefix) => url.startsWith(prefix)) + .sort((a, b) => b.length - a.length)[0] + return match ? routes[match]() : new Response("not found", { status: 404, statusText: "Not Found" }) + }) + +const defaultRoutes = (models: unknown[] = [glmModel]): RouteMap => ({ + [FIREWORKS_MODELS_URL]: () => json({ data: models }), + [FIREWORKS_ACCOUNTS_URL]: () => json({ accounts: [{ name: ACCOUNT }] }), + [SERVERLESS_URL_PREFIX]: () => json(serverlessModelsBody), +}) -import { FIREWORKS_MODELS_URL, getFireworksModels } from "../fireworks" +describe("parseFireworksServerlessPricing", () => { + it("maps default and fast modes (via usageIdentifier) and skips the priority tier", () => { + const pricing = parseFireworksServerlessPricing(serverlessModelsBody) + + expect(pricing.get("accounts/fireworks/models/glm-5p3")).toEqual({ + inputPrice: 1.4, + outputPrice: 4.4, + cacheReadsPrice: 0.26, + }) + expect(pricing.get("accounts/fireworks/routers/glm-5p3-fast")).toEqual({ + inputPrice: 2.1, + outputPrice: 6.6, + cacheReadsPrice: 0.39, + }) + expect(pricing.size).toBe(2) + }) + + it("drops pricing when only one token direction is priced", () => { + // A missing output rate would otherwise be read as $0 by calculateApiCostInternal + // and silently under-report the request cost. + const pricing = parseFireworksServerlessPricing({ + models: [ + { + name: "accounts/fireworks/models/input-only", + serverlessModes: [ + { + name: "accounts/fireworks/models/input-only/serverlessModes/default", + skuInfos: [sku("LLM input tokens (uncached)", 1)], + }, + ], + }, + { + name: "accounts/fireworks/models/output-only", + serverlessModes: [ + { + name: "accounts/fireworks/models/output-only/serverlessModes/default", + skuInfos: [sku("LLM output tokens", 2)], + }, + ], + }, + ], + }) -const mockFetchJson = (body: unknown) => - vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify(body), { status: 200 })) + expect(pricing.size).toBe(0) + }) + + it("ignores unknown SKUs, non-USD amounts, non per-1M units and malformed entries", () => { + const pricing = parseFireworksServerlessPricing({ + models: [ + { + name: "accounts/fireworks/models/embed", + serverlessModes: [ + { + name: "accounts/fireworks/models/embed/serverlessModes/default", + skuInfos: [sku("Embedding input tokens", 0.1)], + }, + ], + }, + { + name: "accounts/fireworks/models/eur", + serverlessModes: [ + { + name: "accounts/fireworks/models/eur/serverlessModes/default", + skuInfos: [ + { + sku: "LLM output tokens", + amount: { currencyCode: "EUR", units: "1" }, + unit: "1M tokens", + }, + { sku: "LLM input tokens (uncached)", amount: usd(1), unit: "1K tokens" }, + ], + }, + ], + }, + null, + { name: 123 }, + { name: "accounts/fireworks/models/no-modes" }, + ], + }) + + expect(pricing.size).toBe(0) + }) + + it("returns an empty map for unexpected payloads", () => { + expect(parseFireworksServerlessPricing(undefined).size).toBe(0) + expect(parseFireworksServerlessPricing({ models: "nope" }).size).toBe(0) + }) +}) describe("getFireworksModels", () => { afterEach(() => { vi.restoreAllMocks() }) - it("requests the OpenAI-compatible models endpoint with the API key", async () => { - const fetchSpy = mockFetchJson({ data: [] }) + it("builds model info from the models endpoint and attaches serverlessModels pricing", async () => { + const fetchSpy = mockFetchRoutes(defaultRoutes()) - await getFireworksModels("fw-key") + const models = await getFireworksModels("fw-key") - expect(fetchSpy).toHaveBeenCalledWith( - FIREWORKS_MODELS_URL, - expect.objectContaining({ - headers: expect.objectContaining({ Authorization: "Bearer fw-key" }), - }), + expect(models["accounts/fireworks/models/glm-5p3"]).toEqual({ + // Fireworks reports no separate output ceiling, so the 20% output convention is + // used instead of a flat 16,384 cap. + maxTokens: 209_716, + contextWindow: 1_048_576, + supportsImages: false, + supportsPromptCache: true, + supportsMaxTokens: true, + inputPrice: 1.4, + outputPrice: 4.4, + cacheReadsPrice: 0.26, + }) + + const urls = fetchSpy.mock.calls.map(([input]) => String(input)) + expect(urls).toContain(FIREWORKS_MODELS_URL) + expect(urls).toContain(FIREWORKS_ACCOUNTS_URL) + expect(urls).toContain(`${SERVERLESS_URL_PREFIX}?pageSize=200`) + for (const [, init] of fetchSpy.mock.calls) { + expect((init?.headers as Record)["Authorization"]).toBe("Bearer fw-key") + } + }) + + it("prices router ids through the fast mode usageIdentifier", async () => { + mockFetchRoutes( + defaultRoutes([ + glmModel, + { ...glmModel, id: "accounts/fireworks/routers/glm-5p3-fast", supports_image_input: true }, + ]), ) - expect(FIREWORKS_MODELS_URL).toBe("https://api.fireworks.ai/inference/v1/models") + + const models = await getFireworksModels("fw-key") + + expect(models["accounts/fireworks/routers/glm-5p3-fast"]).toMatchObject({ + supportsImages: true, + inputPrice: 2.1, + outputPrice: 6.6, + cacheReadsPrice: 0.39, + }) }) - it("omits the Authorization header when no API key is provided", async () => { - const fetchSpy = mockFetchJson({ data: [] }) + it("keeps models without a price and leaves their cost unknown", async () => { + mockFetchRoutes( + defaultRoutes([{ id: "accounts/fireworks/models/inkling", supports_chat: true, context_length: 8192 }]), + ) - await getFireworksModels(undefined) + const models = await getFireworksModels("fw-key") - const init = fetchSpy.mock.calls[0][1] as RequestInit - expect((init.headers as Record)["Authorization"]).toBeUndefined() + expect(models["accounts/fireworks/models/inkling"]).toEqual({ + maxTokens: 8192, + contextWindow: 8192, + supportsImages: false, + supportsPromptCache: false, + supportsMaxTokens: true, + }) }) - it("keeps curated static specs (including pricing) for known models", async () => { - const knownId = "accounts/fireworks/models/kimi-k2p6" - mockFetchJson({ - data: [ - { - id: knownId, - supports_chat: true, - supports_image_input: false, - supports_tools: true, - context_length: 1, - }, - ], + it("uses a fallback context window when context_length is missing", async () => { + mockFetchRoutes(defaultRoutes([{ id: "accounts/fireworks/models/no-ctx", supports_chat: true }])) + + const models = await getFireworksModels("fw-key") + + expect(models["accounts/fireworks/models/no-ctx"]).toMatchObject({ + contextWindow: 128_000, + maxTokens: 25_600, }) + }) + + it("keeps the historical output budget as a floor below the 20% clamp", async () => { + mockFetchRoutes( + defaultRoutes([{ id: "accounts/fireworks/models/mid", supports_chat: true, context_length: 40_960 }]), + ) const models = await getFireworksModels("fw-key") - expect(models[knownId]).toEqual(fireworksModels[knownId]) + // 20% of 40,960 is 8,192, below the previous 16,384 allowance, so the floor wins. + expect(models["accounts/fireworks/models/mid"].maxTokens).toBe(16_384) }) - it("builds model info from API metadata for unknown models without pricing", async () => { - mockFetchJson({ - data: [ - { - id: "accounts/fireworks/models/glm-5p3", - supports_chat: true, - supports_image_input: true, - supports_tools: true, - context_length: 1_048_576, - }, - ], + it("caps the output budget at the context window", async () => { + mockFetchRoutes( + defaultRoutes([{ id: "accounts/fireworks/models/tiny", supports_chat: true, context_length: 8192 }]), + ) + + const models = await getFireworksModels("fw-key") + + expect(models["accounts/fireworks/models/tiny"].maxTokens).toBe(8192) + }) + + it("warns when the pricing page cap truncates the catalog", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + const alwaysMore = () => json({ ...serverlessModelsBody, nextPageToken: "more" }) + mockFetchRoutes({ ...defaultRoutes(), [SERVERLESS_URL_PREFIX]: alwaysMore }) + + const models = await getFireworksModels("fw-key") + + // The catalog still loads; only the pricing lookup is bounded. + expect(models["accounts/fireworks/models/glm-5p3"].inputPrice).toBe(1.4) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Pricing pagination truncated")) + }) + + it("follows serverlessModels pagination", async () => { + const fetchSpy = mockFetchRoutes({ + ...defaultRoutes([glmModel, { id: "accounts/fireworks/models/kimi-k3", supports_chat: true }]), + [`${SERVERLESS_URL_PREFIX}?pageSize=200&pageToken=p2`]: () => + json({ + models: [ + { + name: "accounts/fireworks/models/kimi-k3", + serverlessModes: [ + pricedMode("accounts/fireworks/models/kimi-k3/serverlessModes/default", { + input: 1, + output: 5, + }), + ], + }, + ], + }), + [SERVERLESS_URL_PREFIX]: () => json({ ...serverlessModelsBody, nextPageToken: "p2" }), }) const models = await getFireworksModels("fw-key") - expect(models["accounts/fireworks/models/glm-5p3"]).toEqual({ - maxTokens: 16_384, - contextWindow: 1_048_576, - supportsImages: true, + expect(models["accounts/fireworks/models/glm-5p3"].inputPrice).toBe(1.4) + expect(models["accounts/fireworks/models/kimi-k3"]).toMatchObject({ + inputPrice: 1, + outputPrice: 5, supportsPromptCache: false, - description: "Fireworks model: accounts/fireworks/models/glm-5p3", }) - expect(models["accounts/fireworks/models/glm-5p3"].inputPrice).toBeUndefined() + expect(fetchSpy.mock.calls.filter(([input]) => String(input).startsWith(SERVERLESS_URL_PREFIX))).toHaveLength(2) }) - it("uses fallbacks when context length is missing and caps maxTokens to small windows", async () => { - mockFetchJson({ - data: [ - { id: "accounts/fireworks/routers/no-ctx", supports_chat: true }, - { id: "accounts/fireworks/models/tiny", supports_chat: true, context_length: 8192 }, - ], - }) + it.each([ + ["accounts lookup fails", { [FIREWORKS_ACCOUNTS_URL]: () => json({}, 500) }], + ["accounts payload has no account", { [FIREWORKS_ACCOUNTS_URL]: () => json({ accounts: [] }) }], + ["serverlessModels is forbidden", { [SERVERLESS_URL_PREFIX]: () => json({ code: 7 }, 403) }], + [ + "serverlessModels throws", + { + [SERVERLESS_URL_PREFIX]: () => { + throw new Error("network down") + }, + }, + ], + ])("still returns the model list without prices when %s", async (_label, overrides) => { + vi.spyOn(console, "warn").mockImplementation(() => {}) + mockFetchRoutes({ ...defaultRoutes(), ...overrides }) const models = await getFireworksModels("fw-key") - expect(models["accounts/fireworks/routers/no-ctx"]).toMatchObject({ - contextWindow: 128_000, - maxTokens: 16_384, - supportsImages: false, - }) - expect(models["accounts/fireworks/models/tiny"]).toMatchObject({ contextWindow: 8192, maxTokens: 8192 }) + expect(models["accounts/fireworks/models/glm-5p3"]).toBeDefined() + expect(models["accounts/fireworks/models/glm-5p3"].inputPrice).toBeUndefined() }) - it("skips non-chat models and entries without a valid id", async () => { - mockFetchJson({ - data: [ - { id: "accounts/fireworks/models/qwen3-embedding-8b", supports_chat: false }, + it("skips the pricing lookup and Authorization header when no API key is provided", async () => { + const fetchSpy = mockFetchRoutes(defaultRoutes()) + + await getFireworksModels(undefined) + + expect(fetchSpy).toHaveBeenCalledTimes(1) + const init = fetchSpy.mock.calls[0][1] as RequestInit + expect((init.headers as Record)["Authorization"]).toBeUndefined() + }) + + it("skips non-chat and embedding models and entries without a valid id", async () => { + mockFetchRoutes( + defaultRoutes([ + { id: "accounts/fireworks/models/no-chat", supports_chat: false }, + // Embedding/reranker models report supports_chat: true but kind EMBEDDING_MODEL. + { id: "accounts/fireworks/models/qwen3-embedding-8b", kind: "EMBEDDING_MODEL", supports_chat: true }, + { id: "accounts/fireworks/models/qwen3-reranker-8b", kind: "EMBEDDING_MODEL", supports_chat: true }, { id: "" }, { id: 123 }, null, - { id: "accounts/fireworks/models/ok", supports_chat: true, context_length: 32_000 }, - ], - }) + glmModel, + ]), + ) const models = await getFireworksModels("fw-key") - expect(Object.keys(models)).toEqual(["accounts/fireworks/models/ok"]) + expect(Object.keys(models)).toEqual(["accounts/fireworks/models/glm-5p3"]) }) - it("throws for HTTP errors", async () => { - vi.spyOn(globalThis, "fetch").mockResolvedValue( - new Response('{"error":"bad key"}', { status: 401, statusText: "Unauthorized" }), - ) + it("throws for HTTP errors from the models endpoint", async () => { vi.spyOn(console, "error").mockImplementation(() => {}) + mockFetchRoutes({ + ...defaultRoutes(), + [FIREWORKS_MODELS_URL]: () => + new Response('{"error":"bad key"}', { status: 401, statusText: "Unauthorized" }), + }) await expect(getFireworksModels("bad")).rejects.toThrow("HTTP 401: Unauthorized") }) - it("throws on unexpected response format", async () => { - mockFetchJson({ data: "nope" }) + it("throws on unexpected models response format", async () => { vi.spyOn(console, "error").mockImplementation(() => {}) + mockFetchRoutes({ ...defaultRoutes(), [FIREWORKS_MODELS_URL]: () => json({ data: "nope" }) }) await expect(getFireworksModels("fw-key")).rejects.toThrow("Unexpected response format") }) - it("passes the caller's abort signal to the request", async () => { - const fetchSpy = mockFetchJson({ data: [] }) + it("passes the caller's abort signal to every request", async () => { + const fetchSpy = mockFetchRoutes(defaultRoutes()) const controller = new AbortController() await getFireworksModels("fw-key", { signal: controller.signal }) - expect(fetchSpy).toHaveBeenCalledWith( - FIREWORKS_MODELS_URL, - expect.objectContaining({ signal: controller.signal }), - ) + expect(fetchSpy).toHaveBeenCalledTimes(3) + for (const [, init] of fetchSpy.mock.calls) { + expect(init?.signal).toBe(controller.signal) + } }) it("rejects with the abort reason when the caller aborts a pending request", async () => { diff --git a/src/api/providers/fetchers/fireworks.ts b/src/api/providers/fetchers/fireworks.ts index fcf0bbee3d..6cd4f5a1c6 100644 --- a/src/api/providers/fetchers/fireworks.ts +++ b/src/api/providers/fetchers/fireworks.ts @@ -1,73 +1,273 @@ import type { ModelInfo, ModelRecord } from "@roo-code/types" -import { fireworksModels } from "@roo-code/types" +import { FIREWORKS_API_BASE_URL, FIREWORKS_INFERENCE_BASE_URL } from "@roo-code/types" import { DEFAULT_HEADERS } from "../constants" import { throwIfAborted } from "../utils/abort-signal" -export const FIREWORKS_MODELS_URL = "https://api.fireworks.ai/inference/v1/models" +export const FIREWORKS_MODELS_URL = `${FIREWORKS_INFERENCE_BASE_URL}/models` +export const FIREWORKS_ACCOUNTS_URL = `${FIREWORKS_API_BASE_URL}/v1/accounts` -// Fallbacks used for models that are not in the curated static map and whose -// API entry omits the corresponding field. +// Fallbacks for fields the API omits. const FIREWORKS_FALLBACK_CONTEXT_WINDOW = 128_000 -const FIREWORKS_FALLBACK_MAX_TOKENS = 16_384 +// Historical output budget. It is a floor, not a cap: the models endpoint reports no +// separate output ceiling, so larger-context models get the project-wide 20% budget. +const FIREWORKS_MIN_OUTPUT_TOKENS = 16_384 + +// Upper bound on pricing pages. A single page (pageSize=200) currently covers every +// model; the cap only protects against a runaway `nextPageToken`. +const FIREWORKS_PRICING_MAX_PAGES = 10 + +/** Entry of the OpenAI-compatible `GET /inference/v1/models` response. */ type FireworksApiModel = { id?: unknown + kind?: unknown supports_chat?: unknown supports_image_input?: unknown - supports_tools?: unknown context_length?: unknown } +/** + * Pricing types returned by `ListServerlessModels`. + * + * Where this comes from: the `ListServerlessModels` RPC has no page of its own in the + * Fireworks API reference, but its response is described in the Model schema published + * with the "List Models" / "Get Model" docs + * (https://docs.fireworks.ai/api-reference/list-models, field + * `serverlessModes[].skuInfos`): + * + * "Per-mode token pricing, sourced live from Orb on read. Reuses the same SKUInfo type + * as Model.sku_infos (e.g. "LLM input tokens (uncached)", ...). Empty on the stored + * config; only populated by ListServerlessModels." + * + * i.e. `GET /v1/accounts/{account_id}/models` always returns empty `serverlessModes`; + * prices are only filled in by `GET /v1/accounts/{account_id}/serverlessModels`, which + * must be called with the caller's own account id (other accounts return 403). + */ +type FireworksMoney = { currencyCode?: unknown; units?: unknown; nanos?: unknown } +type FireworksSkuInfo = { sku?: unknown; amount?: FireworksMoney; unit?: unknown } +type FireworksServerlessMode = { + name?: unknown + serviceTier?: unknown + usageIdentifier?: unknown + skuInfos?: unknown +} +type FireworksServerlessModel = { + name?: unknown + displayName?: unknown + serverlessModes?: unknown +} + +type FireworksPricing = Pick + +// SKU names as returned in `skuInfos[].sku` (prices are per `unit: "1M tokens"`). +const SKU_TO_PRICE_FIELD: Record = { + "LLM input tokens (uncached)": "inputPrice", + "LLM output tokens": "outputPrice", + "LLM input tokens (cached)": "cacheReadsPrice", +} + const isPositiveNumber = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) && value > 0 +const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null + +/** google.type.Money -> number (`units` is an int64 serialized as a string). */ +function moneyToNumber(amount: FireworksMoney | undefined): number | undefined { + if (!amount || (amount.currencyCode !== undefined && amount.currencyCode !== "USD")) { + return undefined + } + + const units = Number(amount.units ?? 0) + const nanos = Number(amount.nanos ?? 0) + const value = units + nanos / 1e9 + + return Number.isFinite(value) ? value : undefined +} + +function parseSkuPricing(skuInfos: unknown): FireworksPricing | undefined { + if (!Array.isArray(skuInfos)) return undefined + + const pricing: FireworksPricing = {} + + for (const skuInfo of skuInfos as FireworksSkuInfo[]) { + if (!isRecord(skuInfo) || typeof skuInfo.sku !== "string") continue + if (skuInfo.unit !== undefined && skuInfo.unit !== "1M tokens") continue + + const field = SKU_TO_PRICE_FIELD[skuInfo.sku] + const price = moneyToNumber(skuInfo.amount) + + if (field && price !== undefined) { + pricing[field] = price + } + } + + // `calculateApiCostInternal` treats a missing rate as zero, so a partial price list + // would silently drop the missing side from the recorded cost. Report no pricing at + // all unless both token directions are known; cost then shows as unknown. + if (pricing.inputPrice === undefined || pricing.outputPrice === undefined) return undefined + + return pricing +} + /** - * Build ModelInfo for a model that is not in the curated static map, using the - * capability fields returned by the Fireworks OpenAI-compatible /models endpoint. - * Pricing is intentionally omitted: the endpoint does not return it, and guessing - * would produce misleading cost reports. + * Build a map of inference model id -> pricing from a `ListServerlessModels` response. + * + * - The `default` mode (empty `serviceTier`, empty `usageIdentifier`) prices the model's + * own id. `priority` tier modes are skipped: they are the same id requested with a + * `service_tier` flag, which this provider never sends. + * - Modes with a `usageIdentifier` (e.g. `accounts/fireworks/routers/glm-5p3-fast`) price + * that router id. */ -function parseFireworksModel(modelId: string, model: FireworksApiModel): ModelInfo { +export function parseFireworksServerlessPricing(data: unknown): Map { + const pricing = new Map() + const models = isRecord(data) && Array.isArray(data.models) ? (data.models as FireworksServerlessModel[]) : [] + + for (const model of models) { + if (!isRecord(model) || typeof model.name !== "string" || !Array.isArray(model.serverlessModes)) continue + + for (const mode of model.serverlessModes as FireworksServerlessMode[]) { + if (!isRecord(mode)) continue + if (typeof mode.serviceTier === "string" && mode.serviceTier !== "") continue + + const modePricing = parseSkuPricing(mode.skuInfos) + if (!modePricing) continue + + const usageIdentifier = + typeof mode.usageIdentifier === "string" && mode.usageIdentifier ? mode.usageIdentifier : undefined + const isDefaultMode = typeof mode.name === "string" && mode.name.endsWith("/serverlessModes/default") + + if (usageIdentifier) { + pricing.set(usageIdentifier, modePricing) + } else if (isDefaultMode) { + pricing.set(model.name, modePricing) + } + } + } + + return pricing +} + +function authHeaders(apiKey?: string): Record { + const headers: Record = { + "Content-Type": "application/json", + ...DEFAULT_HEADERS, + } + + if (apiKey) { + headers["Authorization"] = `Bearer ${apiKey}` + } + + return headers +} + +/** + * Fetch per-model pricing. Best-effort: any failure (no key, account lookup failure, + * endpoint change) yields an empty map so the model list still loads and cost shows as + * unknown. Aborts are re-thrown so a cancelled fetch never looks like a usable catalog. + */ +async function getFireworksPricing( + apiKey: string | undefined, + signal: AbortSignal | undefined, +): Promise> { + if (!apiKey) return new Map() + + try { + const accountsResponse = await fetch(FIREWORKS_ACCOUNTS_URL, { headers: authHeaders(apiKey), signal }) + if (!accountsResponse.ok) return new Map() + + const accounts: unknown = await accountsResponse.json() + const accountName = + isRecord(accounts) && + Array.isArray(accounts.accounts) && + isRecord(accounts.accounts[0]) && + typeof accounts.accounts[0].name === "string" + ? accounts.accounts[0].name // e.g. "accounts/abc123" + : undefined + + if (!accountName || !/^accounts\/[\w-]+$/.test(accountName)) return new Map() + + const pricing = new Map() + let pageToken: string | undefined + + // Paginate defensively; a single page (pageSize=200) currently covers all models. + for (let page = 0; page < FIREWORKS_PRICING_MAX_PAGES; page++) { + const url = new URL(`${FIREWORKS_API_BASE_URL}/v1/${accountName}/serverlessModels`) + url.searchParams.set("pageSize", "200") + if (pageToken) url.searchParams.set("pageToken", pageToken) + + const response = await fetch(url.toString(), { headers: authHeaders(apiKey), signal }) + if (!response.ok) break + + const data: unknown = await response.json() + for (const [id, modelPricing] of parseFireworksServerlessPricing(data)) { + pricing.set(id, modelPricing) + } + + pageToken = isRecord(data) && typeof data.nextPageToken === "string" ? data.nextPageToken : undefined + if (!pageToken) break + } + + if (pageToken) { + // The page cap was hit while more pages exist. Models on later pages keep their + // entry in the catalog but have no price, so their cost is unknown rather than zero. + console.warn( + `[getFireworksModels] Pricing pagination truncated after ${FIREWORKS_PRICING_MAX_PAGES} pages; later models have unknown cost.`, + ) + } + + return pricing + } catch (error) { + throwIfAborted(signal) + console.warn("[getFireworksModels] Failed to fetch Fireworks pricing:", error) + return new Map() + } +} + +function parseFireworksModel(modelId: string, model: FireworksApiModel, pricing?: FireworksPricing): ModelInfo { const contextWindow = isPositiveNumber(model.context_length) ? model.context_length : FIREWORKS_FALLBACK_CONTEXT_WINDOW + // Fireworks accepts `max_tokens` up to the model's context window and reports no + // separate output ceiling, so the 20% output convention (used for OpenRouter models + // without a reported limit) is the best available per-model value. The historical + // 16,384 budget stays a floor so small-context models keep their previous allowance. + const maxTokens = Math.min(contextWindow, Math.max(FIREWORKS_MIN_OUTPUT_TOKENS, Math.ceil(contextWindow * 0.2))) + return { - maxTokens: Math.min(FIREWORKS_FALLBACK_MAX_TOKENS, contextWindow), + maxTokens, contextWindow, supportsImages: model.supports_image_input === true, - supportsPromptCache: false, - description: `Fireworks model: ${modelId}`, + supportsPromptCache: pricing?.cacheReadsPrice !== undefined, + // Surface the max-output slider so users can lower the budget for costly models. + supportsMaxTokens: true, + ...pricing, } } /** * Fetches the models available to the given API key from the Fireworks API. * - * The Fireworks /inference/v1/models endpoint returns model IDs together with - * capability metadata (context length, image and tool support) but no pricing. - * Known models keep their curated static specs (including pricing); models that - * are not in the static map are built from the API metadata. Non-chat models - * (embeddings, rerankers) are skipped because they cannot back a chat task. + * - Model list and capabilities: `GET /inference/v1/models` (OpenAI-compatible; returns + * `context_length`, `supports_image_input`, `supports_tools`, `supports_chat`). + * - Pricing: `GET /v1/accounts/{account_id}/serverlessModels` (see `FireworksSkuInfo` for + * where this is documented). Models without a price are kept, with cost left unknown. + * + * Non-chat models (embeddings, rerankers) are skipped because they cannot back a task. + * Note: embedding/reranker models currently report `supports_chat: true`, so `kind` is + * checked as well. */ export async function getFireworksModels(apiKey?: string, opts?: { signal?: AbortSignal }): Promise { - const headers: Record = { - "Content-Type": "application/json", - ...DEFAULT_HEADERS, - } + const signal = opts?.signal - if (apiKey) { - headers["Authorization"] = `Bearer ${apiKey}` - } - - const response = await fetch(FIREWORKS_MODELS_URL, { - headers, - signal: opts?.signal, - }) + const [response, pricing] = await Promise.all([ + fetch(FIREWORKS_MODELS_URL, { headers: authHeaders(apiKey), signal }), + getFireworksPricing(apiKey, signal), + ]) if (!response.ok) { - throwIfAborted(opts?.signal) + throwIfAborted(signal) let errorBody = "" try { @@ -100,12 +300,9 @@ export async function getFireworksModels(apiKey?: string, opts?: { signal?: Abor const modelId = typeof model?.id === "string" && model.id ? model.id : null if (!modelId) continue - // Embedding / reranker models report supports_chat: false. - if (model.supports_chat === false) continue - - const knownSpecs = fireworksModels[modelId as keyof typeof fireworksModels] + if (model.supports_chat === false || model.kind === "EMBEDDING_MODEL") continue - models[modelId] = knownSpecs ? { ...knownSpecs } : parseFireworksModel(modelId, model) + models[modelId] = parseFireworksModel(modelId, model, pricing.get(modelId)) } return models diff --git a/src/api/providers/fireworks.ts b/src/api/providers/fireworks.ts index 89c9cdeae0..fa74fc4477 100644 --- a/src/api/providers/fireworks.ts +++ b/src/api/providers/fireworks.ts @@ -1,60 +1,83 @@ import { - type FireworksModelId, fireworksDefaultModelId, - fireworksModels, + fireworksUnknownModelInfo, providerIdentifiers, type ModelRecord, + FIREWORKS_INFERENCE_BASE_URL, } from "@roo-code/types" import type { ApiHandlerOptions, GetModelsOptions } from "../../shared/api" import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" -import { getModelsFromCache } from "./fetchers/modelCache" +import { getModels, getModelsFromCache } from "./fetchers/modelCache" import type { ModelCacheScope } from "../index" +import { rejectOnAbort } from "./utils/abort-signal" import { getFireworksModels } from "./fetchers/fireworks" -export class FireworksHandler extends BaseOpenAiCompatibleProvider { +export class FireworksHandler extends BaseOpenAiCompatibleProvider { constructor(options: ApiHandlerOptions) { super({ ...options, providerName: "Fireworks", - baseURL: "https://api.fireworks.ai/inference/v1", + baseURL: FIREWORKS_INFERENCE_BASE_URL, apiKey: options.fireworksApiKey, defaultProviderModelId: fireworksDefaultModelId, - providerModels: fireworksModels, + // No hardcoded catalog: model metadata and pricing come from the Fireworks API + // via the model cache (see fetchers/fireworks.ts). + providerModels: {}, defaultTemperature: 0.5, }) } override getModel() { - const requestedId = this.options.apiModelId - - // Curated static metadata (incl. pricing) wins for known models. For models - // only discovered through the Fireworks /models endpoint, use the cached - // API metadata (context length, image support) instead of generic defaults. - if (requestedId && !(requestedId in this.providerModels)) { - const cached = getModelsFromCache( - { - provider: providerIdentifiers.fireworks, - apiKey: this.options.fireworksApiKey, - }, - this, - ) - const info = cached?.[requestedId] - - if (info) { - return { id: requestedId as FireworksModelId, info } - } - } + const id = this.options.apiModelId || fireworksDefaultModelId + const cached = getModelsFromCache( + { + provider: providerIdentifiers.fireworks, + apiKey: this.options.fireworksApiKey, + }, + this, + ) - return super.getModel() + return { id, info: cached?.[id] ?? fireworksUnknownModelInfo } } override getModelCacheScope(): ModelCacheScope { return { urlScoped: false, keyScoped: true, authScoped: false } } + /** + * Load the keyed catalog before the first request so `getModel()` reports the real + * context window and prices instead of `fireworksUnknownModelInfo`. A warm cache that + * already describes the selection needs no network round-trip. + * + * The fetch is single-flighted inside `getModels`, so concurrent tasks share one + * request. `signal` only bounds this caller's wait: aborting detaches the waiter while + * the shared fetch may still populate the cache for others. + */ + async ensureModelFetched(signal?: AbortSignal): Promise { + if (signal?.aborted) { + throw signal.reason + } + + const id = this.options.apiModelId || fireworksDefaultModelId + const cacheOptions = { provider: providerIdentifiers.fireworks, apiKey: this.options.fireworksApiKey } as const + + if (getModelsFromCache(cacheOptions, this)?.[id]) { + return + } + + const fetch = getModels(signal ? { ...cacheOptions, signal } : cacheOptions, this) + + if (!signal) { + await fetch + return + } + + await rejectOnAbort(fetch, signal, "Fireworks") + } + async fetchModels(options: GetModelsOptions, signal?: AbortSignal): Promise { const fetchOpts: [] | [{ signal: AbortSignal }] = signal ? [{ signal }] : [] return getFireworksModels(options.apiKey, ...fetchOpts) diff --git a/webview-ui/src/components/settings/constants.ts b/webview-ui/src/components/settings/constants.ts index 8590e536fe..f43f764c3b 100644 --- a/webview-ui/src/components/settings/constants.ts +++ b/webview-ui/src/components/settings/constants.ts @@ -15,7 +15,6 @@ import { xaiModels, sambaNovaModels, internationalZAiModels, - fireworksModels, friendliModels, minimaxModels, basetenModels, @@ -36,7 +35,6 @@ export const MODELS_BY_PROVIDER: Partial 0 ? fetchedModels : fireworksModels - return ( <> { expect(screen.getByTestId("model-picker-default")).toHaveTextContent(fireworksDefaultModelId) }) - it("falls back to the static model list when nothing has been fetched", () => { + it("shows no models when nothing has been fetched from the API", () => { render( { />, ) - expect(screen.getByTestId("model-picker-ids")).toHaveTextContent(Object.keys(fireworksModels).join(",")) + expect(screen.getByTestId("model-picker-ids")).toBeEmptyDOMElement() }) it("disables refresh without an API key and shows the get-key link", () => { diff --git a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index 6bf4b6637d..371929991b 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -32,7 +32,7 @@ import { friendliDefaultModelId, friendliModels, fireworksDefaultModelId, - fireworksModels, + fireworksUnknownModelInfo, basetenDefaultModelId, basetenModels, sambaNovaDefaultModelId, @@ -307,9 +307,16 @@ describe("useSelectedModel", () => { mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) }) - it("uses API-fetched metadata for a model missing from the static list", () => { + it("uses API-fetched metadata (including pricing) for the configured model", () => { const modelId = "accounts/fireworks/models/glm-5p3" - const modelInfo: ModelInfo = { contextWindow: 1_048_576, supportsPromptCache: false, supportsImages: true } + const modelInfo: ModelInfo = { + contextWindow: 1_048_576, + supportsPromptCache: true, + supportsImages: false, + inputPrice: 1.4, + outputPrice: 4.4, + cacheReadsPrice: 0.26, + } mockUseRouterModels.mockReturnValue( createRouterModelsResult({ [providerIdentifiers.fireworks]: { [modelId]: modelInfo } }), ) @@ -323,24 +330,46 @@ describe("useSelectedModel", () => { expect(result.current.info).toEqual(modelInfo) }) - it("prefers curated static metadata (with pricing) for known models", () => { + it("falls back to the default model id when none is configured", () => { + const modelInfo: ModelInfo = { contextWindow: 262_144, supportsPromptCache: false } mockUseRouterModels.mockReturnValue( - createRouterModelsResult({ - [providerIdentifiers.fireworks]: { - [fireworksDefaultModelId]: { contextWindow: 1, supportsPromptCache: false }, - }, - }), + createRouterModelsResult({ [providerIdentifiers.fireworks]: { [fireworksDefaultModelId]: modelInfo } }), + ) + + const { result } = renderHook(() => useSelectedModel({ apiProvider: providerIdentifiers.fireworks }), { + wrapper: createWrapper(), + }) + + expect(result.current.id).toBe(fireworksDefaultModelId) + expect(result.current.info).toEqual(modelInfo) + }) + + it("keeps a saved nondefault id while the model list is loading", () => { + const savedId = "accounts/fireworks/models/glm-5p3" + mockUseRouterModels.mockReturnValue(createRouterModelsResult(undefined, { isLoading: true })) + + const { result } = renderHook( + () => useSelectedModel({ apiProvider: providerIdentifiers.fireworks, apiModelId: savedId }), + { wrapper: createWrapper() }, ) + // Returning the default here would let ApiOptions persist it over the saved selection. + expect(result.current.id).toBe(savedId) + expect(result.current.info).toEqual(fireworksUnknownModelInfo) + expect(result.current.isLoading).toBe(true) + }) + + it("falls back to the default id while loading when nothing is saved", () => { + mockUseRouterModels.mockReturnValue(createRouterModelsResult(undefined, { isLoading: true })) + const { result } = renderHook(() => useSelectedModel({ apiProvider: providerIdentifiers.fireworks }), { wrapper: createWrapper(), }) expect(result.current.id).toBe(fireworksDefaultModelId) - expect(result.current.info).toEqual(fireworksModels[fireworksDefaultModelId]) }) - it("keeps an unknown configured id with sane default info when absent from both lists", () => { + it("keeps an unknown configured id with unpriced default info when absent from the fetched list", () => { mockUseRouterModels.mockReturnValue(createRouterModelsResult({ [providerIdentifiers.fireworks]: {} })) const { result } = renderHook( @@ -348,10 +377,24 @@ describe("useSelectedModel", () => { { wrapper: createWrapper() }, ) - // Mirrors FireworksHandler.getModel(), which falls back to sane defaults for - // custom ids so the UI shows sensible limits instead of an unknown model. + // Mirrors FireworksHandler.getModel(): sane limits, but no guessed price. expect(result.current.id).toBe("custom/model") - expect(result.current.info).toEqual(openAiModelInfoSaneDefaults) + expect(result.current.info).toEqual(fireworksUnknownModelInfo) + expect(result.current.info?.inputPrice).toBeUndefined() + }) + + it("treats inherited Object.prototype keys as custom ids with unpriced default info", () => { + mockUseRouterModels.mockReturnValue(createRouterModelsResult({ [providerIdentifiers.fireworks]: {} })) + + for (const apiModelId of ["constructor", "toString", "__proto__", "hasOwnProperty"]) { + const { result } = renderHook( + () => useSelectedModel({ apiProvider: providerIdentifiers.fireworks, apiModelId }), + { wrapper: createWrapper() }, + ) + + expect(result.current.id).toBe(apiModelId) + expect(result.current.info).toEqual(fireworksUnknownModelInfo) + } }) }) @@ -1945,19 +1988,13 @@ describe("useSelectedModel", () => { }) }) - describe("OpenAI-compatible static catalog providers (fireworks, baseten, sambanova)", () => { + describe("OpenAI-compatible static catalog providers (baseten, sambanova)", () => { beforeEach(() => { mockUseRouterModels.mockReturnValue(createRouterModelsResult({ openrouter: {}, requesty: {}, litellm: {} })) mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) }) const staticCatalogCases = [ - { - apiProvider: providerIdentifiers.fireworks, - defaultId: fireworksDefaultModelId, - models: fireworksModels as Record, - knownId: "accounts/fireworks/models/kimi-k2-instruct", - }, { apiProvider: providerIdentifiers.baseten, defaultId: basetenDefaultModelId, @@ -2011,7 +2048,7 @@ describe("useSelectedModel", () => { }, ) - it.each([providerIdentifiers.fireworks, providerIdentifiers.baseten, providerIdentifiers.sambanova])( + it.each([providerIdentifiers.baseten, providerIdentifiers.sambanova])( "honors a custom %s model ID with sane default metadata (not undefined info)", (apiProvider) => { // Regression: custom models had undefined info, so the task header fell back to a @@ -2027,7 +2064,7 @@ describe("useSelectedModel", () => { }, ) - describe.each([providerIdentifiers.fireworks, providerIdentifiers.baseten, providerIdentifiers.sambanova])( + describe.each([providerIdentifiers.baseten, providerIdentifiers.sambanova])( "%s inherited Object.prototype keys", (apiProvider) => { // An `in` check would match these keys on the prototype chain and return a function as `info`. diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index d89ba43f41..466f7c8ab6 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -21,9 +21,9 @@ import { openAiCodexModels, sambaNovaModels, getZAiModels, - fireworksModels, friendliModels, basetenModels, + fireworksUnknownModelInfo, qwenCodeModels, kimiCodeDefaultModelInfo, litellmDefaultModelInfo, @@ -66,7 +66,7 @@ function getValidatedModelId( /** * Resolves a static-catalog model for providers backed by `BaseOpenAiCompatibleProvider` - * (Fireworks, Baseten, SambaNova). Mirrors the backend `getModel()`: a user-supplied custom + * (Baseten, SambaNova). Mirrors the backend `getModel()`: a user-supplied custom * model ID that isn't in the static list is honored with `openAiModelInfoSaneDefaults`, so the * UI shows sensible limits (e.g. context window) instead of treating the selection as unknown. */ @@ -129,10 +129,8 @@ export const useSelectedModel = (apiConfiguration: ProviderSettings) => { // LiteLLM, IO Intelligence and Fireworks may legitimately have no entry in // the router payload (partial listing, failed fetch, renamed deployment, or // no API key yet) even though the configured ID is a valid selection, so - // they only need the fetch to settle. Fireworks additionally has a curated - // static catalog, so it can resolve before any catalog arrives. Other - // dynamic providers require a populated provider entry before the selection - // is resolved. + // they only need the fetch to settle. Other dynamic providers require a + // populated provider entry before the selection is resolved. const hasValidRouterData = needRouterModels && dynamicProvider ? dynamicProvider === providerIdentifiers.litellm || @@ -170,13 +168,17 @@ export const useSelectedModel = (apiConfiguration: ProviderSettings) => { getProviderDefaultModelId(providerIdentifiers.kimiCode, apiConfiguration), info: kimiCodeDefaultModelInfo, } - : { - id: getProviderDefaultModelId( - activeProvider ?? providerIdentifiers.openrouter, - apiConfiguration ?? {}, - ), - info: undefined, - } + : activeProvider === providerIdentifiers.fireworks && apiConfiguration?.apiModelId + ? // The catalog is still loading. Returning the default here would let the + // ApiOptions sync effect overwrite the user's saved nondefault selection. + { id: apiConfiguration.apiModelId, info: fireworksUnknownModelInfo } + : { + id: getProviderDefaultModelId( + activeProvider ?? providerIdentifiers.openrouter, + apiConfiguration ?? {}, + ), + info: undefined, + } return { provider, @@ -513,15 +515,16 @@ function getSelectedModel({ } case providerIdentifiers.sambanova: return getStaticCatalogModel(apiConfiguration.apiModelId, sambaNovaModels, defaultModelId) - case providerIdentifiers.fireworks: - // Curated static specs (with pricing) win over API-fetched metadata, which only - // fills in models the static map does not know. A configured id absent from both - // is still honoured (the backend sends it as-is) with sane defaults. - return getStaticCatalogModel( - apiConfiguration.apiModelId, - { ...routerModels[providerIdentifiers.fireworks], ...fireworksModels }, - defaultModelId, - ) + case providerIdentifiers.fireworks: { + // Model metadata and pricing come only from the Fireworks API. Keep the + // configured id even when it isn't listed (the backend honours custom ids) + // and fall back to the same unpriced defaults as FireworksHandler.getModel(). + const id = apiConfiguration.apiModelId || defaultModelId + const models = routerModels[providerIdentifiers.fireworks] + const info = + models && Object.prototype.hasOwnProperty.call(models, id) ? models[id] : fireworksUnknownModelInfo + return { id, info } + } case providerIdentifiers.friendli: { const id = apiConfiguration.apiModelId ?? defaultModelId const info = friendliModels[id as keyof typeof friendliModels]