diff --git a/packages/types/src/providers/friendli.ts b/packages/types/src/providers/friendli.ts index 71591d8f40..63961c7f66 100644 --- a/packages/types/src/providers/friendli.ts +++ b/packages/types/src/providers/friendli.ts @@ -20,8 +20,10 @@ export const friendliModels = { outputPrice: 4.4, cacheWritesPrice: 0, cacheReadsPrice: 0.26, + supportsReasoningEffort: ["minimal", "low", "medium", "high", "xhigh", "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.", + "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. (controllable reasoning model)", }, "zai-org/GLM-5.1": { maxTokens: 131_072, @@ -33,8 +35,10 @@ export const friendliModels = { outputPrice: 4.4, cacheWritesPrice: 0, cacheReadsPrice: 0.26, + supportsReasoningEffort: ["minimal", "low", "medium", "high", "xhigh", "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.", + "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. (controllable reasoning model)", }, "deepseek-ai/DeepSeek-V3.2": { maxTokens: 16384, @@ -57,7 +61,8 @@ export const friendliModels = { outputPrice: 1.2, cacheWritesPrice: 0, cacheReadsPrice: 0.06, + supportsReasoningBinary: true, 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.", + "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. (always-reasoning model)", }, } as const satisfies Record diff --git a/src/api/providers/__tests__/friendli.spec.ts b/src/api/providers/__tests__/friendli.spec.ts index 41892d3bc6..0d008e80ea 100644 --- a/src/api/providers/__tests__/friendli.spec.ts +++ b/src/api/providers/__tests__/friendli.spec.ts @@ -394,3 +394,210 @@ describe("Friendli model max output tokens (clamping behavior)", () => { expect(result).toBe(80_000) }) }) + +describe("FriendliHandler — Friendli-specific reasoning params", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("should include reasoning_effort, chat_template_kwargs, parse_reasoning for GLM-5.2 with reasoning enabled", async () => { + const handler = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + friendliApiKey: "test-key", + enableReasoningEffort: true, + reasoningEffort: "high", + }) + + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ async next() { return { done: true } } }), + })) + + await handler.createMessage("system", []).next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: "zai-org/GLM-5.2", + reasoning_effort: "high", + chat_template_kwargs: { enable_thinking: true }, + parse_reasoning: true, + include_reasoning: true, + }), + undefined, + ) + }) + + it("should send enable_thinking: false when enableReasoningEffort is false on controllable model", async () => { + const handler = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + friendliApiKey: "test-...ey", + enableReasoningEffort: false, + }) + + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ async next() { return { done: true } } }), + })) + + await handler.createMessage("system", []).next() + + const callArgs = mockCreate.mock.calls[0][0] as any + expect(callArgs.reasoning_effort).toBeUndefined() + expect(callArgs.chat_template_kwargs).toEqual({ enable_thinking: false }) + expect(callArgs.parse_reasoning).toBeUndefined() + expect(callArgs.include_reasoning).toBeUndefined() + }) + + it("should send enable_thinking: false when reasoningEffort is none on controllable model", async () => { + const handler = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + friendliApiKey: "test-...ey", + enableReasoningEffort: true, + reasoningEffort: "none", + }) + + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ async next() { return { done: true } } }), + })) + + await handler.createMessage("system", []).next() + + const callArgs = mockCreate.mock.calls[0][0] as any + expect(callArgs.reasoning_effort).toBeUndefined() + expect(callArgs.chat_template_kwargs).toEqual({ enable_thinking: false }) + expect(callArgs.parse_reasoning).toBeUndefined() + }) + + it("should send enable_thinking: false when reasoningEffort is disable on controllable model", async () => { + const handler = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + friendliApiKey: "test-...ey", + enableReasoningEffort: true, + reasoningEffort: "disable", + }) + + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ async next() { return { done: true } } }), + })) + + await handler.createMessage("system", []).next() + + const callArgs = mockCreate.mock.calls[0][0] as any + expect(callArgs.reasoning_effort).toBeUndefined() + expect(callArgs.chat_template_kwargs).toEqual({ enable_thinking: false }) + expect(callArgs.parse_reasoning).toBeUndefined() + }) + + it("should use model default reasoningEffort when no explicit settings are provided", async () => { + const handler = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + friendliApiKey: "test-...ey", + // No enableReasoningEffort or reasoningEffort — model default "high" kicks in + }) + + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ async next() { return { done: true } } }), + })) + + await handler.createMessage("system", []).next() + + const callArgs = mockCreate.mock.calls[0][0] as any + 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) + }) + + it("should include parse_reasoning for always-reasoning MiniMax-M2.5", async () => { + const handler = new FriendliHandler({ + apiModelId: "MiniMaxAI/MiniMax-M2.5", + friendliApiKey: "test-key", + enableReasoningEffort: true, + }) + + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ async next() { return { done: true } } }), + })) + + await handler.createMessage("system", []).next() + + const callArgs = mockCreate.mock.calls[0][0] as any + expect(callArgs.parse_reasoning).toBe(true) + expect(callArgs.include_reasoning).toBe(true) + expect(callArgs.chat_template_kwargs).toBeUndefined() + expect(callArgs.reasoning_effort).toBeUndefined() + }) + + it("should not include any reasoning params for non-reasoning DeepSeek-V3.2", async () => { + const handler = new FriendliHandler({ + apiModelId: "deepseek-ai/DeepSeek-V3.2", + friendliApiKey: "test-key", + enableReasoningEffort: true, + reasoningEffort: "high", + }) + + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ async next() { return { done: true } } }), + })) + + await handler.createMessage("system", []).next() + + const callArgs = mockCreate.mock.calls[0][0] as any + expect(callArgs.reasoning_effort).toBeUndefined() + expect(callArgs.chat_template_kwargs).toBeUndefined() + expect(callArgs.parse_reasoning).toBeUndefined() + }) + + it("should handle delta.reasoning_content from parse_reasoning=true stream", async () => { + const handler = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + friendliApiKey: "test-key", + enableReasoningEffort: true, + reasoningEffort: "high", + }) + + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { reasoning_content: "Let me think..." } }], + usage: null, + } + yield { + choices: [{ delta: { content: "The answer is 42" } }], + usage: null, + } + yield { + choices: [{ delta: {} }], + usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, + } + }, + })) + + const stream = handler.createMessage("system", []) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks).toContainEqual({ type: "reasoning", text: "Let me think..." }) + expect(chunks).toContainEqual({ type: "text", text: "The answer is 42" }) + }) + + it("completePrompt should include reasoning params when enabled", async () => { + const handler = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + friendliApiKey: "test-key", + enableReasoningEffort: true, + reasoningEffort: "medium", + }) + + mockCreate.mockResolvedValueOnce({ + choices: [{ message: { content: "test result" } }], + }) + + await handler.completePrompt("test") + + const callArgs = mockCreate.mock.calls[0][0] as any + expect(callArgs.reasoning_effort).toBe("medium") + expect(callArgs.chat_template_kwargs).toEqual({ enable_thinking: true }) + expect(callArgs.parse_reasoning).toBe(true) + }) +}) diff --git a/src/api/providers/friendli.ts b/src/api/providers/friendli.ts index f9aa4fa20c..ae7ca7cf43 100644 --- a/src/api/providers/friendli.ts +++ b/src/api/providers/friendli.ts @@ -1,12 +1,44 @@ +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" + import { type FriendliModelId, friendliDefaultModelId, friendliModels } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" +import { shouldUseReasoningEffort, getModelMaxOutputTokens } from "../../shared/api" + +import { convertToOpenAiMessages } from "../transform/openai-format" +import { getModelParams } from "../transform/model-params" import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" +import { handleOpenAIError } from "./utils/error-handler" +import type { ApiHandlerCreateMessageMetadata } from "../index" + +/** + * Friendli extends the OpenAI Chat Completions API with these non-standard fields: + * - reasoning_effort: enum (minimal, low, medium, high, xhigh, max) — reasoning depth + * - chat_template_kwargs: { enable_thinking: boolean } — toggles thinking for controllable models + * - parse_reasoning / include_reasoning: when true, Friendli streams reasoning via + * delta.reasoning_content (which extractReasoningFromDelta already handles) + * - reasoning_budget: integer token budget (not currently surfaced in settings UI) + */ +type FriendliChatCompletionParams = OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming & { + chat_template_kwargs?: { enable_thinking: boolean } + parse_reasoning?: boolean + include_reasoning?: boolean + // Friendli's reasoning_effort supports a broader enum than OpenAI's type allows + reasoning_effort?: + | OpenAI.Chat.Completions.ChatCompletionCreateParams["reasoning_effort"] + | "minimal" + | "xhigh" + | "max" +} /** * Handler for the Friendli Model APIs (OpenAI-compatible). * Routes chat completions to `https://api.friendli.ai/serverless/v1`. + * + * Overrides `createStream` and `completePrompt` to inject Friendli-specific + * reasoning parameters that the base class doesn't know about. */ export class FriendliHandler extends BaseOpenAiCompatibleProvider { /** @@ -23,4 +55,154 @@ export class FriendliHandler extends BaseOpenAiCompatibleProvider { + const { info: modelInfo, reasoningEffort } = this.getModel() + const extra: Partial = {} + + const isControllableReasoning = Array.isArray(modelInfo.supportsReasoningEffort) + + const useReasoningEffort = modelInfo.supportsReasoningEffort + ? shouldUseReasoningEffort({ model: modelInfo, settings: this.options }) + : false + + const useReasoningBinary = + !!(modelInfo.supportsReasoningBinary && this.options.enableReasoningEffort) + + // User disabled reasoning on a controllable model — explicitly turn thinking off. + // The model's Jinja chat template defaults enable_thinking to true, so omitting + // the param would leave reasoning active (burning tokens against user intent). + if (isControllableReasoning && !useReasoningEffort) { + extra.chat_template_kwargs = { enable_thinking: false } + return extra + } + + // Non-reasoning model and no binary reasoning — nothing to send + if (!useReasoningEffort && !useReasoningBinary) { + return extra + } + + // Reasoning is enabled + if (useReasoningEffort) { + extra.parse_reasoning = true + extra.include_reasoning = true + extra.chat_template_kwargs = { enable_thinking: true } + + if (reasoningEffort) { + extra.reasoning_effort = reasoningEffort as FriendliChatCompletionParams["reasoning_effort"] + } + } + + // Always-reasoning models (MiniMax-M2.5) + if (useReasoningBinary) { + extra.parse_reasoning = true + extra.include_reasoning = true + } + + return extra + } + + /** + * Override createStream to inject Friendli-specific reasoning params. + * The base class createMessage() calls createStream and handles all stream + * processing (TagMatcher, extractReasoningFromDelta, tool calls, usage). + */ + protected override createStream( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + requestOptions?: OpenAI.RequestOptions, + ) { + const friendliExtra = this.buildFriendliReasoningParams() + + const { id: model, info } = this.getModel() + + // Centralized cap: clamp to 20% of the context window + const max_tokens = getModelMaxOutputTokens({ + modelId: model, + model: info, + settings: this.options, + format: "openai", + }) ?? undefined + + const temperature = this.options.modelTemperature ?? info.defaultTemperature ?? this.defaultTemperature + + const params: FriendliChatCompletionParams = { + model, + max_tokens, + temperature, + messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)], + stream: true, + stream_options: { include_usage: true }, + tools: this.convertToolsForOpenAI(metadata?.tools), + tool_choice: metadata?.tool_choice, + parallel_tool_calls: metadata?.parallelToolCalls ?? true, + ...friendliExtra, + } + + try { + return this.client.chat.completions.create( + params as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming, + requestOptions, + ) + } catch (error) { + throw handleOpenAIError(error, this.providerName) + } + } + + override async completePrompt(prompt: string): Promise { + const { id: modelId, info: modelInfo } = this.getModel() + const friendliExtra = this.buildFriendliReasoningParams() + + const params: OpenAI.Chat.Completions.ChatCompletionCreateParams & Partial = { + model: modelId, + messages: [{ role: "user", content: prompt }], + ...friendliExtra, + } + + try { + const response = await this.client.chat.completions.create(params as any) + + // Check for provider-specific error responses (e.g., MiniMax base_resp) + const responseAny = response as any + if (responseAny.base_resp?.status_code && responseAny.base_resp.status_code !== 0) { + throw new Error( + `${this.providerName} API Error (${responseAny.base_resp.status_code}): ${responseAny.base_resp.status_msg || "Unknown error"}`, + ) + } + + return response.choices?.[0]?.message.content || "" + } catch (error) { + throw handleOpenAIError(error, this.providerName) + } + } }