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/__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..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, @@ -97,6 +96,7 @@ export const dynamicProviders = [ providerIdentifiers.poe, providerIdentifiers.deepseek, providerIdentifiers.moonshot, + providerIdentifiers.fireworks, providerIdentifiers.opencodeGo, providerIdentifiers.kenari, providerIdentifiers.nanogpt, @@ -457,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 480f965894..2daaf378ff 100644 --- a/src/api/providers/__tests__/fireworks.spec.ts +++ b/src/api/providers/__tests__/fireworks.spec.ts @@ -3,16 +3,15 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import { type FireworksModelId, fireworksDefaultModelId, fireworksModels } from "@roo-code/types" +import { type ModelInfo, fireworksDefaultModelId, providerIdentifiers } from "@roo-code/types" import { FireworksHandler } from "../fireworks" +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() -// Mock OpenAI module vi.mock("openai", () => ({ default: vi.fn(function () { return { @@ -25,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" }) }) @@ -81,451 +88,106 @@ describe("FireworksHandler", () => { expect(() => new FireworksHandler({})).toThrow("API key is required") }) - it("should return default model when no model is specified", () => { - const model = handler.getModel() - expect(model.id).toBe(fireworksDefaultModelId) - expect(model.info).toEqual(expect.objectContaining(fireworksModels[fireworksDefaultModelId])) - }) + it("should read model info from the key-scoped model cache", () => { + vi.mocked(getModelsFromCache).mockReturnValue({ [GLM_ID]: 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 handlerWithModel = new FireworksHandler({ apiModelId: GLM_ID, fireworksApiKey: "fw-key" }) const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual(expect.objectContaining(fireworksModels[testModelId])) - }) - - it("should omit max_tokens for a custom model", async () => { - const handlerWithCustomModel = new FireworksHandler({ - apiModelId: "accounts/fireworks/models/deepseek-v4p1-flash", - fireworksApiKey: "test-fireworks-api-key", - }) - - await collectStream(handlerWithCustomModel.createMessage("system prompt", [])) - expect(mockCreate.mock.calls[0][0].model).toBe("accounts/fireworks/models/deepseek-v4p1-flash") - expect(mockCreate.mock.calls[0][0].max_tokens).toBeUndefined() + expect(getModelsFromCache).toHaveBeenCalledWith( + { provider: providerIdentifiers.fireworks, apiKey: "fw-key" }, + handlerWithModel, + ) + expect(model).toEqual({ id: GLM_ID, info: pricedGlm }) }) - 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", - }) + it("should use the default model id when none is configured", () => { + vi.mocked(getModelsFromCache).mockReturnValue({ [fireworksDefaultModelId]: pricedGlm }) - await collectStream(handlerWithModel.createMessage("system prompt", [])) + const model = handler.getModel() - expect(mockCreate.mock.calls[0][0].max_tokens).toBe(16384) + expect(model.id).toBe(fireworksDefaultModelId) + expect(model.info).toEqual(pricedGlm) }) - 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, - }) - }) + 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 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"), - }), - ) - }) + await handlerWithModel.ensureModelFetched() - 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", + expect(getModels).toHaveBeenCalledWith( + { provider: providerIdentifiers.fireworks, apiKey: "fw-key" }, + handlerWithModel, + ) }) - 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 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, - }), - ) - }) + 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" }) - 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.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"), - }), - ) - }) + await handlerWithModel.ensureModelFetched() - 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", + expect(getModels).not.toHaveBeenCalled() }) - 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 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"), - }), - ) - }) + it("rejects with the caller's abort reason when already aborted", async () => { + const controller = new AbortController() + controller.abort(new Error("caller canceled")) - 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", + await expect(handler.ensureModelFetched(controller.signal)).rejects.toThrow("caller canceled") + expect(getModels).not.toHaveBeenCalled() }) - 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"), - }), - ) - }) - 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("detaches the waiter when the signal aborts during the fetch", async () => { + vi.mocked(getModels).mockReturnValue(new Promise(() => {})) + const controller = new AbortController() - 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 pending = handler.ensureModelFetched(controller.signal) + controller.abort() - 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", + await expect(pending).rejects.toThrow("The Fireworks request was aborted") }) - 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"), - }), - ) }) - 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 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() - 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"), - }), - ) + expect(model.id).toBe("accounts/fireworks/models/not-cached") + expect(model.info.maxTokens).toBeUndefined() + expect(model.info.supportsImages).toBe(false) + expect(model.info.inputPrice).toBeUndefined() + expect(model.info.outputPrice).toBeUndefined() }) - 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, + 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", }) - 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"), - }), - ) - }) - it("completePrompt method should return text from Fireworks API", async () => { - const expectedResponse = "This is a test response from Fireworks" - mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: expectedResponse } }] }) - const result = await handler.completePrompt("test prompt") - expect(result).toBe(expectedResponse) - }) - - it("should handle errors in completePrompt", async () => { - const errorMessage = "Fireworks API error" - mockCreate.mockRejectedValueOnce(new Error(errorMessage)) - await expect(handler.completePrompt("test prompt")).rejects.toThrow( - `Fireworks completion error: ${errorMessage}`, - ) - }) - - 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() + await collectStream(handlerWithCustomModel.createMessage("system prompt", [])) - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ type: "text", text: testContent }) + expect(mockCreate.mock.calls[0][0].model).toBe("accounts/fireworks/models/deepseek-v4p1-flash") + expect(mockCreate.mock.calls[0][0].max_tokens).toBeUndefined() }) - 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([])) + 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" }) 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() + await collectStream(handlerWithModel.createMessage(systemPrompt, messages)) expect(mockCreate).toHaveBeenCalledWith( expect.objectContaining({ - model: modelId, - max_tokens: modelInfo.maxTokens, + model: GLM_ID, + max_tokens: 16384, temperature: 0.5, messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]), stream: true, @@ -535,66 +197,55 @@ describe("FireworksHandler", () => { ) }) - 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", - }) + it("should use user-specified temperature over the provider default", async () => { + const handlerWithTemp = new FireworksHandler({ fireworksApiKey: "k", modelTemperature: 0.7 }) - mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) + await collectStream(handlerWithTemp.createMessage("system", [])) - const messageGenerator = handlerWithModel.createMessage("system", []) - await messageGenerator.next() + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ temperature: 0.7 }), undefined) + }) - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - temperature: 0.5, + 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 }, }), - 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 handlerWithModel = new FireworksHandler({ apiModelId: GLM_ID, fireworksApiKey: "k" }) - const messageGenerator = handlerWithModel.createMessage("system", []) - await messageGenerator.next() + const chunks = await collectStream(handlerWithModel.createMessage("system", [])) + const usage = chunks.find((chunk) => chunk.type === "usage") - // Model's defaultTemperature (1.0) should take precedence over provider's default (0.5) - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - temperature: 1.0, - }), - undefined, - ) + // 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 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, - }) + 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" }) - mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) + const chunks = await collectStream(handlerWithModel.createMessage("system", [])) + const usage = chunks.find((chunk) => chunk.type === "usage") - const messageGenerator = handlerWithModel.createMessage("system", []) - await messageGenerator.next() + expect(usage).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 5, totalCost: 0 }) + }) - // User-specified temperature should take precedence over everything - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - temperature: 0.7, - }), - undefined, + it("completePrompt method should return text from Fireworks API", async () => { + const expectedResponse = "This is a test response from Fireworks" + mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: expectedResponse } }] }) + const result = await handler.completePrompt("test prompt") + expect(result).toBe(expectedResponse) + }) + + it("should handle errors in completePrompt", async () => { + const errorMessage = "Fireworks API error" + mockCreate.mockRejectedValueOnce(new Error(errorMessage)) + await expect(handler.completePrompt("test prompt")).rejects.toThrow( + `Fireworks completion error: ${errorMessage}`, ) }) @@ -613,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 new file mode 100644 index 0000000000..b4bcd4dd62 --- /dev/null +++ b/src/api/providers/fetchers/__tests__/fireworks.spec.ts @@ -0,0 +1,419 @@ +// 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, +}) + +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), +}) + +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)], + }, + ], + }, + ], + }) + + 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("builds model info from the models endpoint and attaches serverlessModels pricing", async () => { + const fetchSpy = mockFetchRoutes(defaultRoutes()) + + const models = await getFireworksModels("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 }, + ]), + ) + + 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("keeps models without a price and leaves their cost unknown", async () => { + mockFetchRoutes( + defaultRoutes([{ id: "accounts/fireworks/models/inkling", supports_chat: true, context_length: 8192 }]), + ) + + const models = await getFireworksModels("fw-key") + + expect(models["accounts/fireworks/models/inkling"]).toEqual({ + maxTokens: 8192, + contextWindow: 8192, + supportsImages: false, + supportsPromptCache: false, + supportsMaxTokens: true, + }) + }) + + 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") + + // 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("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"].inputPrice).toBe(1.4) + expect(models["accounts/fireworks/models/kimi-k3"]).toMatchObject({ + inputPrice: 1, + outputPrice: 5, + supportsPromptCache: false, + }) + expect(fetchSpy.mock.calls.filter(([input]) => String(input).startsWith(SERVERLESS_URL_PREFIX))).toHaveLength(2) + }) + + 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/models/glm-5p3"]).toBeDefined() + expect(models["accounts/fireworks/models/glm-5p3"].inputPrice).toBeUndefined() + }) + + 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, + glmModel, + ]), + ) + + const models = await getFireworksModels("fw-key") + + expect(Object.keys(models)).toEqual(["accounts/fireworks/models/glm-5p3"]) + }) + + 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 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 every request", async () => { + const fetchSpy = mockFetchRoutes(defaultRoutes()) + const controller = new AbortController() + + await getFireworksModels("fw-key", { 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 () => { + 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..6cd4f5a1c6 --- /dev/null +++ b/src/api/providers/fetchers/fireworks.ts @@ -0,0 +1,309 @@ +import type { ModelInfo, ModelRecord } 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 = `${FIREWORKS_INFERENCE_BASE_URL}/models` +export const FIREWORKS_ACCOUNTS_URL = `${FIREWORKS_API_BASE_URL}/v1/accounts` + +// Fallbacks for fields the API omits. +const FIREWORKS_FALLBACK_CONTEXT_WINDOW = 128_000 + +// 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 + 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 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. + */ +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, + contextWindow, + supportsImages: model.supports_image_input === true, + 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. + * + * - 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 signal = opts?.signal + + const [response, pricing] = await Promise.all([ + fetch(FIREWORKS_MODELS_URL, { headers: authHeaders(apiKey), signal }), + getFireworksPricing(apiKey, signal), + ]) + + if (!response.ok) { + throwIfAborted(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 + + if (model.supports_chat === false || model.kind === "EMBEDDING_MODEL") continue + + 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 db29e7bf3f..fa74fc4477 100644 --- a/src/api/providers/fireworks.ts +++ b/src/api/providers/fireworks.ts @@ -1,19 +1,85 @@ -import { type FireworksModelId, fireworksDefaultModelId, fireworksModels } from "@roo-code/types" +import { + fireworksDefaultModelId, + fireworksUnknownModelInfo, + providerIdentifiers, + type ModelRecord, + FIREWORKS_INFERENCE_BASE_URL, +} 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 { getModels, getModelsFromCache } from "./fetchers/modelCache" +import type { ModelCacheScope } from "../index" +import { rejectOnAbort } from "./utils/abort-signal" -export class FireworksHandler extends BaseOpenAiCompatibleProvider { +import { getFireworksModels } from "./fetchers/fireworks" + +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 id = this.options.apiModelId || fireworksDefaultModelId + const cached = getModelsFromCache( + { + provider: providerIdentifiers.fireworks, + apiKey: this.options.fireworksApiKey, + }, + this, + ) + + 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/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/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 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 +197,29 @@ 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]) + 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..8ec5caa14e --- /dev/null +++ b/webview-ui/src/components/settings/providers/__tests__/Fireworks.spec.tsx @@ -0,0 +1,714 @@ +// 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, + 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("shows no models when nothing has been fetched from the API", () => { + render( + , + ) + + expect(screen.getByTestId("model-picker-ids")).toBeEmptyDOMElement() + }) + + 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..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, @@ -302,6 +302,102 @@ describe("useSelectedModel", () => { }, ) + describe("Fireworks model selection", () => { + beforeEach(() => { + mockUseOpenRouterModelProviders.mockReturnValue(createOpenRouterModelProvidersResult({})) + }) + + 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: true, + supportsImages: false, + inputPrice: 1.4, + outputPrice: 4.4, + cacheReadsPrice: 0.26, + } + 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("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]: 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) + }) + + it("keeps an unknown configured id with unpriced default info when absent from the fetched list", () => { + mockUseRouterModels.mockReturnValue(createRouterModelsResult({ [providerIdentifiers.fireworks]: {} })) + + const { result } = renderHook( + () => useSelectedModel({ apiProvider: providerIdentifiers.fireworks, apiModelId: "custom/model" }), + { wrapper: createWrapper() }, + ) + + // Mirrors FireworksHandler.getModel(): sane limits, but no guessed price. + expect(result.current.id).toBe("custom/model") + 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) + } + }) + }) + it("uses router data for Poe", () => { const modelInfo: ModelInfo = { contextWindow: 42_000, supportsPromptCache: false } mockUseRouterModels.mockReturnValue( @@ -1892,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, @@ -1958,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 @@ -1974,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`. @@ -2152,6 +2242,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..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. */ @@ -126,14 +126,16 @@ 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. 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 && @@ -166,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, @@ -509,8 +515,16 @@ function getSelectedModel({ } case providerIdentifiers.sambanova: return getStaticCatalogModel(apiConfiguration.apiModelId, sambaNovaModels, defaultModelId) - case providerIdentifiers.fireworks: - return getStaticCatalogModel(apiConfiguration.apiModelId, 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] 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 = {