Skip to content

Commit 3ec8de5

Browse files
committed
feat(friendli): fetch model list dynamically from /v1/models
Convert Friendli from a static provider (4 hardcoded models) to a dynamic provider that fetches the live model list from the public https://api.friendli.ai/serverless/v1/models endpoint at runtime. - Add getFriendliModels() fetcher with zod schema validation - Wire friendli into modelCache, webviewMessageHandler, and dynamicProviders - FriendliHandler loads dynamic models in constructor, falls back to static friendliModels for cold-start and API lag - UI model picker uses routerModels.friendli instead of static list - Add fetcher spec (14 tests) and update Friendli.spec.tsx with ModelPicker mock
1 parent 488732e commit 3ec8de5

17 files changed

Lines changed: 683 additions & 16 deletions

File tree

packages/types/src/__tests__/provider-identifiers.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ describe("provider identifiers", () => {
106106
providerIdentifiers.opencodeGo,
107107
providerIdentifiers.kenari,
108108
providerIdentifiers.kimiCode,
109+
providerIdentifiers.friendli,
109110
])
110111
expect(localProviders).toEqual([providerIdentifiers.ollama, providerIdentifiers.lmstudio])
111112
expect(internalProviders).toEqual([providerIdentifiers.vscodeLm])

packages/types/src/provider-settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export const dynamicProviders = [
5656
providerIdentifiers.opencodeGo,
5757
providerIdentifiers.kenari,
5858
providerIdentifiers.kimiCode,
59+
providerIdentifiers.friendli,
5960
] as const
6061

6162
export type DynamicProvider = (typeof dynamicProviders)[number]

packages/types/src/providers/friendli.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,12 @@ export type FriendliModelId =
88

99
export const friendliDefaultModelId: FriendliModelId = "zai-org/GLM-5.2"
1010

11+
// Static fallback for the Friendli provider. Used as a fallback when dynamic
12+
// models cannot be fetched (cold start, network errors, API lag), in tests,
13+
// and in the webview's MODELS_BY_PROVIDER fallback. The provider itself fetches
14+
// the live list from https://api.friendli.ai/serverless/v1/models at runtime.
1115
// Pricing sourced from https://friendli.ai/api/public/model-apis (per 1M tokens).
12-
export const friendliModels = {
16+
export const friendliModels: Record<string, ModelInfo> = {
1317
"zai-org/GLM-5.2": {
1418
maxTokens: 131_072,
1519
contextWindow: 1_000_000,
@@ -64,4 +68,4 @@ export const friendliModels = {
6468
description:
6569
"MiniMax M2.5 is a high-performance language model with a 204.8K context window, optimized for long-context understanding and generation tasks, served via Friendli Model APIs.",
6670
},
67-
} as const satisfies Record<string, ModelInfo>
71+
}
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
// npx vitest run api/providers/fetchers/__tests__/friendli.spec.ts
2+
3+
import axios from "axios"
4+
5+
import { getFriendliModels, parseFriendliModel } from "../friendli"
6+
import type { FriendliModel } from "../friendli"
7+
8+
vi.mock("axios")
9+
const mockedAxios = vi.mocked(axios, { partial: true })
10+
11+
describe("Friendli Fetchers", () => {
12+
beforeEach(() => {
13+
vitest.clearAllMocks()
14+
})
15+
16+
describe("getFriendliModels", () => {
17+
const mockResponse = {
18+
data: {
19+
data: [
20+
{
21+
id: "zai-org/GLM-5.2",
22+
name: "zai-org/GLM-5.2",
23+
created: 1776162486,
24+
context_length: 1048576,
25+
max_completion_tokens: 131072,
26+
pricing: {
27+
input: "0.0000014",
28+
output: "0.0000044",
29+
input_cache_read: "0.00000026",
30+
cache_write: "0.0000015",
31+
},
32+
functionality: {
33+
tool_call: true,
34+
parallel_tool_call: true,
35+
structured_output: true,
36+
tool_choice: true,
37+
system_messages: true,
38+
},
39+
description: "GLM-5.2 flagship model",
40+
reasoning: true,
41+
reasoning_options: [
42+
{ type: "toggle" },
43+
{ type: "effort", values: ["low", "medium", "high", "default"] },
44+
{ type: "budget_tokens", min: -1, max: 202752 },
45+
],
46+
input_modalities: ["text"],
47+
output_modalities: ["text"],
48+
mode: "chat",
49+
},
50+
{
51+
id: "deepseek-ai/DeepSeek-V3.2",
52+
name: "deepseek-ai/DeepSeek-V3.2",
53+
context_length: 163840,
54+
max_completion_tokens: 163840,
55+
pricing: {
56+
input: "0.0000005",
57+
output: "0.0000015",
58+
input_cache_read: "0.00000025",
59+
},
60+
functionality: {
61+
tool_call: true,
62+
parallel_tool_call: true,
63+
structured_output: true,
64+
},
65+
description: "DeepSeek V3.2",
66+
reasoning: false,
67+
input_modalities: ["text"],
68+
output_modalities: ["text"],
69+
mode: "chat",
70+
},
71+
{
72+
id: "some/embedding-model",
73+
context_length: 8192,
74+
max_completion_tokens: 8192,
75+
mode: "embedding",
76+
pricing: { input: "0.0000001", output: "0" },
77+
},
78+
],
79+
},
80+
}
81+
82+
it("fetches and parses models correctly", async () => {
83+
mockedAxios.get.mockResolvedValueOnce(mockResponse)
84+
85+
const models = await getFriendliModels()
86+
87+
expect(mockedAxios.get).toHaveBeenCalledWith("https://api.friendli.ai/serverless/v1/models")
88+
// Two chat models, embedding model filtered out
89+
expect(Object.keys(models)).toHaveLength(2)
90+
expect(models["zai-org/GLM-5.2"]).toBeDefined()
91+
expect(models["deepseek-ai/DeepSeek-V3.2"]).toBeDefined()
92+
})
93+
94+
it("handles API errors gracefully", async () => {
95+
const consoleErrorSpy = vitest.spyOn(console, "error").mockImplementation(function () {})
96+
mockedAxios.get.mockRejectedValueOnce(new Error("Network error"))
97+
98+
const models = await getFriendliModels()
99+
100+
expect(models).toEqual({})
101+
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Error fetching Friendli models"))
102+
consoleErrorSpy.mockRestore()
103+
})
104+
105+
it("handles invalid response schema gracefully", async () => {
106+
const consoleErrorSpy = vitest.spyOn(console, "error").mockImplementation(function () {})
107+
mockedAxios.get.mockResolvedValueOnce({
108+
data: { invalid: "response" },
109+
})
110+
111+
const models = await getFriendliModels()
112+
113+
expect(models).toEqual({})
114+
expect(consoleErrorSpy).toHaveBeenCalled()
115+
consoleErrorSpy.mockRestore()
116+
})
117+
118+
it("filters out non-chat models", async () => {
119+
mockedAxios.get.mockResolvedValueOnce({
120+
data: {
121+
data: [
122+
{
123+
id: "test/chat-model",
124+
context_length: 4096,
125+
max_completion_tokens: 2048,
126+
mode: "chat",
127+
pricing: { input: "0.0000001", output: "0.0000002" },
128+
},
129+
{
130+
id: "test/embedding-model",
131+
context_length: 4096,
132+
max_completion_tokens: 2048,
133+
mode: "embedding",
134+
pricing: { input: "0.0000001", output: "0" },
135+
},
136+
],
137+
},
138+
})
139+
140+
const models = await getFriendliModels()
141+
142+
expect(Object.keys(models)).toHaveLength(1)
143+
expect(models["test/chat-model"]).toBeDefined()
144+
expect(models["test/embedding-model"]).toBeUndefined()
145+
})
146+
})
147+
148+
describe("parseFriendliModel", () => {
149+
const baseModel: FriendliModel = {
150+
id: "test/model",
151+
name: "test/model",
152+
context_length: 100000,
153+
max_completion_tokens: 8000,
154+
pricing: {
155+
input: "0.0000025",
156+
output: "0.00001",
157+
},
158+
description: "A test model",
159+
input_modalities: ["text"],
160+
output_modalities: ["text"],
161+
mode: "chat",
162+
}
163+
164+
it("parses basic model info correctly", () => {
165+
const result = parseFriendliModel({ id: "test/model", model: baseModel })
166+
167+
expect(result.maxTokens).toBe(8000)
168+
expect(result.contextWindow).toBe(100000)
169+
expect(result.supportsImages).toBe(false)
170+
expect(result.supportsPromptCache).toBe(false)
171+
expect(result.inputPrice).toBe(2.5) // 0.0000025 * 1_000_000 = 2.5
172+
expect(result.outputPrice).toBe(10) // 0.00001 * 1_000_000 = 10
173+
expect(result.cacheWritesPrice).toBeUndefined()
174+
expect(result.cacheReadsPrice).toBeUndefined()
175+
expect(result.description).toBe("A test model")
176+
})
177+
178+
it("parses cache pricing when available", () => {
179+
const modelWithCache: FriendliModel = {
180+
...baseModel,
181+
pricing: {
182+
input: "0.0000030",
183+
output: "0.0000150",
184+
input_cache_read: "0.00000030",
185+
cache_write: "0.00000375",
186+
},
187+
}
188+
189+
const result = parseFriendliModel({ id: "test/model", model: modelWithCache })
190+
191+
expect(result.supportsPromptCache).toBe(true)
192+
expect(result.cacheWritesPrice).toBe(3.75)
193+
expect(result.cacheReadsPrice).toBe(0.3)
194+
})
195+
196+
it("handles partial cache pricing (only read)", () => {
197+
const modelPartialCache: FriendliModel = {
198+
...baseModel,
199+
pricing: {
200+
input: "0.0000025",
201+
output: "0.00001",
202+
input_cache_read: "0.00000030",
203+
},
204+
}
205+
206+
const result = parseFriendliModel({ id: "test/model", model: modelPartialCache })
207+
208+
expect(result.supportsPromptCache).toBe(true)
209+
expect(result.cacheWritesPrice).toBeUndefined()
210+
expect(result.cacheReadsPrice).toBe(0.3)
211+
})
212+
213+
it("detects image support from input_modalities", () => {
214+
const visionModel: FriendliModel = {
215+
...baseModel,
216+
input_modalities: ["text", "image"],
217+
}
218+
219+
const result = parseFriendliModel({ id: "test/model", model: visionModel })
220+
221+
expect(result.supportsImages).toBe(true)
222+
})
223+
224+
it("sets supportsReasoningEffort as array for controllable reasoning models", () => {
225+
const model: FriendliModel = {
226+
...baseModel,
227+
reasoning: true,
228+
reasoning_options: [
229+
{ type: "toggle" },
230+
{ type: "effort", values: ["low", "medium", "high", "default"] },
231+
{ type: "budget_tokens", min: -1, max: 8000 },
232+
],
233+
}
234+
235+
const result = parseFriendliModel({ id: "test/model", model })
236+
237+
expect(result.supportsReasoningEffort).toEqual(
238+
expect.arrayContaining(["low", "medium", "high", "minimal", "xhigh", "max"]),
239+
)
240+
// "default" should be filtered out
241+
expect(result.supportsReasoningEffort).not.toContain("default")
242+
expect(result.reasoningEffort).toBe("high")
243+
expect(result.supportsMaxTokens).toBe(true)
244+
})
245+
246+
it("sets supportsReasoningEffort to true for reasoning models without effort options", () => {
247+
const model: FriendliModel = {
248+
...baseModel,
249+
reasoning: true,
250+
}
251+
252+
const result = parseFriendliModel({ id: "test/model", model })
253+
254+
expect(result.supportsReasoningEffort).toBe(true)
255+
expect(result.reasoningEffort).toBeUndefined()
256+
expect(result.supportsMaxTokens).toBeUndefined()
257+
})
258+
259+
it("omits supportsReasoningEffort for non-reasoning models", () => {
260+
const model: FriendliModel = {
261+
...baseModel,
262+
reasoning: false,
263+
}
264+
265+
const result = parseFriendliModel({ id: "test/model", model })
266+
267+
expect(result.supportsReasoningEffort).toBeUndefined()
268+
})
269+
270+
it("marks deprecated models", () => {
271+
const model: FriendliModel = {
272+
...baseModel,
273+
deprecation_date: "2026-08-05T00:00:00Z",
274+
}
275+
276+
const result = parseFriendliModel({ id: "test/model", model })
277+
278+
expect(result.deprecated).toBe(true)
279+
})
280+
281+
it("handles empty description", () => {
282+
const model: FriendliModel = {
283+
...baseModel,
284+
description: " ",
285+
}
286+
287+
const result = parseFriendliModel({ id: "test/model", model })
288+
289+
expect(result.description).toBeUndefined()
290+
})
291+
292+
it("falls back to prompt/completion pricing aliases", () => {
293+
const model: FriendliModel = {
294+
...baseModel,
295+
pricing: {
296+
prompt: "0.0000025",
297+
completion: "0.00001",
298+
},
299+
}
300+
301+
const result = parseFriendliModel({ id: "test/model", model })
302+
303+
expect(result.inputPrice).toBe(2.5)
304+
expect(result.outputPrice).toBe(10)
305+
})
306+
})
307+
})

0 commit comments

Comments
 (0)