Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 833bb0c

Browse files
committed
feat(api): implement FirmwareHandler for firmware.ai integration
- Add FirmwareHandler extending RouterProvider for OpenAI-compatible API - Add firmware fetcher for models and quota endpoints - Update modelCache to include firmware provider case - Export FirmwareHandler from providers index - Add firmware case in buildApiHandler switch - Add firmware to dynamicProviderExtras for model management - Add firmware case in useSelectedModel hook - Fix test mock to include firmware in RouterModels
1 parent 4eabd91 commit 833bb0c

8 files changed

Lines changed: 333 additions & 1 deletion

File tree

src/api/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
DeepInfraHandler,
4242
MiniMaxHandler,
4343
BasetenHandler,
44+
FirmwareHandler,
4445
} from "./providers"
4546
import { NativeOllamaHandler } from "./providers/native-ollama"
4647

@@ -197,6 +198,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
197198
return new MiniMaxHandler(options)
198199
case "baseten":
199200
return new BasetenHandler(options)
201+
case "firmware":
202+
return new FirmwareHandler(options)
200203
default:
201204
return new AnthropicHandler(options)
202205
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import type { ModelInfo, ModelRecord } from "@roo-code/types"
2+
3+
import { DEFAULT_HEADERS } from "../constants"
4+
5+
const FIRMWARE_BASE_URL = "https://app.firmware.ai/api/v1"
6+
7+
/**
8+
* Fetches available models from the Firmware.ai API
9+
*
10+
* @param apiKey The API key for the Firmware.ai provider
11+
* @returns A promise that resolves to a record of model IDs to model info
12+
* @throws Will throw an error if the request fails or the response is not as expected.
13+
*/
14+
export async function getFirmwareModels(apiKey?: string): Promise<ModelRecord> {
15+
const url = `${FIRMWARE_BASE_URL}/models`
16+
17+
try {
18+
const headers: Record<string, string> = {
19+
"Content-Type": "application/json",
20+
...DEFAULT_HEADERS,
21+
}
22+
23+
if (apiKey) {
24+
headers["Authorization"] = `Bearer ${apiKey}`
25+
}
26+
27+
const controller = new AbortController()
28+
const timeoutId = setTimeout(() => controller.abort(), 10000)
29+
30+
try {
31+
const response = await fetch(url, {
32+
headers,
33+
signal: controller.signal,
34+
})
35+
36+
if (!response.ok) {
37+
let errorBody = ""
38+
try {
39+
errorBody = await response.text()
40+
} catch {
41+
errorBody = "(unable to read response body)"
42+
}
43+
44+
console.error(`[getFirmwareModels] HTTP error:`, {
45+
status: response.status,
46+
statusText: response.statusText,
47+
url,
48+
body: errorBody,
49+
})
50+
51+
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
52+
}
53+
54+
const data = await response.json()
55+
const models: ModelRecord = {}
56+
57+
// OpenAI-compatible /models endpoint returns { data: [...] }
58+
if (!data.data || !Array.isArray(data.data)) {
59+
console.error("[getFirmwareModels] Unexpected response format:", data)
60+
throw new Error("Failed to fetch Firmware models: Unexpected response format.")
61+
}
62+
63+
for (const model of data.data) {
64+
const modelId = model.id
65+
66+
if (!modelId) continue
67+
68+
// Extract model capabilities from the API response
69+
// Firmware.ai returns OpenAI-compatible model info with additional metadata
70+
const modelInfo: ModelInfo = {
71+
maxTokens: model.max_tokens ?? model.max_output_tokens ?? 8192,
72+
contextWindow: model.context_window ?? model.context_length ?? 128000,
73+
supportsImages: model.supports_vision ?? model.capabilities?.vision ?? false,
74+
supportsPromptCache: model.supports_prompt_cache ?? false,
75+
inputPrice: model.pricing?.input ?? model.input_price ?? 0,
76+
outputPrice: model.pricing?.output ?? model.output_price ?? 0,
77+
description: model.description ?? model.name ?? modelId,
78+
}
79+
80+
// Add optional fields if available
81+
if (model.supports_reasoning || model.capabilities?.reasoning) {
82+
modelInfo.supportsReasoningEffort = true
83+
}
84+
85+
if (model.default_temperature !== undefined) {
86+
modelInfo.defaultTemperature = model.default_temperature
87+
}
88+
89+
models[modelId] = modelInfo
90+
}
91+
92+
return models
93+
} finally {
94+
clearTimeout(timeoutId)
95+
}
96+
} catch (error: any) {
97+
console.error("[getFirmwareModels] Error fetching Firmware models:", {
98+
message: error.message || String(error),
99+
name: error.name,
100+
url,
101+
hasApiKey: Boolean(apiKey),
102+
})
103+
104+
if (error.name === "AbortError") {
105+
throw new Error("Failed to fetch Firmware models: Request timed out after 10 seconds.")
106+
}
107+
108+
if (error.message?.includes("HTTP")) {
109+
throw new Error(`Failed to fetch Firmware models: ${error.message}. Check API key.`)
110+
}
111+
112+
if (error instanceof TypeError) {
113+
throw new Error("Failed to fetch Firmware models: No response from server. Check network connection.")
114+
}
115+
116+
throw new Error(`Failed to fetch Firmware models: ${error.message || "An unknown error occurred."}`)
117+
}
118+
}
119+
120+
/**
121+
* Fetches quota information from the Firmware.ai API
122+
*
123+
* @param apiKey The API key for the Firmware.ai provider
124+
* @returns A promise that resolves to quota information
125+
* @throws Will throw an error if the request fails
126+
*/
127+
export async function getFirmwareQuota(apiKey: string): Promise<{ remaining: number; windowHours: number }> {
128+
const url = `${FIRMWARE_BASE_URL}/quota`
129+
130+
try {
131+
const headers: Record<string, string> = {
132+
"Content-Type": "application/json",
133+
...DEFAULT_HEADERS,
134+
Authorization: `Bearer ${apiKey}`,
135+
}
136+
137+
const controller = new AbortController()
138+
const timeoutId = setTimeout(() => controller.abort(), 10000)
139+
140+
try {
141+
const response = await fetch(url, {
142+
headers,
143+
signal: controller.signal,
144+
})
145+
146+
if (!response.ok) {
147+
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
148+
}
149+
150+
const data = await response.json()
151+
152+
return {
153+
remaining: data.remaining ?? data.balance ?? 0,
154+
windowHours: data.window_hours ?? data.windowHours ?? 5,
155+
}
156+
} finally {
157+
clearTimeout(timeoutId)
158+
}
159+
} catch (error: any) {
160+
console.error("[getFirmwareQuota] Error fetching Firmware quota:", error.message)
161+
throw new Error(`Failed to fetch Firmware quota: ${error.message || "An unknown error occurred."}`)
162+
}
163+
}

src/api/providers/fetchers/modelCache.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { getDeepInfraModels } from "./deepinfra"
2929
import { getHuggingFaceModels } from "./huggingface"
3030
import { getRooModels } from "./roo"
3131
import { getChutesModels } from "./chutes"
32+
import { getFirmwareModels } from "./firmware"
3233

3334
const memoryCache = new NodeCache({ stdTTL: 5 * 60, checkperiod: 5 * 60 })
3435

@@ -108,8 +109,10 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise<Model
108109
case "chutes":
109110
models = await getChutesModels(options.apiKey)
110111
break
112+
case "firmware":
113+
models = await getFirmwareModels(options.apiKey)
114+
break
111115
default: {
112-
// Ensures router is exhaustively checked if RouterName is a strict union.
113116
const exhaustiveCheck: never = provider
114117
throw new Error(`Unknown provider: ${exhaustiveCheck}`)
115118
}

src/api/providers/firmware.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { Anthropic } from "@anthropic-ai/sdk"
2+
import OpenAI from "openai"
3+
4+
import { openAiModelInfoSaneDefaults, type ModelInfo } from "@roo-code/types"
5+
6+
import type { ApiHandlerOptions } from "../../shared/api"
7+
import { calculateApiCostOpenAI } from "../../shared/cost"
8+
9+
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
10+
import { convertToOpenAiMessages } from "../transform/openai-format"
11+
import { getModelParams } from "../transform/model-params"
12+
13+
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
14+
import { RouterProvider } from "./router-provider"
15+
import { getModels } from "./fetchers/modelCache"
16+
17+
const FIRMWARE_BASE_URL = "https://app.firmware.ai/api/v1"
18+
const FIRMWARE_DEFAULT_MODEL_ID = "claude-sonnet-4-5"
19+
20+
export class FirmwareHandler extends RouterProvider implements SingleCompletionHandler {
21+
constructor(options: ApiHandlerOptions) {
22+
super({
23+
options,
24+
name: "firmware",
25+
baseURL: FIRMWARE_BASE_URL,
26+
apiKey: options.firmwareApiKey || "not-provided",
27+
modelId: options.firmwareModelId,
28+
defaultModelId: FIRMWARE_DEFAULT_MODEL_ID,
29+
defaultModelInfo: openAiModelInfoSaneDefaults,
30+
})
31+
}
32+
33+
public override async fetchModel() {
34+
this.models = await getModels({ provider: this.name, apiKey: this.client.apiKey, baseUrl: this.client.baseURL })
35+
return this.getModel()
36+
}
37+
38+
override getModel() {
39+
const id = this.options.firmwareModelId ?? FIRMWARE_DEFAULT_MODEL_ID
40+
const info = this.models[id] ?? openAiModelInfoSaneDefaults
41+
42+
const params = getModelParams({
43+
format: "openai",
44+
modelId: id,
45+
model: info,
46+
settings: this.options,
47+
})
48+
49+
return { id, info, ...params }
50+
}
51+
52+
override async *createMessage(
53+
systemPrompt: string,
54+
messages: Anthropic.Messages.MessageParam[],
55+
metadata?: ApiHandlerCreateMessageMetadata,
56+
): ApiStream {
57+
await this.fetchModel()
58+
const { id: modelId, info, reasoningEffort: reasoning_effort } = this.getModel()
59+
60+
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
61+
model: modelId,
62+
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
63+
stream: true,
64+
stream_options: { include_usage: true },
65+
reasoning_effort,
66+
tools: this.convertToolsForOpenAI(metadata?.tools),
67+
tool_choice: metadata?.tool_choice,
68+
parallel_tool_calls: metadata?.parallelToolCalls ?? false,
69+
} as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming
70+
71+
if (this.supportsTemperature(modelId)) {
72+
requestOptions.temperature = this.options.modelTemperature ?? 0
73+
}
74+
75+
if (this.options.includeMaxTokens === true && info.maxTokens) {
76+
;(requestOptions as any).max_completion_tokens = this.options.modelMaxTokens || info.maxTokens
77+
}
78+
79+
const { data: stream } = await this.client.chat.completions.create(requestOptions).withResponse()
80+
81+
let lastUsage: OpenAI.CompletionUsage | undefined
82+
for await (const chunk of stream) {
83+
const delta = chunk.choices[0]?.delta
84+
85+
if (delta?.content) {
86+
yield { type: "text", text: delta.content }
87+
}
88+
89+
if (delta && "reasoning_content" in delta && delta.reasoning_content) {
90+
yield { type: "reasoning", text: (delta.reasoning_content as string | undefined) || "" }
91+
}
92+
93+
if (delta?.tool_calls) {
94+
for (const toolCall of delta.tool_calls) {
95+
yield {
96+
type: "tool_call_partial",
97+
index: toolCall.index,
98+
id: toolCall.id,
99+
name: toolCall.function?.name,
100+
arguments: toolCall.function?.arguments,
101+
}
102+
}
103+
}
104+
105+
if (chunk.usage) {
106+
lastUsage = chunk.usage
107+
}
108+
}
109+
110+
if (lastUsage) {
111+
yield this.processUsageMetrics(lastUsage, info)
112+
}
113+
}
114+
115+
async completePrompt(prompt: string): Promise<string> {
116+
await this.fetchModel()
117+
const { id: modelId, info } = this.getModel()
118+
119+
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {
120+
model: modelId,
121+
messages: [{ role: "user", content: prompt }],
122+
}
123+
124+
if (this.supportsTemperature(modelId)) {
125+
requestOptions.temperature = this.options.modelTemperature ?? 0
126+
}
127+
128+
if (this.options.includeMaxTokens === true && info.maxTokens) {
129+
;(requestOptions as any).max_completion_tokens = this.options.modelMaxTokens || info.maxTokens
130+
}
131+
132+
const resp = await this.client.chat.completions.create(requestOptions)
133+
return resp.choices[0]?.message?.content || ""
134+
}
135+
136+
protected processUsageMetrics(usage: any, modelInfo?: ModelInfo): ApiStreamUsageChunk {
137+
const inputTokens = usage?.prompt_tokens || 0
138+
const outputTokens = usage?.completion_tokens || 0
139+
const cacheWriteTokens = usage?.prompt_tokens_details?.cache_write_tokens || 0
140+
const cacheReadTokens = usage?.prompt_tokens_details?.cached_tokens || 0
141+
142+
const { totalCost } = modelInfo
143+
? calculateApiCostOpenAI(modelInfo, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
144+
: { totalCost: 0 }
145+
146+
return {
147+
type: "usage",
148+
inputTokens,
149+
outputTokens,
150+
cacheWriteTokens: cacheWriteTokens || undefined,
151+
cacheReadTokens: cacheReadTokens || undefined,
152+
totalCost,
153+
}
154+
}
155+
}

src/api/providers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,4 @@ export { VercelAiGatewayHandler } from "./vercel-ai-gateway"
3333
export { DeepInfraHandler } from "./deepinfra"
3434
export { MiniMaxHandler } from "./minimax"
3535
export { BasetenHandler } from "./baseten"
36+
export { FirmwareHandler } from "./firmware"

src/shared/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ const dynamicProviderExtras = {
181181
lmstudio: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type
182182
roo: {} as { apiKey?: string; baseUrl?: string },
183183
chutes: {} as { apiKey?: string },
184+
firmware: {} as { apiKey?: string },
184185
} as const satisfies Record<RouterName, object>
185186

186187
// Build the dynamic options union from the map, intersected with CommonFetchParams

webview-ui/src/components/ui/hooks/useSelectedModel.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,11 @@ function getSelectedModel({
368368
const info = routerModels["vercel-ai-gateway"]?.[id]
369369
return { id, info }
370370
}
371+
case "firmware": {
372+
const id = getValidatedModelId(apiConfiguration.firmwareModelId, routerModels.firmware, defaultModelId)
373+
const info = routerModels.firmware?.[id]
374+
return { id, info }
375+
}
371376
// case "anthropic":
372377
// case "fake-ai":
373378
default: {

webview-ui/src/utils/__tests__/validate.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ describe("Model Validation Functions", () => {
4949
huggingface: {},
5050
roo: {},
5151
chutes: {},
52+
firmware: {},
5253
}
5354

5455
const allowAllOrganization: OrganizationAllowList = {

0 commit comments

Comments
 (0)