diff --git a/.changeset/add-fireworks-provider.md b/.changeset/add-fireworks-provider.md new file mode 100644 index 00000000..b33094f6 --- /dev/null +++ b/.changeset/add-fireworks-provider.md @@ -0,0 +1,8 @@ +--- +"@perstack/core": patch +"@perstack/runtime": patch +"@perstack/fireworks-provider": patch +"perstack": patch +--- + +feat: add Fireworks AI provider diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 43c61b9b..a531ef6f 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -79,6 +79,7 @@ jobs: EXA_API_KEY: ${{ secrets.EXA_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }} + FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }} PERSTACK_API_KEY: ${{ secrets.PERSTACK_STAGING_API_KEY }} # Gate job for branch protection: required check that passes when E2E is diff --git a/bun.lock b/bun.lock index 9796aab9..b8dcd629 100644 --- a/bun.lock +++ b/bun.lock @@ -18,10 +18,10 @@ }, "apps/base": { "name": "@perstack/base", - "version": "0.0.70", + "version": "0.0.73", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", - "@perstack/core": "0.0.58", + "@perstack/core": "0.0.60", "commander": "^14.0.3", "zod": "^4.3.6", }, @@ -49,7 +49,7 @@ }, "apps/perstack": { "name": "perstack", - "version": "0.0.100", + "version": "0.0.111", "dependencies": { "commander": "^14.0.3", }, @@ -67,7 +67,7 @@ }, "packages/core": { "name": "@perstack/core", - "version": "0.0.58", + "version": "0.0.60", "dependencies": { "@paralleldrive/cuid2": "^3.3.0", "zod": "^4.3.6", @@ -80,7 +80,7 @@ }, "packages/filesystem": { "name": "@perstack/filesystem-storage", - "version": "0.0.29", + "version": "0.0.31", "dependencies": { "@perstack/core": "workspace:*", }, @@ -93,7 +93,7 @@ }, "packages/installer": { "name": "@perstack/installer", - "version": "0.0.23", + "version": "0.0.29", "dependencies": { "@perstack/api-client": "^0.0.57", "@perstack/core": "workspace:*", @@ -109,7 +109,7 @@ }, "packages/log": { "name": "@perstack/log", - "version": "0.0.15", + "version": "0.0.17", "dependencies": { "@perstack/core": "workspace:*", "@perstack/filesystem-storage": "workspace:*", @@ -122,7 +122,7 @@ }, "packages/perstack-toml": { "name": "@perstack/perstack-toml", - "version": "0.0.14", + "version": "0.0.17", "dependencies": { "@perstack/core": "workspace:*", "smol-toml": "^1.6.0", @@ -136,7 +136,7 @@ }, "packages/providers/anthropic": { "name": "@perstack/anthropic-provider", - "version": "0.0.32", + "version": "0.0.34", "dependencies": { "@ai-sdk/anthropic": "^3.0.47", "@perstack/core": "workspace:*", @@ -151,7 +151,7 @@ }, "packages/providers/azure-openai": { "name": "@perstack/azure-openai-provider", - "version": "0.0.31", + "version": "0.0.33", "dependencies": { "@ai-sdk/azure": "^3.0.31", "@perstack/core": "workspace:*", @@ -166,7 +166,7 @@ }, "packages/providers/bedrock": { "name": "@perstack/bedrock-provider", - "version": "0.0.31", + "version": "0.0.33", "dependencies": { "@ai-sdk/amazon-bedrock": "^4.0.60", "@perstack/core": "workspace:*", @@ -181,7 +181,7 @@ }, "packages/providers/core": { "name": "@perstack/provider-core", - "version": "0.0.31", + "version": "0.0.33", "dependencies": { "@perstack/core": "workspace:*", "undici": "^7.22.0", @@ -195,7 +195,7 @@ }, "packages/providers/deepseek": { "name": "@perstack/deepseek-provider", - "version": "0.0.31", + "version": "0.0.33", "dependencies": { "@ai-sdk/deepseek": "^2.0.20", "@perstack/core": "workspace:*", @@ -208,9 +208,24 @@ "typescript": "^5.9.3", }, }, + "packages/providers/fireworks": { + "name": "@perstack/fireworks-provider", + "version": "0.0.1", + "dependencies": { + "@ai-sdk/fireworks": "^2.0.36", + "@perstack/core": "workspace:*", + "@perstack/provider-core": "workspace:*", + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.5", + "@types/node": "^25.3.0", + "ai": "^6.0.86", + "typescript": "^5.9.3", + }, + }, "packages/providers/google": { "name": "@perstack/google-provider", - "version": "0.0.31", + "version": "0.0.33", "dependencies": { "@ai-sdk/google": "^3.0.29", "@perstack/core": "workspace:*", @@ -225,7 +240,7 @@ }, "packages/providers/ollama": { "name": "@perstack/ollama-provider", - "version": "0.0.31", + "version": "0.0.33", "dependencies": { "@perstack/core": "workspace:*", "@perstack/provider-core": "workspace:*", @@ -240,7 +255,7 @@ }, "packages/providers/openai": { "name": "@perstack/openai-provider", - "version": "0.0.31", + "version": "0.0.33", "dependencies": { "@ai-sdk/openai": "^3.0.29", "@perstack/core": "workspace:*", @@ -255,7 +270,7 @@ }, "packages/providers/vertex": { "name": "@perstack/vertex-provider", - "version": "0.0.31", + "version": "0.0.33", "dependencies": { "@ai-sdk/google-vertex": "^4.0.58", "@perstack/core": "workspace:*", @@ -270,7 +285,7 @@ }, "packages/react": { "name": "@perstack/react", - "version": "0.0.62", + "version": "0.0.64", "dependencies": { "@perstack/core": "0.0.58", }, @@ -289,20 +304,21 @@ }, "packages/runtime": { "name": "@perstack/runtime", - "version": "0.0.121", + "version": "0.0.126", "dependencies": { "@ai-sdk/amazon-bedrock": "^4.0.60", "@ai-sdk/anthropic": "^3.0.44", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/deepseek": "^2.0.20", + "@ai-sdk/fireworks": "^2.0.36", "@ai-sdk/google": "^3.0.29", "@ai-sdk/google-vertex": "^4.0.58", "@ai-sdk/openai": "^3.0.29", "@modelcontextprotocol/sdk": "^1.26.0", "@paralleldrive/cuid2": "^3.3.0", "@perstack/api-client": "^0.0.57", - "@perstack/base": "0.0.70", - "@perstack/core": "0.0.58", + "@perstack/base": "0.0.73", + "@perstack/core": "0.0.60", "ai": "^6.0.86", "ollama-ai-provider-v2": "^3.3.0", "smol-toml": "^1.6.0", @@ -315,6 +331,7 @@ "@perstack/azure-openai-provider": "workspace:*", "@perstack/bedrock-provider": "workspace:*", "@perstack/deepseek-provider": "workspace:*", + "@perstack/fireworks-provider": "workspace:*", "@perstack/google-provider": "workspace:*", "@perstack/ollama-provider": "workspace:*", "@perstack/openai-provider": "workspace:*", @@ -328,7 +345,7 @@ }, "packages/skill-manager": { "name": "@perstack/skill-manager", - "version": "0.0.16", + "version": "0.0.20", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", "@paralleldrive/cuid2": "^3.3.0", @@ -344,7 +361,7 @@ }, "packages/studio": { "name": "@perstack/studio", - "version": "0.0.2", + "version": "0.0.10", "dependencies": { "@perstack/api-client": "^0.0.57", "@perstack/core": "workspace:*", @@ -359,7 +376,7 @@ }, "packages/tui": { "name": "@perstack/tui", - "version": "0.0.20", + "version": "0.0.23", "dependencies": { "@paralleldrive/cuid2": "^3.3.0", "@perstack/core": "workspace:*", @@ -376,7 +393,7 @@ }, "packages/tui-components": { "name": "@perstack/tui-components", - "version": "0.0.22", + "version": "0.0.25", "dependencies": { "@perstack/core": "workspace:*", "@perstack/react": "workspace:*", @@ -400,6 +417,8 @@ "@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.20", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MAL04sDTOWUiBjAGWaVgyeE4bYRb9QpKYRlIeCTZFga6I8yQs50XakhWEssrmvVihdpHGkqpDtCHsFqCydsWLA=="], + "@ai-sdk/fireworks": ["@ai-sdk/fireworks@2.0.36", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.31", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.16" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hSZUcC25H+c3lcNrJWt9jCvmnzUidgysukVzawupTDxq+CDIv9Q4mPy9QzkqUaaBDZ1w9fsz2UdUq4aev95VeA=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-QT3FEoNARMRlk8JJVR7L98exiK9C8AGfrEJVbRxBT1yIXKs/N19o/+PsjTRVsARgDJNcy9JbJp1FspKucEat0Q=="], "@ai-sdk/google": ["@ai-sdk/google@3.0.30", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZzG6dU0XUSSXbxQJJTQUFpWeKkfzdpR7IykEZwaiaW5d+3u3RZ/zkRiGwAOcUpLp6k0eMd+IJF4looJv21ecxw=="], @@ -408,6 +427,8 @@ "@ai-sdk/openai": ["@ai-sdk/openai@3.0.30", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YDht3t7TDyWKP+JYZp20VuYqSjyF2brHYh47GGFDUPf2wZiqNQ263ecL+quar2bP3GZ3BeQA8f0m2B7UwLPR+g=="], + "@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.16" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-e78xiImcTe2aCMQoFbVJluQmUV4XgahOmmehAuRPlcwzRv2KtkvuLCXPC9Xcy2u83e8SimVva9k9G8SvZcnaBA=="], + "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.15", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w=="], @@ -618,6 +639,8 @@ "@perstack/filesystem-storage": ["@perstack/filesystem-storage@workspace:packages/filesystem"], + "@perstack/fireworks-provider": ["@perstack/fireworks-provider@workspace:packages/providers/fireworks"], + "@perstack/google-provider": ["@perstack/google-provider@workspace:packages/providers/google"], "@perstack/installer": ["@perstack/installer@workspace:packages/installer"], @@ -1324,8 +1347,12 @@ "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zXJPiNHaIiQ6XUqLeSYZ3ZbSzjqt1pNWEUf2hlkXlmmw8IF8KI0ruuGaDwKCExmtuNRf0E4TDxhsc9wRgWTzpw=="], + "@ai-sdk/fireworks/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.16", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kBvDqNkt5EwlzF9FujmNhhtl8FYg3e8FO8P5uneKliqfRThWemzBj+wfYr7ZCymAQhTRnwSSz1/SOqhOAwmx9g=="], + "@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zXJPiNHaIiQ6XUqLeSYZ3ZbSzjqt1pNWEUf2hlkXlmmw8IF8KI0ruuGaDwKCExmtuNRf0E4TDxhsc9wRgWTzpw=="], + "@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.16", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kBvDqNkt5EwlzF9FujmNhhtl8FYg3e8FO8P5uneKliqfRThWemzBj+wfYr7ZCymAQhTRnwSSz1/SOqhOAwmx9g=="], + "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], "@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], diff --git a/e2e/lib/prerequisites.ts b/e2e/lib/prerequisites.ts index beb51bc0..88eea6ed 100644 --- a/e2e/lib/prerequisites.ts +++ b/e2e/lib/prerequisites.ts @@ -9,3 +9,7 @@ export function hasAnthropicKey(): boolean { export function hasGoogleKey(): boolean { return !!process.env.GOOGLE_GENERATIVE_AI_API_KEY } + +export function hasFireworksKey(): boolean { + return !!process.env.FIREWORKS_API_KEY +} diff --git a/e2e/perstack-cli/providers.test.ts b/e2e/perstack-cli/providers.test.ts index e1bcdb47..bd4445b7 100644 --- a/e2e/perstack-cli/providers.test.ts +++ b/e2e/perstack-cli/providers.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "bun:test" import { assertEventSequenceContains } from "../lib/assertions.js" -import { hasAnthropicKey, hasGoogleKey, hasOpenAIKey } from "../lib/prerequisites.js" +import { + hasAnthropicKey, + hasFireworksKey, + hasGoogleKey, + hasOpenAIKey, +} from "../lib/prerequisites.js" import { runCli, withEventParsing } from "../lib/runner.js" const CONFIG = "./e2e/experts/providers.toml" @@ -10,6 +15,7 @@ const providers = [ { provider: "openai", hasKey: hasOpenAIKey }, { provider: "anthropic", hasKey: hasAnthropicKey }, { provider: "google", hasKey: hasGoogleKey }, + { provider: "fireworks", hasKey: hasFireworksKey }, ] describe.concurrent("LLM Providers", () => { diff --git a/packages/core/src/known-models/index.ts b/packages/core/src/known-models/index.ts index 8625d119..2dc5b0b0 100644 --- a/packages/core/src/known-models/index.ts +++ b/packages/core/src/known-models/index.ts @@ -200,6 +200,32 @@ export const knownModels = [ }, ], }, + { + provider: "fireworks", + models: [ + // https://fireworks.ai/models/fireworks/kimi-k2p5 + { + name: "accounts/fireworks/models/kimi-k2p5", + tier: "high" as const, + contextWindow: 262_144, + maxOutputTokens: 262_144, + }, + // https://fireworks.ai/models/fireworks/deepseek-v3p2 + { + name: "accounts/fireworks/models/deepseek-v3p2", + tier: "high" as const, + contextWindow: 163_840, + maxOutputTokens: 163_840, + }, + // https://fireworks.ai/models/fireworks/glm-5 + { + name: "accounts/fireworks/models/glm-5", + tier: "high" as const, + contextWindow: 202_752, + maxOutputTokens: 202_752, + }, + ], + }, { provider: "ollama", models: [ diff --git a/packages/core/src/known-models/model-tiers.test.ts b/packages/core/src/known-models/model-tiers.test.ts index e5481e53..d404f75d 100644 --- a/packages/core/src/known-models/model-tiers.test.ts +++ b/packages/core/src/known-models/model-tiers.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test" import { knownModels } from "./index.js" -import { modelTierSchema, resolveModelTier } from "./model-tiers.js" +import { modelTierSchema, resolveModelTier, resolveModelTierWithFallback } from "./model-tiers.js" describe("@perstack/core: modelTierSchema", () => { it("accepts valid tier values", () => { @@ -61,6 +61,12 @@ describe("@perstack/core: resolveModelTier", () => { expect(resolveModelTier("deepseek", "low")).toBeUndefined() }) + it("resolves fireworks tiers", () => { + expect(resolveModelTier("fireworks", "high")).toBe("accounts/fireworks/models/kimi-k2p5") + expect(resolveModelTier("fireworks", "middle")).toBeUndefined() + expect(resolveModelTier("fireworks", "low")).toBeUndefined() + }) + it("every model in knownModels has a valid tier", () => { const validTiers = new Set(["low", "middle", "high"]) for (const provider of knownModels) { @@ -70,3 +76,18 @@ describe("@perstack/core: resolveModelTier", () => { } }) }) + +describe("@perstack/core: resolveModelTierWithFallback", () => { + it("returns high tier model for providers with all tiers", () => { + expect(resolveModelTierWithFallback("anthropic")).toBe("claude-opus-4-6") + }) + + it("returns high tier model for provider with only high tier", () => { + expect(resolveModelTierWithFallback("fireworks")).toBe("accounts/fireworks/models/kimi-k2p5") + }) + + it("cascades from high to middle when high is missing", () => { + // deepseek has no "low" but has "middle" and "high" + expect(resolveModelTierWithFallback("deepseek")).toBe("deepseek-reasoner") + }) +}) diff --git a/packages/core/src/known-models/model-tiers.ts b/packages/core/src/known-models/model-tiers.ts index 24a64e24..e6854e6a 100644 --- a/packages/core/src/known-models/model-tiers.ts +++ b/packages/core/src/known-models/model-tiers.ts @@ -28,3 +28,17 @@ export function resolveModelTier(providerName: ProviderName, tier: ModelTier): s const providerModels = knownModels.find((p) => p.provider === lookupProvider) return providerModels?.models.find((m) => m.tier === tier)?.name } + +const TIER_CASCADE: ModelTier[] = ["high", "middle", "low"] + +/** + * Resolve a model by cascading through tiers from high to low. + * Used as the final fallback when no explicit model or tier-specific match is found. + */ +export function resolveModelTierWithFallback(providerName: ProviderName): string | undefined { + for (const tier of TIER_CASCADE) { + const model = resolveModelTier(providerName, tier) + if (model) return model + } + return undefined +} diff --git a/packages/core/src/schemas/provider-config.ts b/packages/core/src/schemas/provider-config.ts index 35b05673..317cf17c 100644 --- a/packages/core/src/schemas/provider-config.ts +++ b/packages/core/src/schemas/provider-config.ts @@ -15,6 +15,7 @@ export type ProviderName = | "amazon-bedrock" | "google-vertex" | "deepseek" + | "fireworks" export const providerNameSchema = z.enum([ "anthropic", @@ -25,6 +26,7 @@ export const providerNameSchema = z.enum([ "amazon-bedrock", "google-vertex", "deepseek", + "fireworks", ]) /** Anthropic provider configuration */ @@ -200,6 +202,25 @@ export const deepseekProviderConfigSchema = z.object({ }) deepseekProviderConfigSchema satisfies z.ZodType +/** Fireworks AI provider configuration */ +export interface FireworksProviderConfig { + providerName: "fireworks" + /** API key for Fireworks AI */ + apiKey: string + /** Custom base URL */ + baseUrl?: string + /** Custom headers */ + headers?: Headers +} + +export const fireworksProviderConfigSchema = z.object({ + providerName: z.literal(providerNameSchema.enum.fireworks), + apiKey: z.string(), + baseUrl: z.string().optional(), + headers: headersSchema, +}) +fireworksProviderConfigSchema satisfies z.ZodType + /** Union of all provider configurations */ export type ProviderConfig = | AnthropicProviderConfig @@ -210,6 +231,7 @@ export type ProviderConfig = | AmazonBedrockProviderConfig | GoogleVertexProviderConfig | DeepseekProviderConfig + | FireworksProviderConfig export const providerConfigSchema = z.discriminatedUnion("providerName", [ anthropicProviderConfigSchema, @@ -220,4 +242,5 @@ export const providerConfigSchema = z.discriminatedUnion("providerName", [ amazonBedrockProviderConfigSchema, googleVertexProviderConfigSchema, deepseekProviderConfigSchema, + fireworksProviderConfigSchema, ]) diff --git a/packages/providers/fireworks/package.json b/packages/providers/fireworks/package.json new file mode 100644 index 00000000..a778fb84 --- /dev/null +++ b/packages/providers/fireworks/package.json @@ -0,0 +1,43 @@ +{ + "name": "@perstack/fireworks-provider", + "private": true, + "version": "0.0.1", + "description": "Fireworks AI provider adapter for Perstack", + "author": "Wintermute Technologies, Inc.", + "license": "Apache-2.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "import": "./dist/src/index.js" + } + } + }, + "files": [ + "dist" + ], + "scripts": { + "clean": "rm -rf dist", + "build": "bun run clean && tsdown --config ../../../tsdown.config.ts", + "typecheck": "tsc --noEmit", + "test": "bun test" + }, + "dependencies": { + "@ai-sdk/fireworks": "^2.0.36", + "@perstack/core": "workspace:*", + "@perstack/provider-core": "workspace:*" + }, + "devDependencies": { + "@tsconfig/node22": "^22.0.5", + "@types/node": "^25.3.0", + "ai": "^6.0.86", + "typescript": "^5.9.3" + }, + "engines": { + "bun": ">=1.2.0" + } +} diff --git a/packages/providers/fireworks/src/adapter.test.ts b/packages/providers/fireworks/src/adapter.test.ts new file mode 100644 index 00000000..4ec9f591 --- /dev/null +++ b/packages/providers/fireworks/src/adapter.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "bun:test" +import type { FireworksProviderConfig } from "@perstack/core" +import { FireworksProviderAdapter } from "./adapter.js" + +const mockConfig: FireworksProviderConfig = { + providerName: "fireworks", + apiKey: "test-api-key", +} + +describe("FireworksProviderAdapter", () => { + describe("providerName", () => { + it("returns fireworks", () => { + const adapter = new FireworksProviderAdapter(mockConfig) + expect(adapter.providerName).toBe("fireworks") + }) + }) + + describe("createModel", () => { + it("creates a language model", () => { + const adapter = new FireworksProviderAdapter(mockConfig) + const model = adapter.createModel("accounts/fireworks/models/firefunction-v1") + expect(model).toBeDefined() + }) + }) + + describe("getProviderTools", () => { + it("returns empty object for any tool names (no tools supported)", () => { + const adapter = new FireworksProviderAdapter(mockConfig) + const tools = adapter.getProviderTools(["webSearch"]) + expect(tools).toEqual({}) + }) + }) +}) diff --git a/packages/providers/fireworks/src/adapter.ts b/packages/providers/fireworks/src/adapter.ts new file mode 100644 index 00000000..8a200b4c --- /dev/null +++ b/packages/providers/fireworks/src/adapter.ts @@ -0,0 +1,39 @@ +import { createFireworks } from "@ai-sdk/fireworks" +import type { FireworksProviderConfig } from "@perstack/core" +import { + BaseProviderAdapter, + type ProviderAdapterOptions, + type ProviderError, +} from "@perstack/provider-core" +import type { LanguageModel } from "ai" +import { isFireworksRetryable, normalizeFireworksError } from "./errors.js" + +export class FireworksProviderAdapter extends BaseProviderAdapter { + readonly providerName = "fireworks" as const + private readonly client: ReturnType + + constructor( + readonly config: FireworksProviderConfig, + options?: ProviderAdapterOptions, + ) { + super(options) + this.client = createFireworks({ + apiKey: config.apiKey, + baseURL: config.baseUrl, + headers: config.headers, + fetch: options?.proxyUrl ? this.createProxyFetch(options.proxyUrl) : undefined, + }) + } + + override createModel(modelId: string): LanguageModel { + return this.client(modelId) + } + + override normalizeError(error: unknown): ProviderError { + return normalizeFireworksError(error) + } + + override isRetryable(error: unknown): boolean { + return isFireworksRetryable(error) + } +} diff --git a/packages/providers/fireworks/src/errors.ts b/packages/providers/fireworks/src/errors.ts new file mode 100644 index 00000000..dcfdf55b --- /dev/null +++ b/packages/providers/fireworks/src/errors.ts @@ -0,0 +1,50 @@ +import type { ProviderError } from "@perstack/provider-core" +import { APICallError } from "ai" + +export function normalizeFireworksError(error: unknown): ProviderError { + if (error instanceof APICallError) { + return { + name: error.name, + message: error.message, + statusCode: error.statusCode, + isRetryable: isFireworksRetryable(error), + provider: "fireworks", + originalError: error, + } + } + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + isRetryable: isFireworksRetryable(error), + provider: "fireworks", + originalError: error, + } + } + return { + name: "UnknownError", + message: String(error), + isRetryable: false, + provider: "fireworks", + originalError: error, + } +} + +export function isFireworksRetryable(error: unknown): boolean { + if (error instanceof APICallError) { + if (error.isRetryable) return true + const statusCode = error.statusCode + if (statusCode === 429) return true + if (statusCode === 500) return true + if (statusCode === 502) return true + if (statusCode === 503) return true + if (statusCode === 504) return true + } + if (error instanceof Error) { + const message = error.message.toLowerCase() + if (message.includes("rate limit")) return true + if (message.includes("timeout")) return true + if (message.includes("service unavailable")) return true + } + return false +} diff --git a/packages/providers/fireworks/src/index.ts b/packages/providers/fireworks/src/index.ts new file mode 100644 index 00000000..e7b49b98 --- /dev/null +++ b/packages/providers/fireworks/src/index.ts @@ -0,0 +1 @@ +export { FireworksProviderAdapter } from "./adapter.js" diff --git a/packages/providers/fireworks/tsconfig.json b/packages/providers/fireworks/tsconfig.json new file mode 100644 index 00000000..563aebf5 --- /dev/null +++ b/packages/providers/fireworks/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "@tsconfig/node22/tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": ".", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "exactOptionalPropertyTypes": false, + "noEmit": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 08a73bf7..f44d81b3 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -31,6 +31,7 @@ "@ai-sdk/anthropic": "^3.0.44", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/deepseek": "^2.0.20", + "@ai-sdk/fireworks": "^2.0.36", "@ai-sdk/google": "^3.0.29", "@ai-sdk/google-vertex": "^4.0.58", "@ai-sdk/openai": "^3.0.29", @@ -52,6 +53,7 @@ "@perstack/azure-openai-provider": "workspace:*", "@perstack/bedrock-provider": "workspace:*", "@perstack/deepseek-provider": "workspace:*", + "@perstack/fireworks-provider": "workspace:*", "@perstack/google-provider": "workspace:*", "@perstack/ollama-provider": "workspace:*", "@perstack/openai-provider": "workspace:*", diff --git a/packages/runtime/src/helpers/model.ts b/packages/runtime/src/helpers/model.ts index c433590b..3e7e0b8b 100644 --- a/packages/runtime/src/helpers/model.ts +++ b/packages/runtime/src/helpers/model.ts @@ -2,6 +2,7 @@ import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock" import { createAnthropic } from "@ai-sdk/anthropic" import { createAzure } from "@ai-sdk/azure" import { createDeepSeek } from "@ai-sdk/deepseek" +import { createFireworks } from "@ai-sdk/fireworks" import { createGoogleGenerativeAI } from "@ai-sdk/google" import { createVertex } from "@ai-sdk/google-vertex" import { createOpenAI } from "@ai-sdk/openai" @@ -114,6 +115,15 @@ export function getModel( }) return deepseek(modelId) } + case "fireworks": { + const fireworks = createFireworks({ + apiKey: providerConfig.apiKey, + baseURL: providerConfig.baseUrl, + headers: providerConfig.headers, + fetch: customFetch, + }) + return fireworks(modelId) + } default: { const _exhaustive: never = providerConfig throw new Error(`Unknown provider: ${(_exhaustive as ProviderConfig).providerName}`) diff --git a/packages/runtime/src/helpers/provider-adapter-factory.ts b/packages/runtime/src/helpers/provider-adapter-factory.ts index 9a33948d..8a282da4 100644 --- a/packages/runtime/src/helpers/provider-adapter-factory.ts +++ b/packages/runtime/src/helpers/provider-adapter-factory.ts @@ -16,6 +16,7 @@ const PROVIDER_PACKAGE_NAMES: Record = { "amazon-bedrock": "bedrock-provider", "google-vertex": "vertex-provider", deepseek: "deepseek-provider", + fireworks: "fireworks-provider", } // Module-level state for provider adapter factory diff --git a/packages/runtime/src/helpers/register-providers.ts b/packages/runtime/src/helpers/register-providers.ts index 2bede85a..43deafc2 100644 --- a/packages/runtime/src/helpers/register-providers.ts +++ b/packages/runtime/src/helpers/register-providers.ts @@ -3,6 +3,7 @@ import { AzureOpenAIProviderAdapter } from "@perstack/azure-openai-provider" import { BedrockProviderAdapter } from "@perstack/bedrock-provider" import type { ProviderConfig } from "@perstack/core" import { DeepseekProviderAdapter } from "@perstack/deepseek-provider" +import { FireworksProviderAdapter } from "@perstack/fireworks-provider" import { GoogleProviderAdapter } from "@perstack/google-provider" import { OllamaProviderAdapter } from "@perstack/ollama-provider" import { OpenAIProviderAdapter } from "@perstack/openai-provider" @@ -54,3 +55,8 @@ registerProviderAdapter( "deepseek", async () => DeepseekProviderAdapter as unknown as GenericAdapterConstructor, ) + +registerProviderAdapter( + "fireworks", + async () => FireworksProviderAdapter as unknown as GenericAdapterConstructor, +) diff --git a/packages/runtime/src/orchestration/coordinator-executor.test.ts b/packages/runtime/src/orchestration/coordinator-executor.test.ts index 18a29ca6..c7facd9f 100644 --- a/packages/runtime/src/orchestration/coordinator-executor.test.ts +++ b/packages/runtime/src/orchestration/coordinator-executor.test.ts @@ -375,7 +375,7 @@ describe("@perstack/runtime: coordinator-executor", () => { ) }) - it("falls back to middle tier when neither model nor expert tier exists", async () => { + it("falls back to high tier when neither model nor expert tier exists", async () => { const { executeStateMachine } = await import("../state-machine/index.js") mockSetupExperts.mockResolvedValueOnce({ expertToRun: { @@ -398,10 +398,10 @@ describe("@perstack/runtime: coordinator-executor", () => { await executor.execute(setting) - // "middle" tier for anthropic resolves to claude-sonnet-4-5 + // Cascades high → middle → low; "high" tier for anthropic resolves to claude-opus-4-6 expect(executeStateMachine).toHaveBeenCalledWith( expect.objectContaining({ - setting: expect.objectContaining({ model: "claude-sonnet-4-5" }), + setting: expect.objectContaining({ model: "claude-opus-4-6" }), }), ) }) diff --git a/packages/runtime/src/orchestration/coordinator-executor.ts b/packages/runtime/src/orchestration/coordinator-executor.ts index 3e99ae9b..41778d50 100644 --- a/packages/runtime/src/orchestration/coordinator-executor.ts +++ b/packages/runtime/src/orchestration/coordinator-executor.ts @@ -9,6 +9,7 @@ import { type RunSetting, type RuntimeEvent, resolveModelTier, + resolveModelTierWithFallback, type Step, } from "@perstack/core" import { type SkillAdapterLifecycleEvent, SkillManager } from "@perstack/skill-manager" @@ -61,13 +62,13 @@ export class CoordinatorExecutor { const { expertToRun, experts } = await setupExperts(setting, this.options.resolveExpertToRun) validateRuntimeVersion(experts) - // Resolve model: explicit setting > expert's default tier > middle tier fallback + // Resolve model: explicit setting > expert's default tier > high→middle→low fallback const resolvedModel = setting.model ?? (expertToRun.defaultModelTier ? resolveModelTier(setting.providerConfig.providerName, expertToRun.defaultModelTier) : undefined) ?? - resolveModelTier(setting.providerConfig.providerName, "middle") + resolveModelTierWithFallback(setting.providerConfig.providerName) if (!resolvedModel) { throw new PerstackError( diff --git a/packages/runtime/src/orchestration/delegation-executor.ts b/packages/runtime/src/orchestration/delegation-executor.ts index c729aa42..318d112e 100644 --- a/packages/runtime/src/orchestration/delegation-executor.ts +++ b/packages/runtime/src/orchestration/delegation-executor.ts @@ -13,7 +13,7 @@ import type { ToolResult, Usage, } from "@perstack/core" -import { resolveModelTier } from "@perstack/core" +import { resolveModelTier, resolveModelTierWithFallback } from "@perstack/core" /** Reference to the parent Expert that delegated */ type DelegatedBy = NonNullable @@ -189,18 +189,19 @@ export class DelegationExecutor { const { expert, toolCallId, toolName, query } = delegation const delegateRunId = createId() - // Resolve per-expert model tier for the delegate expert - let delegateModel = parentSetting.model + // Resolve per-expert model tier for the delegate expert. + // defaultModelTier on the delegate expert is an explicit user choice and + // takes precedence over the parent's model. const delegateExpert = parentSetting.experts?.[expert.key] + let delegateModel: string | undefined if (delegateExpert?.defaultModelTier) { - const tierModel = resolveModelTier( + delegateModel = resolveModelTier( parentSetting.providerConfig.providerName, delegateExpert.defaultModelTier, ) - if (tierModel) { - delegateModel = tierModel - } } + delegateModel ??= + parentSetting.model ?? resolveModelTierWithFallback(parentSetting.providerConfig.providerName) const delegateSetting: RunSetting = { ...parentSetting, diff --git a/packages/tui/src/lib/provider-config.test.ts b/packages/tui/src/lib/provider-config.test.ts index 66e158af..f9fe7d86 100644 --- a/packages/tui/src/lib/provider-config.test.ts +++ b/packages/tui/src/lib/provider-config.test.ts @@ -214,4 +214,33 @@ describe("getProviderConfig", () => { expect(() => getProviderConfig("deepseek", {})).toThrow("DEEPSEEK_API_KEY is not set") }) }) + + describe("fireworks", () => { + it("returns fireworks config with API key", () => { + const env = { FIREWORKS_API_KEY: "test-key" } + const config = getProviderConfig("fireworks", env) + + expect(config.providerName).toBe("fireworks") + expect((config as ConfigWithApiKey).apiKey).toBe("test-key") + }) + + it("throws when FIREWORKS_API_KEY is not set", () => { + expect(() => getProviderConfig("fireworks", {})).toThrow("FIREWORKS_API_KEY is not set") + }) + + it("uses baseUrl from setting if provided", () => { + const env = { FIREWORKS_API_KEY: "test-key" } + const providerTable = { setting: { baseUrl: "https://custom.api.com" } } as ProviderTable + const config = getProviderConfig("fireworks", env, providerTable) + + expect((config as ConfigWithBaseUrl).baseUrl).toBe("https://custom.api.com") + }) + + it("uses baseUrl from env if setting not provided", () => { + const env = { FIREWORKS_API_KEY: "test-key", FIREWORKS_BASE_URL: "https://env.api.com" } + const config = getProviderConfig("fireworks", env) + + expect((config as ConfigWithBaseUrl).baseUrl).toBe("https://env.api.com") + }) + }) }) diff --git a/packages/tui/src/lib/provider-config.ts b/packages/tui/src/lib/provider-config.ts index 2a3a3c98..1b239811 100644 --- a/packages/tui/src/lib/provider-config.ts +++ b/packages/tui/src/lib/provider-config.ts @@ -14,6 +14,7 @@ export const PROVIDER_ENV_MAP: Record = { "amazon-bedrock": "AWS_ACCESS_KEY_ID", "google-vertex": "GOOGLE_APPLICATION_CREDENTIALS", ollama: undefined, + fireworks: "FIREWORKS_API_KEY", } type SettingRecord = Record @@ -117,5 +118,15 @@ export function getProviderConfig( headers: setting.headers as Record | undefined, } } + case "fireworks": { + const apiKey = env.FIREWORKS_API_KEY + if (!apiKey) throw new PerstackError("FIREWORKS_API_KEY is not set") + return { + providerName: "fireworks", + apiKey, + baseUrl: (setting.baseUrl as string | undefined) ?? env.FIREWORKS_BASE_URL, + headers: setting.headers as Record | undefined, + } + } } }