From 93e3c23e03e05432c8fa744a4c30d484f420b855 Mon Sep 17 00:00:00 2001 From: rekram1-node Date: Mon, 7 Sep 2026 19:32:50 +0000 Subject: [PATCH] feat(sync): add Fireworks AI catalog sync --- .github/workflows/sync-models.yml | 1 + package.json | 1 + packages/core/src/sync/index.ts | 5 +- .../core/src/sync/providers/fireworks-ai.ts | 168 ++++++++++++++++++ packages/core/test/fireworks-ai-sync.test.ts | 106 +++++++++++ .../fireworks/models/qwen3p8-max.toml | 13 +- sync.md | 13 ++ 7 files changed, 298 insertions(+), 9 deletions(-) create mode 100644 packages/core/src/sync/providers/fireworks-ai.ts create mode 100644 packages/core/test/fireworks-ai-sync.test.ts diff --git a/.github/workflows/sync-models.yml b/.github/workflows/sync-models.yml index ac9f3690bcd..3ec03eb942d 100644 --- a/.github/workflows/sync-models.yml +++ b/.github/workflows/sync-models.yml @@ -77,6 +77,7 @@ jobs: DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }} DIGITALOCEAN_API_TOKEN: ${{ secrets.DIGITALOCEAN_API_TOKEN }} DIGITALOCEAN_ACCESS_TOKEN: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} + FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }} HF_TOKEN: ${{ secrets.HF_TOKEN }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} diff --git a/package.json b/package.json index 4a7b90393f7..a1de7f5bb8e 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "vercel:generate": "bun ./packages/core/script/sync-models.ts vercel", "wandb:generate": "bun ./packages/core/script/sync-models.ts wandb", "digitalocean:sync": "bun ./packages/core/script/sync-models.ts digitalocean", + "fireworks:sync": "bun ./packages/core/script/sync-models.ts fireworks-ai", "ambient:sync": "bun ./packages/core/script/sync-models.ts ambient", "models:sync": "bun ./packages/core/script/sync-models.ts", "sync:models": "bun ./packages/core/script/sync-models.ts", diff --git a/packages/core/src/sync/index.ts b/packages/core/src/sync/index.ts index 7f88aacb5bb..b15e90c2bf8 100644 --- a/packages/core/src/sync/index.ts +++ b/packages/core/src/sync/index.ts @@ -18,6 +18,7 @@ import { deepinfra } from "./providers/deepinfra.js"; import { digitalocean } from "./providers/digitalocean.js"; import { edenai } from "./providers/edenai.js"; import { empiriolabs } from "./providers/empiriolabs.js"; +import { fireworksAi } from "./providers/fireworks-ai.js"; import { githubCopilot } from "./providers/github-copilot.js"; import { google } from "./providers/google.js"; import { hyper } from "./providers/hyper.js"; @@ -142,6 +143,7 @@ export const providers: { digitalocean: SyncProvider; edenai: SyncProvider; empiriolabs: SyncProvider; + "fireworks-ai": SyncProvider; "github-copilot": SyncProvider; google: SyncProvider; hyper: SyncProvider; @@ -177,6 +179,7 @@ export const providers: { digitalocean, edenai, empiriolabs, + "fireworks-ai": fireworksAi, "github-copilot": githubCopilot, google, hyper, @@ -219,7 +222,7 @@ export const groups = { "vercel", ], cloudflare: ["cloudflare-ai-gateway", "cloudflare-workers-ai"], - direct: ["ambient", "anthropic", "baseten", "chutes", "cortecs", "deepinfra", "digitalocean", "github-copilot", "google", "hyper", "meta", "openai", "ovhcloud", "pioneer", "tinfoil", "venice", "wandb", "xai"], + direct: ["ambient", "anthropic", "baseten", "chutes", "cortecs", "deepinfra", "digitalocean", "fireworks-ai", "github-copilot", "google", "hyper", "meta", "openai", "ovhcloud", "pioneer", "tinfoil", "venice", "wandb", "xai"], } as const; type ProviderID = keyof typeof providers; diff --git a/packages/core/src/sync/providers/fireworks-ai.ts b/packages/core/src/sync/providers/fireworks-ai.ts new file mode 100644 index 00000000000..5ebae8659b3 --- /dev/null +++ b/packages/core/src/sync/providers/fireworks-ai.ts @@ -0,0 +1,168 @@ +import { z } from "zod"; + +import type { ExistingModel, SyncProvider, SyncedFullModel, SyncedModel } from "../index.js"; +import { factorBaseModel } from "./openrouter.js"; + +const API_ENDPOINT = "https://api.fireworks.ai/inference/v1/models"; + +export const FireworksModel = z.object({ + id: z.string().min(1), + object: z.literal("model"), + created: z.number().int().nonnegative(), + owned_by: z.string(), + context_length: z.number().int().positive().optional(), + kind: z.string(), + supports_chat: z.boolean(), + supports_image_input: z.boolean(), + supports_tools: z.boolean(), +}).passthrough(); + +export const FireworksResponse = z.object({ + object: z.literal("list"), + data: z.array(FireworksModel), +}).passthrough(); + +export type FireworksModel = z.infer; + +export const fireworksAi = { + id: "fireworks-ai", + name: "Fireworks AI", + modelsDir: "providers/fireworks-ai/models", + skipCreates: true, + // The endpoint is filtered by account model-access policy. Never interpret + // an absent row as proof that Fireworks removed a public model. + deleteMissing: false, + sourceID(model) { + return supportsCatalogModel(model) ? model.id : undefined; + }, + skippedNotice(ids) { + if (ids.length === 0) return []; + return [ + `${ids.length} Fireworks text/vision models returned by the API were not created because the endpoint does not provide pricing, output limits, reasoning controls, or structured-output metadata. Existing models are still updated from API-authoritative fields.`, + `Skipped remote IDs: ${ids.map((id) => `\`${id}\``).join(", ")}`, + ]; + }, + missingNotice(paths) { + if (paths.length === 0) return []; + return [ + `${paths.length} local Fireworks models were absent from this account's model list and were retained for manual lifecycle review.`, + `Retained local paths: ${paths.map((item) => `\`${item}\``).join(", ")}`, + ]; + }, + async fetchModels() { + const key = process.env.FIREWORKS_API_KEY; + if (key === undefined) throw new Error("Fireworks AI sync requires FIREWORKS_API_KEY"); + return fetchFireworksModels(key); + }, + parseModels(raw) { + return FireworksResponse.parse(raw).data; + }, + translateModel(model, context) { + if (!supportsCatalogModel(model)) return undefined; + const existing = context.existing(model.id); + if (existing === undefined) return undefined; + return { + id: model.id, + model: buildFireworksModel(model, existing), + }; + }, +} satisfies SyncProvider; + +export async function fetchFireworksModels( + key: string, + fetcher: typeof fetch = fetch, +) { + const response = await fetcher(API_ENDPOINT, { + headers: { Authorization: `Bearer ${key}` }, + }); + if (!response.ok) { + throw new Error(`Fireworks AI models request failed: ${response.status} ${response.statusText}`); + } + return FireworksResponse.parse(await response.json()); +} + +function supportsCatalogModel(model: FireworksModel) { + return model.supports_chat && model.kind !== "EMBEDDING_MODEL"; +} + +type Modality = SyncedFullModel["modalities"]["input"][number]; + +function inputModalities(existing: Modality[], supportsImage: boolean): Modality[] { + if (!supportsImage || existing.includes("image")) return existing; + return [...existing, "image" as const]; +} + +export function buildFireworksModel( + model: FireworksModel, + existing: ExistingModel, +): SyncedModel { + const name = existing.name; + const description = existing.description; + const releaseDate = existing.release_date; + const lastUpdated = existing.last_updated; + const reasoning = existing.reasoning; + const toolCall = existing.tool_call; + const openWeights = existing.open_weights; + const limit = existing.limit; + const modalities = existing.modalities; + const cost = existing.cost; + + if ( + name === undefined + || description === undefined + || releaseDate === undefined + || lastUpdated === undefined + || reasoning === undefined + || toolCall === undefined + || openWeights === undefined + || limit === undefined + || limit.context === undefined + || limit.output === undefined + || modalities === undefined + || cost === undefined + ) { + throw new Error(`Fireworks AI model ${model.id} has incomplete local TOML metadata required for sync`); + } + + const input = inputModalities(modalities.input, model.supports_image_input); + // Fireworks reports the advertised context window, while some deployments + // reserve a few prompt tokens. Preserve a smaller verified local cap, but + // immediately follow any lower ceiling reported by the API. + const context = model.context_length === undefined + ? limit.context + : Math.min(limit.context, model.context_length); + const output = Math.min(limit.output, context); + const values = { + name, + description, + family: existing.family, + release_date: releaseDate, + last_updated: lastUpdated, + attachment: input.some((modality) => modality !== "text"), + reasoning, + reasoning_options: existing.reasoning_options, + temperature: existing.temperature, + tool_call: model.supports_tools || toolCall, + structured_output: existing.structured_output, + knowledge: existing.knowledge, + open_weights: openWeights, + status: existing.status, + interleaved: existing.interleaved, + cost, + limit: { + context, + input: limit.input, + output, + }, + modalities: { + input, + output: modalities.output, + }, + provider: existing.provider, + experimental: existing.experimental, + } satisfies SyncedFullModel; + + return existing.base_model === undefined + ? values + : factorBaseModel(existing.base_model, values, values.limit, existing.base_model_omit); +} diff --git a/packages/core/test/fireworks-ai-sync.test.ts b/packages/core/test/fireworks-ai-sync.test.ts new file mode 100644 index 00000000000..3776f2f4d61 --- /dev/null +++ b/packages/core/test/fireworks-ai-sync.test.ts @@ -0,0 +1,106 @@ +import { expect, test } from "bun:test"; + +import type { ExistingModel } from "../src/sync/index.js"; +import { + buildFireworksModel, + FireworksResponse, + fireworksAi, + type FireworksModel, +} from "../src/sync/providers/fireworks-ai.js"; + +test("parses the Fireworks OpenAI-compatible model list", () => { + const parsed = FireworksResponse.parse({ + object: "list", + data: [fireworksModel()], + }); + + expect(parsed.data[0]).toMatchObject({ + id: "accounts/fireworks/models/example", + context_length: 1_048_576, + supports_tools: true, + }); +}); + +test("adds positive Fireworks capabilities while preserving authored facts", () => { + const model = buildFireworksModel( + fireworksModel({ supports_image_input: true, supports_tools: false }), + existingModel(), + ); + + expect(model).toMatchObject({ + attachment: true, + tool_call: true, + cost: { input: 1, output: 2 }, + limit: { context: 1_048_573, output: 262_144 }, + modalities: { input: ["text", "image"], output: ["text"] }, + reasoning_options: [{ type: "effort", values: ["low", "high"] }], + }); +}); + +test("does not remove image support from a false-negative Fireworks flag", () => { + const model = buildFireworksModel( + fireworksModel({ supports_image_input: false }), + { + ...existingModel(), + attachment: true, + modalities: { input: ["text", "image", "video"], output: ["text"] }, + }, + ); + + expect(model.modalities?.input).toEqual(["text", "image", "video"]); + expect(model.attachment).toBe(true); +}); + +test("uses Fireworks context length only as an upper bound", () => { + const model = buildFireworksModel( + fireworksModel({ context_length: 131_072 }), + existingModel(), + ); + + expect(model.limit?.context).toBe(131_072); + expect(model.limit?.output).toBe(131_072); +}); + +test("does not report Fireworks embedding rows as missing generation models", () => { + const embedding = fireworksModel({ kind: "EMBEDDING_MODEL" }); + + expect(fireworksAi.sourceID(embedding)).toBeUndefined(); + expect(fireworksAi.translateModel(embedding, { + existing: () => existingModel(), + authored: () => existingModel(), + })).toBeUndefined(); +}); + +function fireworksModel(overrides: Partial = {}): FireworksModel { + return { + id: "accounts/fireworks/models/example", + object: "model", + created: 1_788_566_400, + owned_by: "fireworks", + context_length: 1_048_576, + kind: "HF_BASE_MODEL", + supports_chat: true, + supports_image_input: false, + supports_tools: true, + ...overrides, + }; +} + +function existingModel(): ExistingModel { + return { + name: "Example", + description: "Example reasoning model", + release_date: "2026-09-01", + last_updated: "2026-09-01", + attachment: false, + reasoning: true, + reasoning_options: [{ type: "effort", values: ["low", "high"] }], + temperature: true, + tool_call: true, + structured_output: true, + open_weights: true, + cost: { input: 1, output: 2 }, + limit: { context: 1_048_573, output: 262_144 }, + modalities: { input: ["text"], output: ["text"] }, + }; +} diff --git a/providers/fireworks-ai/models/accounts/fireworks/models/qwen3p8-max.toml b/providers/fireworks-ai/models/accounts/fireworks/models/qwen3p8-max.toml index 61607c96136..f02034c50ba 100644 --- a/providers/fireworks-ai/models/accounts/fireworks/models/qwen3p8-max.toml +++ b/providers/fireworks-ai/models/accounts/fireworks/models/qwen3p8-max.toml @@ -1,18 +1,15 @@ base_model = "alibaba/qwen3.8-max" -attachment = false - [[reasoning_options]] type = "toggle" -[modalities] -input = ["text"] -output = ["text"] - [cost] -input = 2.00 -output = 6.00 +input = 2 +output = 6 cache_read = 0.25 [limit] context = 262_144 + +[modalities] +input = ["text", "image"] diff --git a/sync.md b/sync.md index 7595a924dc4..6870126d049 100644 --- a/sync.md +++ b/sync.md @@ -289,6 +289,19 @@ OVHcloud AI Endpoints is implemented in `packages/core/src/sync/providers/ovhclo - `attachment` is derived from non-text `input_modalities`, and `open_weights` from the presence of `hugging_face_id`. - `release_date`/`last_updated` default to the catalog `created` timestamp but preserve any existing hand-authored dates; `knowledge`, `family`, `status`, `interleaved`, and `limit.input` are preserved when present. +## Fireworks AI Notes + +Fireworks AI is implemented in `packages/core/src/sync/providers/fireworks-ai.ts`. + +- Run it with `bun models:sync fireworks-ai` or `bun fireworks:sync`. +- Source endpoint: `https://api.fireworks.ai/inference/v1/models`; required auth: `FIREWORKS_API_KEY`. +- The OpenAI-compatible response is the most complete callable catalog for an account: it includes base-model and router IDs, creation timestamps, advertised context length, model kind, and chat/image/tool capability flags. +- The endpoint does not expose OpenRouter-format metadata. `format=openrouter`, `type=text`, `limit`, and `pageSize` are accepted but ignored and return the same unpaginated response. +- The management endpoint at `/v1/accounts/fireworks/models?filter=supports_serverless=true` exposes richer base-model metadata but omits routers and can omit callable models. Its documented `serverlessModes` pricing data was empty in the live response, so it is not used by the sync. +- New text/vision IDs are reported but not created automatically because neither endpoint provides pricing, output limits, reasoning controls, or structured-output metadata. Embedding/reranking rows are ignored because the catalog provider entries describe generation models. +- Existing exact context caps smaller than the advertised API value are preserved; a lower API ceiling is applied. Positive image/tool flags upgrade authored capabilities, but false flags do not remove them: `minimax-m3` reported `supports_image_input=false` while successfully accepting an image completion. Authored output limits, pricing, reasoning options, and other metadata are preserved. +- Models absent from this account-scoped response are retained for manual lifecycle review. + ## DigitalOcean Notes - DigitalOcean is implemented in `packages/core/src/sync/providers/digitalocean.ts`.