Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ describe("provider identifiers", () => {
providerIdentifiers.opencodeGo,
providerIdentifiers.kenari,
providerIdentifiers.kimiCode,
providerIdentifiers.friendli,
])
expect(localProviders).toEqual([providerIdentifiers.ollama, providerIdentifiers.lmstudio])
expect(internalProviders).toEqual([providerIdentifiers.vscodeLm])
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/provider-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export const dynamicProviders = [
providerIdentifiers.opencodeGo,
providerIdentifiers.kenari,
providerIdentifiers.kimiCode,
providerIdentifiers.friendli,
] as const

export type DynamicProvider = (typeof dynamicProviders)[number]
Expand Down
16 changes: 12 additions & 4 deletions packages/types/src/providers/friendli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@ export type FriendliModelId =

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

// Static fallback for the Friendli provider. Used as a fallback when dynamic
// models cannot be fetched (cold start, network errors, API lag), in tests,
// and in the webview's MODELS_BY_PROVIDER fallback. The provider itself fetches
// the live list from https://api.friendli.ai/serverless/v1/models at runtime.
// Pricing sourced from https://friendli.ai/api/public/model-apis (per 1M tokens).
export const friendliModels = {
export const friendliModels: Record<string, ModelInfo> = {
"zai-org/GLM-5.2": {
maxTokens: 131_072,
contextWindow: 1_000_000,
Expand All @@ -20,7 +24,7 @@ export const friendliModels = {
outputPrice: 4.4,
cacheWritesPrice: 0,
cacheReadsPrice: 0.26,
supportsReasoningEffort: ["minimal", "low", "medium", "high", "xhigh", "max"],
supportsReasoningEffort: ["high", "max"],
reasoningEffort: "high",
description:
"GLM-5.2 is Zhipu's flagship model with a 1M context window and 128k max output, served via Friendli Model APIs. It delivers top-tier long-context reasoning, coding, and agentic performance for extended engineering sessions.",
Expand All @@ -35,7 +39,7 @@ export const friendliModels = {
outputPrice: 4.4,
cacheWritesPrice: 0,
cacheReadsPrice: 0.26,
supportsReasoningEffort: ["minimal", "low", "medium", "high", "xhigh", "max"],
supportsReasoningEffort: ["high", "max"],
reasoningEffort: "high",
description:
"GLM-5.1 is Zhipu's most capable model with a 200k context window and 128k max output, served via Friendli Model APIs. It delivers top-tier reasoning, coding, and agentic performance.",
Expand All @@ -45,6 +49,8 @@ export const friendliModels = {
contextWindow: 163_840,
supportsImages: false,
supportsPromptCache: true,
supportsMaxTokens: true,
supportsReasoningBinary: true,
inputPrice: 0.5,
outputPrice: 1.5,
cacheWritesPrice: 0,
Expand All @@ -57,11 +63,13 @@ export const friendliModels = {
contextWindow: 204_800,
supportsImages: false,
supportsPromptCache: true,
supportsMaxTokens: true,
supportsReasoningBinary: true,
inputPrice: 0.3,
outputPrice: 1.2,
cacheWritesPrice: 0,
cacheReadsPrice: 0.06,
description:
"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.",
},
} as const satisfies Record<string, ModelInfo>
}
132 changes: 112 additions & 20 deletions src/api/providers/__tests__/friendli.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import { FriendliHandler } from "../friendli"
import { asyncStreamFrom, collectStream } from "../../../test-utils/stream"

// Create mock functions
const mockCreate = vi.fn()
const { mockCreate, mockGetModels } = vi.hoisted(() => ({
mockCreate: vi.fn(),
mockGetModels: vi.fn(),
}))

// Mock OpenAI module
vi.mock("openai", () => ({
Expand All @@ -26,11 +29,18 @@ vi.mock("openai", () => ({
}),
}))

// Mock modelCache so we can control dynamic model loading
vi.mock("../fetchers/modelCache", () => ({
getModels: mockGetModels,
}))

describe("FriendliHandler", () => {
let handler: FriendliHandler

beforeEach(() => {
vi.clearAllMocks()
// By default, dynamic model fetch resolves to empty (static models win)
mockGetModels.mockResolvedValue({})
// Set up default mock implementation
mockCreate.mockImplementation(async () =>
asyncStreamFrom([
Expand Down Expand Up @@ -115,7 +125,6 @@ describe("FriendliHandler", () => {
modelId: "zai-org/GLM-5.1" as const,
contextWindow: 200_000,
maxTokens: 131_072,
supportsMaxTokens: true,
inputPrice: 1.4,
outputPrice: 4.4,
cacheWritesPrice: 0,
Expand All @@ -125,7 +134,6 @@ describe("FriendliHandler", () => {
modelId: "deepseek-ai/DeepSeek-V3.2" as const,
contextWindow: 163_840,
maxTokens: 16384,
supportsMaxTokens: undefined,
inputPrice: 0.5,
outputPrice: 1.5,
cacheWritesPrice: 0,
Expand All @@ -135,29 +143,19 @@ describe("FriendliHandler", () => {
modelId: "MiniMaxAI/MiniMax-M2.5" as const,
contextWindow: 204_800,
maxTokens: 4096,
supportsMaxTokens: undefined,
inputPrice: 0.3,
outputPrice: 1.2,
cacheWritesPrice: 0,
cacheReadsPrice: 0.06,
},
])(
"should expose newly added model $modelId",
({
modelId,
contextWindow,
maxTokens,
supportsMaxTokens,
inputPrice,
outputPrice,
cacheWritesPrice,
cacheReadsPrice,
}) => {
({ modelId, contextWindow, maxTokens, inputPrice, outputPrice, cacheWritesPrice, cacheReadsPrice }) => {
expect(friendliModels[modelId]).toBeDefined()
const info = friendliModels[modelId] as import("@roo-code/types").ModelInfo
expect(info.maxTokens).toBe(maxTokens)
expect(info.contextWindow).toBe(contextWindow)
expect(info.supportsMaxTokens).toBe(supportsMaxTokens)
expect(info.supportsMaxTokens).toBe(true)
expect(info.inputPrice).toBe(inputPrice)
expect(info.outputPrice).toBe(outputPrice)
expect(info.cacheWritesPrice).toBe(cacheWritesPrice)
Expand Down Expand Up @@ -469,7 +467,7 @@ describe("FriendliHandler — Friendli-specific reasoning params", () => {
expect(callArgs.include_reasoning).toBe(true)
})

it("should not include any reasoning params for non-reasoning DeepSeek-V3.2", async () => {
it("should send enable_thinking + parse_reasoning (no reasoning_effort) for binary reasoning DeepSeek-V3.2", async () => {
const handler = new FriendliHandler({
apiModelId: "deepseek-ai/DeepSeek-V3.2",
friendliApiKey: "test-key",
Expand All @@ -482,9 +480,11 @@ describe("FriendliHandler — Friendli-specific reasoning params", () => {
await handler.createMessage("system", []).next()

const callArgs = mockCreate.mock.calls[0][0] as Record<string, unknown>
// Binary reasoning model: no reasoning_effort, but enable_thinking + parse_reasoning
expect(callArgs.reasoning_effort).toBeUndefined()
expect(callArgs.chat_template_kwargs).toBeUndefined()
expect(callArgs.parse_reasoning).toBeUndefined()
expect(callArgs.chat_template_kwargs).toEqual({ enable_thinking: true })
expect(callArgs.parse_reasoning).toBe(true)
expect(callArgs.include_reasoning).toBe(true)
})

it("should handle delta.reasoning_content from parse_reasoning=true stream", async () => {
Expand Down Expand Up @@ -524,7 +524,7 @@ describe("FriendliHandler — Friendli-specific reasoning params", () => {
apiModelId: "zai-org/GLM-5.2",
friendliApiKey: "test-key",
enableReasoningEffort: true,
reasoningEffort: "medium",
reasoningEffort: "high",
})

mockCreate.mockResolvedValueOnce({
Expand All @@ -534,9 +534,101 @@ describe("FriendliHandler — Friendli-specific reasoning params", () => {
await handler.completePrompt("test")

const callArgs = mockCreate.mock.calls[0][0] as Record<string, unknown>
expect(callArgs.reasoning_effort).toBe("medium")
expect(callArgs.reasoning_effort).toBe("high")
expect(callArgs.chat_template_kwargs).toEqual({ enable_thinking: true })
expect(callArgs.parse_reasoning).toBe(true)
expect(callArgs.include_reasoning).toBe(true)
})
})

describe("FriendliHandler — dynamic model loading", () => {
beforeEach(() => {
vi.clearAllMocks()
mockCreate.mockImplementation(async () => asyncStreamFrom([]))
})

it("preserves a dynamic-only model id during the initial load window", () => {
// mockGetModels never resolves — simulates an in-flight fetch
mockGetModels.mockReturnValue(new Promise(() => {}))

const handler = new FriendliHandler({
apiModelId: "friendli-only/future-model",
friendliApiKey: "test-key",
})

// "friendli-only/future-model" is not in static friendliModels, but
// because dynamicModelsLoaded is still false the handler keeps the
// requested id and falls back to the default model's metadata.
const model = handler.getModel()
expect(model.id).toBe("friendli-only/future-model")
expect(model.info).toEqual(friendliModels[friendliDefaultModelId])
})

it("falls back to default model after load completes and id is not in dynamic set", async () => {
// Dynamic fetch resolves to empty — no models
mockGetModels.mockResolvedValue({})

const handler = new FriendliHandler({
apiModelId: "friendli-only/future-model",
friendliApiKey: "test-key",
})

// Wait for the dynamic fetch to settle
await vi.waitFor(() => {
expect((handler as unknown as Record<string, unknown>)["dynamicModelsLoaded"]).toBe(true)
})

// After load, the dynamic-only id is not found — falls back to default
const model = handler.getModel()
expect(model.id).toBe(friendliDefaultModelId)
})

it("uses dynamic model info when available", async () => {
const dynamicModel = {
"friendli-only/future-model": {
maxTokens: 8192,
contextWindow: 100000,
supportsImages: false,
supportsPromptCache: false,
description: "A dynamic-only model",
},
}
mockGetModels.mockResolvedValue(dynamicModel)

const handler = new FriendliHandler({
apiModelId: "friendli-only/future-model",
friendliApiKey: "test-key",
})

await vi.waitFor(() => {
expect((handler as unknown as Record<string, unknown>)["dynamicModelsLoaded"]).toBe(true)
})

const model = handler.getModel()
expect(model.id).toBe("friendli-only/future-model")
expect(model.info).toEqual(
expect.objectContaining({
maxTokens: 8192,
contextWindow: 100000,
description: "A dynamic-only model",
}),
)
})

it("sets dynamicModelsLoaded even when getModels rejects", async () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
mockGetModels.mockRejectedValue(new Error("Network error"))

const handler = new FriendliHandler({
friendliApiKey: "test-key",
})

await vi.waitFor(() => {
expect((handler as unknown as Record<string, unknown>)["dynamicModelsLoaded"]).toBe(true)
})

// Falls back to default model
expect(handler.getModel().id).toBe(friendliDefaultModelId)
consoleErrorSpy.mockRestore()
})
})
Loading
Loading