From eb3cd4cf4e561556b8451b79675a67a0185e6a1e Mon Sep 17 00:00:00 2001 From: siyoon Date: Mon, 13 Jul 2026 15:02:46 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(friendli):=20add=20reasoning=20params?= =?UTF-8?q?=20support=20=E2=80=94=20reasoning=5Feffort,=20chat=5Ftemplate?= =?UTF-8?q?=5Fkwargs,=20parse=5Freasoning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add supportsReasoningEffort + reasoningEffort to GLM-5.x model catalog entries - Add supportsReasoningBinary to MiniMax-M2.5 model catalog entry - Add 'ultracode' to supportsReasoningEffort schema in model.ts - Override createStream + completePrompt in FriendliHandler - buildFriendliReasoningParams(): injects Friendli-specific reasoning params - Controllable (GLM-5.x): reasoning_effort + chat_template_kwargs + parse_reasoning - Always-reasoning (MiniMax-M2.5): parse_reasoning only - Non-reasoning: no extra params - Add 8 reasoning-specific test cases (29 total) --- packages/types/src/providers/friendli.ts | 11 +- src/api/providers/__tests__/friendli.spec.ts | 187 +++++++++++++++++++ src/api/providers/friendli.ts | 171 +++++++++++++++++ 3 files changed, 366 insertions(+), 3 deletions(-) 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..141e0903c8 100644 --- a/src/api/providers/__tests__/friendli.spec.ts +++ b/src/api/providers/__tests__/friendli.spec.ts @@ -394,3 +394,190 @@ 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 not send any reasoning params when enableReasoningEffort is false — model default applies", async () => { + const handler = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + friendliApiKey: "test-friendli-api-key", + 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).toBeUndefined() + expect(callArgs.parse_reasoning).toBeUndefined() + expect(callArgs.include_reasoning).toBeUndefined() + }) + + it("should not send any reasoning params when reasoningEffort is none — model default applies", async () => { + const handler = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + friendliApiKey: "test-friendli-api-key", + 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).toBeUndefined() + expect(callArgs.parse_reasoning).toBeUndefined() + }) + + it("should not send any reasoning params when reasoningEffort is disable — model default applies", async () => { + const handler = new FriendliHandler({ + apiModelId: "zai-org/GLM-5.2", + friendliApiKey: "test-friendli-api-key", + 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).toBeUndefined() + expect(callArgs.parse_reasoning).toBeUndefined() + }) + + 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..4381c3b70b 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,143 @@ export class FriendliHandler extends BaseOpenAiCompatibleProvider { + const { info: modelInfo, reasoningEffort } = this.getModel() + const extra: Partial = {} + + const useReasoningEffort = modelInfo.supportsReasoningEffort + ? shouldUseReasoningEffort({ model: modelInfo, settings: this.options }) + : false + + const useReasoningBinary = + !!(modelInfo.supportsReasoningBinary && this.options.enableReasoningEffort) + + // No reasoning support or user explicitly disabled — send nothing, let model use its default + if (!useReasoningEffort && !useReasoningBinary) { + return extra + } + + // parse_reasoning + include_reasoning: enable for all reasoning-enabled cases + extra.parse_reasoning = true + extra.include_reasoning = true + + // reasoning_effort: for controllable reasoning models (GLM-5.x) + // getModelParams already filters out "disable" and "none", so reasoningEffort + // from getModel() is a valid effort value or undefined. + if (useReasoningEffort && reasoningEffort) { + extra.reasoning_effort = reasoningEffort as FriendliChatCompletionParams["reasoning_effort"] + } + + // chat_template_kwargs: only for controllable reasoning models (GLM-5.x) + // MiniMax-M2.5 is always-reasoning — no enable_thinking toggle + if (useReasoningEffort && Array.isArray(modelInfo.supportsReasoningEffort)) { + extra.chat_template_kwargs = { enable_thinking: 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) + } + } } From 645b5f1226635991baf4f9ae74df86fbb798a7ea Mon Sep 17 00:00:00 2001 From: siyoon Date: Mon, 13 Jul 2026 16:16:33 +0900 Subject: [PATCH 2/2] fix(friendli): explicitly disable thinking for controllable models when reasoning is off GLM-5.x Jinja template defaults enable_thinking to true, so omitting chat_template_kwargs when user sets none/disable leaves reasoning active. Now sends { enable_thinking: false } to prevent burning reasoning tokens against user intent. Also adds default model settings test per CodeRabbit. --- src/api/providers/__tests__/friendli.spec.ts | 38 ++++++++++++---- src/api/providers/friendli.ts | 47 ++++++++++++-------- 2 files changed, 58 insertions(+), 27 deletions(-) diff --git a/src/api/providers/__tests__/friendli.spec.ts b/src/api/providers/__tests__/friendli.spec.ts index 141e0903c8..0d008e80ea 100644 --- a/src/api/providers/__tests__/friendli.spec.ts +++ b/src/api/providers/__tests__/friendli.spec.ts @@ -426,10 +426,10 @@ describe("FriendliHandler — Friendli-specific reasoning params", () => { ) }) - it("should not send any reasoning params when enableReasoningEffort is false — model default applies", async () => { + 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-friendli-api-key", + friendliApiKey: "test-...ey", enableReasoningEffort: false, }) @@ -441,15 +441,15 @@ describe("FriendliHandler — Friendli-specific reasoning params", () => { const callArgs = mockCreate.mock.calls[0][0] as any expect(callArgs.reasoning_effort).toBeUndefined() - expect(callArgs.chat_template_kwargs).toBeUndefined() + expect(callArgs.chat_template_kwargs).toEqual({ enable_thinking: false }) expect(callArgs.parse_reasoning).toBeUndefined() expect(callArgs.include_reasoning).toBeUndefined() }) - it("should not send any reasoning params when reasoningEffort is none — model default applies", async () => { + 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-friendli-api-key", + friendliApiKey: "test-...ey", enableReasoningEffort: true, reasoningEffort: "none", }) @@ -462,14 +462,14 @@ describe("FriendliHandler — Friendli-specific reasoning params", () => { const callArgs = mockCreate.mock.calls[0][0] as any expect(callArgs.reasoning_effort).toBeUndefined() - expect(callArgs.chat_template_kwargs).toBeUndefined() + expect(callArgs.chat_template_kwargs).toEqual({ enable_thinking: false }) expect(callArgs.parse_reasoning).toBeUndefined() }) - it("should not send any reasoning params when reasoningEffort is disable — model default applies", async () => { + 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-friendli-api-key", + friendliApiKey: "test-...ey", enableReasoningEffort: true, reasoningEffort: "disable", }) @@ -482,10 +482,30 @@ describe("FriendliHandler — Friendli-specific reasoning params", () => { const callArgs = mockCreate.mock.calls[0][0] as any expect(callArgs.reasoning_effort).toBeUndefined() - expect(callArgs.chat_template_kwargs).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", diff --git a/src/api/providers/friendli.ts b/src/api/providers/friendli.ts index 4381c3b70b..ae7ca7cf43 100644 --- a/src/api/providers/friendli.ts +++ b/src/api/providers/friendli.ts @@ -78,16 +78,19 @@ 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 @@ -95,26 +98,34 @@ export class FriendliHandler extends BaseOpenAiCompatibleProvider