mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
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:
parent
359fbc922b
commit
b90e80f509
7 changed files with 1197 additions and 15 deletions
|
|
@ -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
|
||||
|
|
|
|||
485
src/LLMProviders/ChatOpenRouter.ts
Normal file
485
src/LLMProviders/ChatOpenRouter.ts
Normal 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: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
548
src/LLMProviders/chainRunner/utils/ThinkBlockStreamer.test.ts
Normal file
548
src/LLMProviders/chainRunner/utils/ThinkBlockStreamer.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 "Reasoning" capability above to use this feature.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue