Enable Openrouter thinking tokens (#1954)

* Fix sources css

* Enable thinking tokens for openrouter models

* Add reasoning effort for openrouter models

* Add unit tests for ThinkBlockStreamer to validate reasoning details handling and proper <think> tag placement

* Enable token count from openrouter models
This commit is contained in:
Logan Yang 2025-10-22 23:29:59 -07:00 committed by GitHub
parent 359fbc922b
commit b90e80f509
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1197 additions and 15 deletions

View file

@ -192,6 +192,15 @@ For detailed architecture diagrams and documentation, see [`MESSAGE_ARCHITECTURE
- `logError()` for errors
- Import from logger: `import { logInfo, logWarn, logError } from "@/logger"`
### CSS & Styling
- **NEVER edit `styles.css` directly** - This is a generated file
- **Source file**: `src/styles/tailwind.css` - Edit this file for custom CSS
- **Build process**: `npm run build:tailwind` compiles `src/styles/tailwind.css``styles.css`
- **Tailwind classes**: Use Tailwind utility classes in components (see `tailwind.config.js` for available classes)
- **Custom CSS**: Add custom styles to `src/styles/tailwind.css` after the `@import` statements
- After editing CSS, always run `npm run build` to regenerate `styles.css`
## Testing Guidelines
- Unit tests use Jest with TypeScript support

View file

@ -0,0 +1,485 @@
import { BaseChatModelParams } from "@langchain/core/language_models/chat_models";
import { AIMessageChunk } from "@langchain/core/messages";
import type { UsageMetadata } from "@langchain/core/messages";
import { ChatGenerationChunk } from "@langchain/core/outputs";
import { ChatOpenAI } from "@langchain/openai";
import OpenAI from "openai";
import { logInfo } from "@/logger";
type OpenRouterChatChunk = OpenAI.ChatCompletionChunk;
type OpenRouterUsage = NonNullable<OpenRouterChatChunk["usage"]>;
type OpenRouterMessageParam = OpenAI.ChatCompletionMessageParam;
/**
* ChatOpenRouter extends ChatOpenAI to support OpenRouter-specific features,
* particularly reasoning/thinking tokens.
*
* OpenRouter exposes thinking tokens via the `reasoning` request parameter
* and responds with `reasoning_details` in both streaming and non-streaming modes.
*
* @see https://openrouter.ai/docs/use-cases/reasoning-tokens
*/
export interface ChatOpenRouterInput extends BaseChatModelParams {
/**
* Enable reasoning/thinking tokens from OpenRouter
* When true, requests will include reasoning parameters
*/
enableReasoning?: boolean;
/**
* Reasoning effort level: "minimal", "low", "medium", or "high"
* Controls the amount of reasoning the model uses
* Note: "minimal" will be treated as "low" for OpenRouter
*/
reasoningEffort?: "minimal" | "low" | "medium" | "high";
// All other ChatOpenAI parameters
modelName?: string;
apiKey?: string;
configuration?: any;
temperature?: number;
maxTokens?: number;
topP?: number;
frequencyPenalty?: number;
streaming?: boolean;
maxRetries?: number;
maxConcurrency?: number;
[key: string]: any;
}
export class ChatOpenRouter extends ChatOpenAI {
private enableReasoning: boolean;
private reasoningEffort?: "minimal" | "low" | "medium" | "high";
private openaiClient: OpenAI;
constructor(fields: ChatOpenRouterInput) {
const { enableReasoning = false, reasoningEffort, ...rest } = fields;
// Pass all other parameters to ChatOpenAI
super(rest);
this.enableReasoning = enableReasoning;
this.reasoningEffort = reasoningEffort;
// Create our own OpenAI client for raw access
this.openaiClient = new OpenAI({
apiKey: fields.apiKey,
baseURL: fields.configuration?.baseURL || "https://openrouter.ai/api/v1",
defaultHeaders: fields.configuration?.defaultHeaders,
fetch: fields.configuration?.fetch,
dangerouslyAllowBrowser: true,
});
}
/**
* Override the invocation parameters to include reasoning when enabled
*/
override invocationParams(options?: this["ParsedCallOptions"]): any {
const baseParams = super.invocationParams(options);
// Add reasoning parameter if enabled
if (this.enableReasoning) {
// Per OpenRouter docs:
// - For Anthropic models: MUST use reasoning.max_tokens or reasoning.effort
// - For other models: Can use reasoning.enabled
// - max_tokens must be strictly higher than reasoning budget
// Prefer effort if provided, otherwise fall back to max_tokens
if (this.reasoningEffort) {
// Map "minimal" to "low" since OpenRouter doesn't support "minimal"
const effort = this.reasoningEffort === "minimal" ? "low" : this.reasoningEffort;
logInfo(`OpenRouter reasoning enabled with effort: ${effort}`);
return {
...baseParams,
reasoning: {
effort,
},
};
} else {
logInfo(`OpenRouter reasoning enabled with max_tokens: 1024`);
return {
...baseParams,
reasoning: {
max_tokens: 1024,
},
};
}
}
return baseParams;
}
/**
* Override to use raw OpenAI SDK to access reasoning_details
* LangChain filters out reasoning_details, so we bypass it completely
*/
override async *_streamResponseChunks(
messages: any[],
options: this["ParsedCallOptions"],
_runManager?: any
): AsyncGenerator<ChatGenerationChunk> {
const params = this.invocationParams(options);
const openaiMessages = this.toOpenRouterMessages(messages);
const stream = (await this.openaiClient.chat.completions.create({
...params,
messages: openaiMessages,
stream: true,
stream_options: {
...(params.stream_options ?? {}),
include_usage: true,
},
})) as unknown as AsyncIterable<OpenRouterChatChunk>;
let usageSummary: OpenRouterUsage | undefined;
for await (const rawChunk of stream as AsyncIterable<OpenRouterChatChunk>) {
if (rawChunk.usage) {
usageSummary = rawChunk.usage;
}
const choice = rawChunk.choices?.[0];
const delta = choice?.delta;
if (!choice || !delta) {
continue;
}
const reasoningText = this.normalizeReasoningChunk(
(delta as Record<string, unknown>)?.reasoning
);
const reasoningDetails = this.extractReasoningDetails(choice);
const content = this.extractDeltaContent(delta.content);
const messageChunk = this.buildMessageChunk({
rawChunk,
delta,
content,
finishReason: choice.finish_reason,
reasoningDetails,
reasoningText,
});
const generationChunk = new ChatGenerationChunk({
message: messageChunk,
text: typeof messageChunk.content === "string" ? messageChunk.content : "",
generationInfo: {
finish_reason: choice.finish_reason,
system_fingerprint: rawChunk.system_fingerprint,
model: rawChunk.model,
},
});
yield generationChunk;
if (generationChunk.text) {
await _runManager?.handleLLMNewToken(generationChunk.text);
}
}
if (usageSummary) {
yield this.buildUsageGenerationChunk(usageSummary);
}
if (options.signal?.aborted) {
throw new Error("AbortError");
}
}
/**
* Convert LangChain messages to OpenRouter-ready messages.
*
* @param messages LangChain messages passed into the model
* @returns Messages formatted for the OpenRouter API
*/
private toOpenRouterMessages(messages: any[]): OpenRouterMessageParam[] {
return messages.map((msg) => {
const role = typeof msg._getType === "function" ? msg._getType() : (msg.role ?? "user");
const mappedRole =
role === "human"
? "user"
: role === "ai"
? "assistant"
: (role as OpenAI.ChatCompletionRole);
if (msg.tool_call_id) {
return {
role: "tool",
content: msg.content,
tool_call_id: msg.tool_call_id,
} as OpenRouterMessageParam;
}
if (msg.additional_kwargs?.function_call) {
return {
role: mappedRole,
content: msg.content,
function_call: msg.additional_kwargs.function_call,
} as OpenRouterMessageParam;
}
return {
role: mappedRole,
content: msg.content,
} as OpenRouterMessageParam;
});
}
/**
* Build an `AIMessageChunk` enriched with reasoning metadata.
*
* @param config Chunk configuration values extracted from the stream
* @returns AI message chunk ready for downstream streaming utilities
*/
private buildMessageChunk(config: {
rawChunk: OpenRouterChatChunk;
delta: Record<string, any>;
content: string;
finishReason: string | null | undefined;
reasoningText?: string;
reasoningDetails?: unknown[];
}): AIMessageChunk {
const { rawChunk, delta, content, finishReason, reasoningText, reasoningDetails } = config;
const toolCallChunks = this.extractToolCallChunks(delta.tool_calls);
const additionalKwargs: Record<string, unknown> = {};
if (delta.function_call) {
additionalKwargs.function_call = delta.function_call;
}
if (Array.isArray(delta.tool_calls)) {
additionalKwargs.tool_calls = delta.tool_calls;
}
const deltaPayload: Record<string, unknown> = {};
if (reasoningText) {
deltaPayload.reasoning = reasoningText;
}
if (reasoningDetails && reasoningDetails.length > 0) {
deltaPayload.reasoning_details = reasoningDetails;
}
if (Object.keys(deltaPayload).length > 0) {
additionalKwargs.delta = {
...(additionalKwargs.delta as Record<string, unknown>),
...deltaPayload,
};
}
if (reasoningDetails && reasoningDetails.length > 0) {
additionalKwargs.reasoning_details = reasoningDetails;
}
const responseMetadata = this.buildResponseMetadata(rawChunk, finishReason);
return new AIMessageChunk({
content,
additional_kwargs: additionalKwargs,
tool_call_chunks: toolCallChunks,
response_metadata: responseMetadata,
id: rawChunk.id,
});
}
/**
* Normalize streamed reasoning payloads into plain text for the UI.
*
* @param reasoning Arbitrary reasoning payload returned by OpenRouter
* @returns Normalized reasoning text or undefined
*/
private normalizeReasoningChunk(reasoning: unknown): string | undefined {
if (!reasoning) {
return undefined;
}
if (typeof reasoning === "string") {
return reasoning;
}
if (Array.isArray(reasoning)) {
return reasoning
.map((item) => this.normalizeReasoningChunk(item))
.filter((item): item is string => Boolean(item))
.join("");
}
if (typeof reasoning === "object") {
const record = reasoning as Record<string, unknown>;
const candidates = [
record.output_text,
record.text,
record.reasoning,
record.thinking,
record.content,
];
const normalized = candidates.find((value) => typeof value === "string");
if (typeof normalized === "string") {
return normalized;
}
}
return undefined;
}
/**
* Extract reasoning details arrays from the streamed choice payload.
*
* @param choice Chunk choice object from the OpenRouter stream
* @returns Array of reasoning detail entries, if present
*/
private extractReasoningDetails(choice: any): unknown[] | undefined {
const candidate =
choice?.delta?.reasoning_details ??
choice?.message?.reasoning_details ??
choice?.reasoning_details;
if (!Array.isArray(candidate)) {
return undefined;
}
return candidate.filter((detail) => detail !== undefined && detail !== null);
}
/**
* Flatten OpenRouter delta content into a single text string.
*
* @param content Delta content payload
* @returns Text representation for downstream streaming
*/
private extractDeltaContent(content: unknown): string {
if (typeof content === "string") {
return content;
}
if (Array.isArray(content)) {
return content
.map((part) => {
if (typeof part === "string") {
return part;
}
if (part && typeof part === "object" && typeof part.text === "string") {
return part.text;
}
return "";
})
.join("");
}
return "";
}
/**
* Map raw OpenRouter tool call deltas into LangChain tool call chunks.
*
* @param toolCalls Tool call deltas returned by OpenRouter
* @returns Tool call chunk array compatible with LangChain
*/
private extractToolCallChunks(
toolCalls: any
):
| Array<{ name?: string; args?: string; id?: string; index?: number; type: "tool_call_chunk" }>
| undefined {
if (!Array.isArray(toolCalls)) {
return undefined;
}
return toolCalls.map((call) => ({
name: call?.function?.name,
args: call?.function?.arguments,
id: call?.id,
index: call?.index,
type: "tool_call_chunk" as const,
}));
}
/**
* Build response metadata payload with finish reason and usage info.
*
* @param rawChunk Raw streaming chunk from OpenRouter
* @param finishReason Stop reason reported by the model
* @returns Metadata object attached to each AI message chunk
*/
private buildResponseMetadata(
rawChunk: OpenRouterChatChunk,
finishReason: string | null | undefined
): Record<string, unknown> {
const metadata: Record<string, unknown> = {
model_provider: "openrouter",
};
if (finishReason) {
metadata.finish_reason = finishReason;
}
if (rawChunk.model) {
metadata.model = rawChunk.model;
}
if (rawChunk.system_fingerprint) {
metadata.system_fingerprint = rawChunk.system_fingerprint;
}
if (rawChunk.usage) {
metadata.usage = { ...rawChunk.usage };
metadata.tokenUsage = {
promptTokens: rawChunk.usage.prompt_tokens,
completionTokens: rawChunk.usage.completion_tokens,
totalTokens: rawChunk.usage.total_tokens,
};
}
return metadata;
}
/**
* Create a terminal usage chunk so downstream consumers can capture token usage.
*
* @param usage Usage payload returned by the streaming API
* @returns Chat generation chunk containing usage metadata
*/
private buildUsageGenerationChunk(usage: OpenRouterUsage): ChatGenerationChunk {
const inputTokenDetails: Record<string, number> = {};
const outputTokenDetails: Record<string, number> = {};
const promptDetails = usage.prompt_tokens_details ?? {};
if (typeof promptDetails.audio_tokens === "number") {
inputTokenDetails.audio = promptDetails.audio_tokens;
}
if (typeof promptDetails.cached_tokens === "number") {
inputTokenDetails.cache_read = promptDetails.cached_tokens;
}
const completionDetails = usage.completion_tokens_details ?? {};
if (typeof completionDetails.audio_tokens === "number") {
outputTokenDetails.audio = completionDetails.audio_tokens;
}
if (typeof completionDetails.reasoning_tokens === "number") {
outputTokenDetails.reasoning = completionDetails.reasoning_tokens;
}
const usageMetadata: UsageMetadata = {
input_tokens: usage.prompt_tokens ?? 0,
output_tokens: usage.completion_tokens ?? 0,
total_tokens: usage.total_tokens ?? 0,
};
if (Object.keys(inputTokenDetails).length > 0) {
usageMetadata.input_token_details = inputTokenDetails;
}
if (Object.keys(outputTokenDetails).length > 0) {
usageMetadata.output_token_details = outputTokenDetails;
}
const messageChunk = new AIMessageChunk({
content: "",
response_metadata: { usage: { ...usage } },
usage_metadata: usageMetadata,
});
return new ChatGenerationChunk({
message: messageChunk,
text: "",
});
}
}

View file

@ -0,0 +1,548 @@
import { ThinkBlockStreamer } from "./ThinkBlockStreamer";
describe("ThinkBlockStreamer", () => {
describe("OpenRouter reasoning_details format", () => {
it("should handle reasoning_details with text content", () => {
let currentMessage = "";
const streamer = new ThinkBlockStreamer((msg) => {
currentMessage = msg;
});
// First chunk with reasoning_details containing text
streamer.processChunk({
content: "",
additional_kwargs: {
reasoning_details: [
{
text: "Let me think about this problem...",
},
],
},
});
expect(currentMessage).toBe("\n<think>Let me think about this problem...");
// Second chunk with more reasoning
streamer.processChunk({
content: "",
additional_kwargs: {
reasoning_details: [
{
text: " I need to consider several factors.",
},
],
},
});
expect(currentMessage).toBe(
"\n<think>Let me think about this problem... I need to consider several factors."
);
// Third chunk with regular content (should close think block)
streamer.processChunk({
content: "Here is my answer.",
additional_kwargs: {},
});
expect(currentMessage).toBe(
"\n<think>Let me think about this problem... I need to consider several factors.</think>Here is my answer."
);
const result = streamer.close();
expect(result.content).toBe(
"\n<think>Let me think about this problem... I need to consider several factors.</think>Here is my answer."
);
});
it("should handle reasoning_details with summary content", () => {
let currentMessage = "";
const streamer = new ThinkBlockStreamer((msg) => {
currentMessage = msg;
});
streamer.processChunk({
content: "",
additional_kwargs: {
reasoning_details: [
{
summary: "Analyzed the problem systematically",
},
],
},
});
expect(currentMessage).toBe("\n<think>Analyzed the problem systematically");
streamer.processChunk({
content: "Based on my analysis, the answer is 42.",
additional_kwargs: {},
});
expect(currentMessage).toBe(
"\n<think>Analyzed the problem systematically</think>Based on my analysis, the answer is 42."
);
});
it("should handle encrypted reasoning_details", () => {
let currentMessage = "";
const streamer = new ThinkBlockStreamer((msg) => {
currentMessage = msg;
});
streamer.processChunk({
content: "",
additional_kwargs: {
reasoning_details: [
{
encrypted: true,
},
],
},
});
expect(currentMessage).toBe("\n<think>[Encrypted reasoning]");
streamer.processChunk({
content: "The answer is available.",
additional_kwargs: {},
});
expect(currentMessage).toBe("\n<think>[Encrypted reasoning]</think>The answer is available.");
});
it("should NOT treat empty reasoning_details array as thinking content", () => {
let currentMessage = "";
const streamer = new ThinkBlockStreamer((msg) => {
currentMessage = msg;
});
// This was the bug: empty reasoning_details array should not trigger thinking mode
streamer.processChunk({
content: "Regular content",
additional_kwargs: {
reasoning_details: [],
},
});
// Should NOT have <think> tags since reasoning_details is empty
expect(currentMessage).toBe("Regular content");
expect(currentMessage).not.toContain("<think>");
});
it("should handle delta.reasoning for streaming", () => {
let currentMessage = "";
const streamer = new ThinkBlockStreamer((msg) => {
currentMessage = msg;
});
// First chunk with delta.reasoning
streamer.processChunk({
content: "",
additional_kwargs: {
delta: {
reasoning: "Thinking step 1: ",
},
},
});
expect(currentMessage).toBe("\n<think>Thinking step 1: ");
// Second chunk with more delta.reasoning
streamer.processChunk({
content: "",
additional_kwargs: {
delta: {
reasoning: "Thinking step 2.",
},
},
});
expect(currentMessage).toBe("\n<think>Thinking step 1: Thinking step 2.");
// Regular content should close think block
streamer.processChunk({
content: "Here's the result.",
additional_kwargs: {},
});
expect(currentMessage).toBe(
"\n<think>Thinking step 1: Thinking step 2.</think>Here's the result."
);
});
});
describe("Proper <think> tag placement", () => {
it("should close <think> tag BEFORE regular content, not after", () => {
let currentMessage = "";
const streamer = new ThinkBlockStreamer((msg) => {
currentMessage = msg;
});
// Thinking content
streamer.processChunk({
content: "",
additional_kwargs: {
reasoning_details: [{ text: "Analyzing..." }],
},
});
expect(currentMessage).toBe("\n<think>Analyzing...");
// Regular content with empty reasoning_details
// This should close </think> BEFORE adding "20 minutes"
streamer.processChunk({
content: "20 minutes",
additional_kwargs: {
reasoning_details: [],
},
});
// Bug was: "(</think>20 minutes)"
// Correct: "</think>20 minutes" or "20 minutes" (if properly closed)
expect(currentMessage).toBe("\n<think>Analyzing...</think>20 minutes");
expect(currentMessage).not.toMatch(/\(.*<\/think>.*\)/);
});
it("should handle multiple transitions between thinking and regular content", () => {
let currentMessage = "";
const streamer = new ThinkBlockStreamer((msg) => {
currentMessage = msg;
});
// First thinking block
streamer.processChunk({
content: "",
additional_kwargs: {
reasoning_details: [{ text: "First thought" }],
},
});
// Regular content
streamer.processChunk({
content: "First answer. ",
additional_kwargs: {},
});
expect(currentMessage).toBe("\n<think>First thought</think>First answer. ");
// Second thinking block
streamer.processChunk({
content: "",
additional_kwargs: {
reasoning_details: [{ text: "Second thought" }],
},
});
// More regular content
streamer.processChunk({
content: "Second answer.",
additional_kwargs: {},
});
expect(currentMessage).toBe(
"\n<think>First thought</think>First answer. \n<think>Second thought</think>Second answer."
);
});
});
describe("Claude array-based format", () => {
it("should handle Claude's content array with thinking type", () => {
let currentMessage = "";
const streamer = new ThinkBlockStreamer((msg) => {
currentMessage = msg;
});
// Claude format with content array
streamer.processChunk({
content: [
{
type: "thinking",
thinking: "Let me analyze this...",
},
],
});
expect(currentMessage).toBe("\n<think>Let me analyze this...");
// Text content in array
streamer.processChunk({
content: [
{
type: "text",
text: "Based on my analysis, ",
},
],
});
expect(currentMessage).toBe("\n<think>Let me analyze this...</think>Based on my analysis, ");
});
it("should guard against undefined thinking content in Claude format", () => {
let currentMessage = "";
const streamer = new ThinkBlockStreamer((msg) => {
currentMessage = msg;
});
// Malformed chunk with undefined thinking
streamer.processChunk({
content: [
{
type: "thinking",
// thinking property is undefined
},
],
});
// Should not crash, and should not add undefined to response
expect(currentMessage).toBe("\n<think>");
expect(currentMessage).not.toContain("undefined");
});
});
describe("Deepseek format", () => {
it("should handle Deepseek reasoning_content", () => {
let currentMessage = "";
const streamer = new ThinkBlockStreamer((msg) => {
currentMessage = msg;
});
streamer.processChunk({
content: "",
additional_kwargs: {
reasoning_content: "Deepseek is thinking...",
},
});
expect(currentMessage).toBe("\n<think>Deepseek is thinking...");
streamer.processChunk({
content: "The answer is here.",
additional_kwargs: {},
});
expect(currentMessage).toBe("\n<think>Deepseek is thinking...</think>The answer is here.");
});
it("should guard against undefined reasoning_content in Deepseek format", () => {
let currentMessage = "";
const streamer = new ThinkBlockStreamer((msg) => {
currentMessage = msg;
});
// Malformed chunk with undefined reasoning_content
streamer.processChunk({
content: "",
additional_kwargs: {
reasoning_content: undefined,
},
});
// Should not crash, not open think block, and not add undefined
expect(currentMessage).toBe("");
expect(currentMessage).not.toContain("undefined");
expect(currentMessage).not.toContain("<think>");
});
it("should handle streaming Deepseek reasoning_content without premature closure", () => {
let currentMessage = "";
const streamer = new ThinkBlockStreamer((msg) => {
currentMessage = msg;
});
// First chunk with reasoning_content
streamer.processChunk({
content: "",
additional_kwargs: {
reasoning_content: "Thinking step 1...",
},
});
expect(currentMessage).toBe("\n<think>Thinking step 1...");
// Second chunk with MORE reasoning_content (streaming)
// This should NOT close and reopen the think block
streamer.processChunk({
content: "",
additional_kwargs: {
reasoning_content: " Step 2...",
},
});
// Should be continuous, NOT "</think>\n<think>"
expect(currentMessage).toBe("\n<think>Thinking step 1... Step 2...");
expect(currentMessage).not.toContain("</think>\n<think>");
// Third chunk with regular content
streamer.processChunk({
content: "Final answer.",
additional_kwargs: {},
});
expect(currentMessage).toBe("\n<think>Thinking step 1... Step 2...</think>Final answer.");
});
});
describe("excludeThinking option", () => {
it("should skip thinking content when excludeThinking is true", () => {
let currentMessage = "";
const streamer = new ThinkBlockStreamer(
(msg) => {
currentMessage = msg;
},
undefined,
true // excludeThinking = true
);
// Thinking content should be skipped
streamer.processChunk({
content: "",
additional_kwargs: {
reasoning_details: [{ text: "This should be skipped" }],
},
});
expect(currentMessage).toBe("");
// Regular content should still be processed
streamer.processChunk({
content: "This should be included",
additional_kwargs: {},
});
expect(currentMessage).toBe("This should be included");
});
it("should skip Claude thinking content when excludeThinking is true", () => {
let currentMessage = "";
const streamer = new ThinkBlockStreamer(
(msg) => {
currentMessage = msg;
},
undefined,
true
);
streamer.processChunk({
content: [
{
type: "thinking",
thinking: "Claude thinking",
},
],
});
expect(currentMessage).toBe("");
expect(currentMessage).not.toContain("<think>");
});
});
describe("close() method", () => {
it("should close any open think block at the end", () => {
let currentMessage = "";
const streamer = new ThinkBlockStreamer((msg) => {
currentMessage = msg;
});
streamer.processChunk({
content: "",
additional_kwargs: {
reasoning_details: [{ text: "Thinking..." }],
},
});
expect(currentMessage).toBe("\n<think>Thinking...");
const result = streamer.close();
expect(result.content).toBe("\n<think>Thinking...</think>");
});
it("should not add extra closing tag if already closed", () => {
let currentMessage = "";
const streamer = new ThinkBlockStreamer((msg) => {
currentMessage = msg;
});
streamer.processChunk({
content: "",
additional_kwargs: {
reasoning_details: [{ text: "Thinking..." }],
},
});
streamer.processChunk({
content: "Done",
additional_kwargs: {},
});
expect(currentMessage).toBe("\n<think>Thinking...</think>Done");
const result = streamer.close();
expect(result.content).toBe("\n<think>Thinking...</think>Done");
// Should not have double closing tags
expect(result.content.match(/<\/think>/g)?.length).toBe(1);
});
});
describe("mixed content scenarios", () => {
it("should handle chunks with both reasoning_details and regular content", () => {
let currentMessage = "";
const streamer = new ThinkBlockStreamer((msg) => {
currentMessage = msg;
});
// Chunk with both reasoning and content
streamer.processChunk({
content: "Some text here",
additional_kwargs: {
reasoning_details: [{ text: "Thinking first" }],
},
});
// Reasoning should be wrapped, content should be outside
expect(currentMessage).toBe("\n<think>Thinking first</think>Some text here");
});
it("should handle rapid alternation between thinking and regular content", () => {
let currentMessage = "";
const streamer = new ThinkBlockStreamer((msg) => {
currentMessage = msg;
});
const chunks = [
{ thinking: "Think 1", content: "" },
{ thinking: "", content: "Text 1" },
{ thinking: "Think 2", content: "" },
{ thinking: "", content: "Text 2" },
{ thinking: "Think 3", content: "" },
{ thinking: "", content: "Text 3" },
];
chunks.forEach((chunk) => {
if (chunk.thinking) {
streamer.processChunk({
content: "",
additional_kwargs: {
reasoning_details: [{ text: chunk.thinking }],
},
});
} else {
streamer.processChunk({
content: chunk.content,
additional_kwargs: {},
});
}
});
// Should have three separate think blocks
const thinkMatches = currentMessage.match(/<think>/g);
const thinkCloseMatches = currentMessage.match(/<\/think>/g);
expect(thinkMatches?.length).toBe(3);
expect(thinkCloseMatches?.length).toBe(3);
// Each text should be outside think blocks
expect(currentMessage).toContain("</think>Text 1");
expect(currentMessage).toContain("</think>Text 2");
expect(currentMessage).toContain("</think>Text 3");
});
});
});

View file

@ -46,6 +46,11 @@ export class ThinkBlockStreamer {
break;
}
}
// Close think block before adding text content
if (textContent && this.hasOpenThinkBlock) {
this.fullResponse += "</think>";
this.hasOpenThinkBlock = false;
}
if (textContent) {
this.fullResponse += textContent;
}
@ -77,6 +82,81 @@ export class ThinkBlockStreamer {
return false; // No thinking chunk handled
}
/**
* Handle OpenRouter reasoning/thinking content
* OpenRouter exposes reasoning via:
* - delta.reasoning (streaming)
* - reasoning_details array (final response)
*/
private handleOpenRouterChunk(chunk: any) {
let handledThinking = false;
// Handle streaming reasoning from delta
if (chunk.additional_kwargs?.delta?.reasoning) {
// Skip thinking content if excludeThinking is enabled
if (this.excludeThinking) {
return true;
}
if (!this.hasOpenThinkBlock) {
this.fullResponse += "\n<think>";
this.hasOpenThinkBlock = true;
}
this.fullResponse += chunk.additional_kwargs.delta.reasoning;
handledThinking = true;
}
// Handle reasoning_details from final response or accumulated chunks
// IMPORTANT: Only treat as thinking if the array has actual content
if (chunk.additional_kwargs?.reasoning_details) {
const details = chunk.additional_kwargs.reasoning_details;
if (Array.isArray(details) && details.length > 0) {
// Skip thinking content if excludeThinking is enabled
if (this.excludeThinking) {
return true;
}
for (const detail of details) {
if (detail.encrypted) {
// Show placeholder for encrypted reasoning
if (!this.hasOpenThinkBlock) {
this.fullResponse += "\n<think>";
this.hasOpenThinkBlock = true;
}
this.fullResponse += "[Encrypted reasoning]";
handledThinking = true;
} else if (detail.text) {
if (!this.hasOpenThinkBlock) {
this.fullResponse += "\n<think>";
this.hasOpenThinkBlock = true;
}
this.fullResponse += detail.text;
handledThinking = true;
} else if (detail.summary) {
if (!this.hasOpenThinkBlock) {
this.fullResponse += "\n<think>";
this.hasOpenThinkBlock = true;
}
this.fullResponse += detail.summary;
handledThinking = true;
}
}
}
}
// Close think block before adding regular content
if (typeof chunk.content === "string" && chunk.content && this.hasOpenThinkBlock) {
this.fullResponse += "</think>";
this.hasOpenThinkBlock = false;
}
// Handle standard string content (this is the actual response, not thinking)
if (typeof chunk.content === "string" && chunk.content) {
this.fullResponse += chunk.content;
}
return handledThinking;
}
processChunk(chunk: any) {
// If we've already decided to truncate, don't process more chunks
if (this.shouldTruncate) {
@ -95,22 +175,37 @@ export class ThinkBlockStreamer {
this.tokenUsage = usage;
}
let handledThinking = false;
// Determine if this chunk will handle thinking content
const isThinkingChunk =
Array.isArray(chunk.content) ||
chunk.additional_kwargs?.delta?.reasoning ||
(chunk.additional_kwargs?.reasoning_details &&
Array.isArray(chunk.additional_kwargs.reasoning_details) &&
chunk.additional_kwargs.reasoning_details.length > 0) ||
chunk.additional_kwargs?.reasoning_content; // Deepseek format
// Handle Claude 3.7 array-based content
if (Array.isArray(chunk.content)) {
handledThinking = this.handleClaude37Chunk(chunk.content);
} else {
// Handle deepseek format
handledThinking = this.handleDeepseekChunk(chunk);
}
// Close think block if we have one open and didn't handle thinking content
if (this.hasOpenThinkBlock && !handledThinking) {
// Close think block BEFORE processing non-thinking content
if (this.hasOpenThinkBlock && !isThinkingChunk) {
this.fullResponse += "</think>";
this.hasOpenThinkBlock = false;
}
// Now process the chunk
// Route based on the actual chunk format
if (Array.isArray(chunk.content)) {
// Claude format with content array
this.handleClaude37Chunk(chunk.content);
} else if (chunk.additional_kwargs?.reasoning_content) {
// Deepseek format with reasoning_content
this.handleDeepseekChunk(chunk);
} else if (isThinkingChunk) {
// OpenRouter format with delta.reasoning or reasoning_details
this.handleOpenRouterChunk(chunk);
} else {
// Default case: regular content or other formats
this.handleDeepseekChunk(chunk);
}
// Check if we should truncate streaming based on model adapter
if (this.modelAdapter?.shouldTruncateStreaming?.(this.fullResponse)) {
this.shouldTruncate = true;

View file

@ -3,6 +3,7 @@ import {
BREVILABS_MODELS_BASE_URL,
BUILTIN_CHAT_MODELS,
ChatModelProviders,
ModelCapability,
ProviderInfo,
} from "@/constants";
import { getDecryptedKey } from "@/encryptionService";
@ -32,6 +33,7 @@ import { ChatOllama } from "@langchain/ollama";
import { ChatOpenAI } from "@langchain/openai";
import { ChatXAI } from "@langchain/xai";
import { Notice } from "obsidian";
import { ChatOpenRouter } from "./ChatOpenRouter";
type ChatConstructorType = {
new (config: any): any;
@ -44,7 +46,7 @@ const CHAT_PROVIDER_CONSTRUCTORS = {
[ChatModelProviders.COHEREAI]: ChatCohere,
[ChatModelProviders.GOOGLE]: ChatGoogleGenerativeAI,
[ChatModelProviders.XAI]: ChatXAI,
[ChatModelProviders.OPENROUTERAI]: ChatOpenAI,
[ChatModelProviders.OPENROUTERAI]: ChatOpenRouter,
[ChatModelProviders.OLLAMA]: ChatOllama,
[ChatModelProviders.LM_STUDIO]: ChatOpenAI,
[ChatModelProviders.GROQ]: ChatGroq,
@ -243,6 +245,14 @@ export default class ChatModelManager {
"X-Title": "Obsidian Copilot",
},
},
// Enable reasoning if the model has the reasoning capability
enableReasoning: customModel.capabilities?.includes(ModelCapability.REASONING) ?? false,
// Pass reasoning effort if configured and reasoning capability is enabled
reasoningEffort:
customModel.capabilities?.includes(ModelCapability.REASONING) &&
customModel.reasoningEffort
? customModel.reasoningEffort
: undefined,
},
[ChatModelProviders.GROQ]: {
apiKey: await getDecryptedKey(customModel.apiKey || settings.groqApiKey),

View file

@ -387,6 +387,43 @@ export const ModelEditModalContent: React.FC<ModelEditModalContentProps> = ({
)}
</>
)}
{/* Reasoning Effort for OpenRouter models */}
{localModel.provider === "openrouterai" && (
<FormField>
<ParameterControl
type="select"
label="Reasoning Effort"
value={localModel.reasoningEffort}
onChange={(value) => handleLocalUpdate("reasoningEffort", value)}
disableFn={() => handleLocalReset("reasoningEffort")}
defaultValue="low"
options={[
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
]}
helpText={
<>
<p>
Controls the amount of reasoning effort the model uses. Higher effort
provides more thorough reasoning but takes longer.
</p>
<ul className="tw-mt-2 tw-space-y-1 tw-text-xs">
<li>Low: Faster responses, basic reasoning (default)</li>
<li>Medium: Balanced performance</li>
<li>High: Thorough reasoning, slower responses</li>
</ul>
{!localModel.capabilities?.includes(ModelCapability.REASONING) && (
<p className="tw-mt-2 tw-text-warning">
Enable the &quot;Reasoning&quot; capability above to use this feature.
</p>
)}
</>
}
/>
</FormField>
)}
</>
)}
</div>

View file

@ -827,9 +827,6 @@ If your plugin does not need CSS, delete this file.
margin-right: 12px;
}
.model-card-title {
}
.model-provider-wrapper {
display: flex;
flex-direction: column;
@ -953,6 +950,7 @@ If your plugin does not need CSS, delete this file.
font-weight: var(--font-medium);
min-width: var(--size-4-3);
text-align: right;
white-space: nowrap;
}
.copilot-sources__text {