From 040ed9cb9875fb9b14afe12ad05001b3e9f2b5d5 Mon Sep 17 00:00:00 2001 From: "TechnoHouse (deephbz)" <13776377+deephbz@users.noreply.github.com> Date: Sat, 8 Feb 2025 22:00:05 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9C=A8=20feat:=20Support=20include=5Frea?= =?UTF-8?q?soning=20for=20OpenRouter=20provider's=20models.=20Solves=20#57?= =?UTF-8?q?66?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/config/modelProviders/openrouter.ts | 13 +++++++++++++ src/libs/agent-runtime/openrouter/index.ts | 19 ++++++++++++------- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/config/modelProviders/openrouter.ts b/src/config/modelProviders/openrouter.ts index d2b72e437cc59..508b03664d6fc 100644 --- a/src/config/modelProviders/openrouter.ts +++ b/src/config/modelProviders/openrouter.ts @@ -214,6 +214,19 @@ const OpenRouter: ModelProviderCard = { }, releasedAt: '2024-09-05', }, + { + contextWindowTokens: 163_840, + description: 'DeepSeek-R1', + displayName: 'DeepSeek R1', + enabled: true, + functionCall: true, + id: 'deepseek/deepseek-r1', + pricing: { + input: 3, + output: 8, + }, + releasedAt: '2025-01-20', + }, { contextWindowTokens: 131_072, description: diff --git a/src/libs/agent-runtime/openrouter/index.ts b/src/libs/agent-runtime/openrouter/index.ts index 457fb422072ea..a5609fbf92c02 100644 --- a/src/libs/agent-runtime/openrouter/index.ts +++ b/src/libs/agent-runtime/openrouter/index.ts @@ -6,6 +6,14 @@ import { OpenRouterModelCard } from './type'; export const LobeOpenRouterAI = LobeOpenAICompatibleFactory({ baseURL: 'https://openrouter.ai/api/v1', + chatCompletion: { + handlePayload: (payload) => { + return { + ...payload, + include_reasoning: true, + } as any; + }, + }, constructorOptions: { defaultHeaders: { 'HTTP-Referer': 'https://chat-preview.lobehub.com', @@ -17,10 +25,7 @@ export const LobeOpenRouterAI = LobeOpenAICompatibleFactory({ }, models: { transformModel: (m) => { - const visionKeywords = [ - 'qwen/qvq', - 'vision', - ]; + const visionKeywords = ['qwen/qvq', 'vision']; const reasoningKeywords = [ 'deepseek/deepseek-r1', @@ -28,7 +33,7 @@ export const LobeOpenRouterAI = LobeOpenAICompatibleFactory({ 'openai/o3', 'qwen/qvq', 'qwen/qwq', - 'thinking' + 'thinking', ]; const model = m as unknown as OpenRouterModelCard; @@ -45,11 +50,11 @@ export const LobeOpenRouterAI = LobeOpenAICompatibleFactory({ typeof model.top_provider.max_completion_tokens === 'number' ? model.top_provider.max_completion_tokens : undefined, - reasoning: reasoningKeywords.some(keyword => model.id.toLowerCase().includes(keyword)), + reasoning: reasoningKeywords.some((keyword) => model.id.toLowerCase().includes(keyword)), vision: model.description.includes('vision') || model.description.includes('multimodal') || - visionKeywords.some(keyword => model.id.toLowerCase().includes(keyword)), + visionKeywords.some((keyword) => model.id.toLowerCase().includes(keyword)), }; }, }, From 6c0a1a3003e0c3e409943f674b3e75441034c06d Mon Sep 17 00:00:00 2001 From: "TechnoHouse (deephbz)" <13776377+deephbz@users.noreply.github.com> Date: Sun, 9 Feb 2025 11:22:56 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E2=9C=A8=20feat:=20Support=20display=20Ope?= =?UTF-8?q?nRouter's=20"reasoning"=20output=20by=20transforming=20its=20"r?= =?UTF-8?q?easoning"=20to=20be=20consistent=20with=20most=20other=20platfo?= =?UTF-8?q?rms:=20reasoning=20outputs=20wrapped=20by=20=20XML=20tag?= =?UTF-8?q?.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent-runtime/openrouter/index.test.ts | 4 +- src/libs/agent-runtime/openrouter/index.ts | 3 + src/libs/agent-runtime/utils/streams/index.ts | 1 + .../utils/streams/openrouter.test.ts | 359 ++++++++++++++++++ .../agent-runtime/utils/streams/openrouter.ts | 82 ++++ 5 files changed, 447 insertions(+), 2 deletions(-) create mode 100644 src/libs/agent-runtime/utils/streams/openrouter.test.ts create mode 100644 src/libs/agent-runtime/utils/streams/openrouter.ts diff --git a/src/libs/agent-runtime/openrouter/index.test.ts b/src/libs/agent-runtime/openrouter/index.test.ts index 4ed3ef03b2924..d837c95b048cb 100644 --- a/src/libs/agent-runtime/openrouter/index.test.ts +++ b/src/libs/agent-runtime/openrouter/index.test.ts @@ -79,14 +79,14 @@ describe('LobeOpenRouterAI', () => { // Assert expect(instance['client'].chat.completions.create).toHaveBeenCalledWith( - { + expect.objectContaining({ max_tokens: 1024, messages: [{ content: 'Hello', role: 'user' }], stream: true, model: 'mistralai/mistral-7b-instruct:free', temperature: 0.7, top_p: 1, - }, + }), { headers: { Accept: '*/*' } }, ); expect(result).toBeInstanceOf(Response); diff --git a/src/libs/agent-runtime/openrouter/index.ts b/src/libs/agent-runtime/openrouter/index.ts index a5609fbf92c02..3e9b051336a0e 100644 --- a/src/libs/agent-runtime/openrouter/index.ts +++ b/src/libs/agent-runtime/openrouter/index.ts @@ -2,6 +2,7 @@ import { LOBE_DEFAULT_MODEL_LIST } from '@/config/modelProviders'; import { ModelProvider } from '../types'; import { LobeOpenAICompatibleFactory } from '../utils/openaiCompatibleFactory'; +import { OpenRouterReasoningStream } from '../utils/streams'; import { OpenRouterModelCard } from './type'; export const LobeOpenRouterAI = LobeOpenAICompatibleFactory({ @@ -11,8 +12,10 @@ export const LobeOpenRouterAI = LobeOpenAICompatibleFactory({ return { ...payload, include_reasoning: true, + stream: payload.stream ?? true, } as any; }, + handleStream: OpenRouterReasoningStream, }, constructorOptions: { defaultHeaders: { diff --git a/src/libs/agent-runtime/utils/streams/index.ts b/src/libs/agent-runtime/utils/streams/index.ts index 65ea36449bd06..ec7f404f7a79a 100644 --- a/src/libs/agent-runtime/utils/streams/index.ts +++ b/src/libs/agent-runtime/utils/streams/index.ts @@ -4,6 +4,7 @@ export * from './bedrock'; export * from './google-ai'; export * from './ollama'; export * from './openai'; +export * from './openrouter'; export * from './protocol'; export * from './qwen'; export * from './spark'; diff --git a/src/libs/agent-runtime/utils/streams/openrouter.test.ts b/src/libs/agent-runtime/utils/streams/openrouter.test.ts new file mode 100644 index 0000000000000..9deb135f0d9fe --- /dev/null +++ b/src/libs/agent-runtime/utils/streams/openrouter.test.ts @@ -0,0 +1,359 @@ +import { beforeAll, describe, expect, it, vi } from 'vitest'; + +import { OpenRouterReasoningStream } from './openrouter'; + +describe('OpenRouterReasoningStream', () => { + beforeAll(() => { + // No-op + }); + + it('should wrap first reasoning chunk with and subsequent reasoning is appended', async () => { + // Suppose we get two reasoning-only chunks, then content + const mockOpenAIStream = new ReadableStream({ + start(controller) { + // Reasoning chunk #1 + controller.enqueue({ + choices: [ + { + delta: { + role: 'assistant', + reasoning: 'Hello', + }, + index: 0, + }, + ], + id: 'test-1', + }); + // Reasoning chunk #2 + controller.enqueue({ + choices: [ + { + delta: { + role: 'assistant', + reasoning: ', world!', + }, + index: 1, + }, + ], + id: 'test-1', + }); + // Content chunk => triggers closing + controller.enqueue({ + choices: [ + { + delta: { + role: 'assistant', + content: 'Now real content', + }, + index: 2, + }, + ], + id: 'test-1', + }); + // Finally a stop chunk + controller.enqueue({ + choices: [ + { + delta: null, + finish_reason: 'stop', + index: 3, + }, + ], + id: 'test-1', + }); + + controller.close(); + }, + }); + + // Mock any callbacks you want to track + const onStartMock = vi.fn(); + const onTextMock = vi.fn(); + const onTokenMock = vi.fn(); + const onCompletionMock = vi.fn(); + + const protocolStream = OpenRouterReasoningStream(mockOpenAIStream, { + onStart: onStartMock, + onText: onTextMock, + onToken: onTokenMock, + onCompletion: onCompletionMock, + }); + + const decoder = new TextDecoder(); + const chunks: string[] = []; + + // @ts-ignore - for-await usage on a ReadableStream in Node + for await (const chunk of protocolStream) { + chunks.push(decoder.decode(chunk, { stream: true })); + } + + expect(chunks).toEqual([ + // chunk #1: SSE lines + 'id: test-1\n', + 'event: text\n', + 'data: "Hello"\n\n', + + // chunk #2: SSE lines + 'id: test-1\n', + 'event: text\n', + 'data: ", world!"\n\n', + + // chunk #3: SSE lines => content => closes out + 'id: test-1\n', + 'event: text\n', + 'data: "Now real content"\n\n', + + // chunk #4: SSE lines => stop + 'id: test-1\n', + 'event: stop\n', + 'data: "stop"\n\n', + ]); + + // Verify callbacks were triggered + expect(onStartMock).toHaveBeenCalledTimes(1); // Called once at stream start + expect(onTextMock).toHaveBeenCalledTimes(3); // We got 3 text events + expect(onTokenMock).toHaveBeenCalledTimes(3); // Each text is broken down into tokens if you want + expect(onCompletionMock).toHaveBeenCalledTimes(1); // stop event => completion + }); + + it('should output content immediately if reasoning is empty or absent', async () => { + // This simulates a chunk that only has content from the start + const mockStream = new ReadableStream({ + start(controller) { + controller.enqueue({ + choices: [ + { + delta: { + role: 'assistant', + content: 'No reasoning, just content.', + }, + }, + ], + id: 'test-2', + }); + controller.enqueue({ + choices: [ + { + delta: null, + finish_reason: 'stop', + }, + ], + id: 'test-2', + }); + + controller.close(); + }, + }); + + const protocolStream = OpenRouterReasoningStream(mockStream); + const decoder = new TextDecoder(); + const chunks: string[] = []; + + // @ts-ignore + for await (const chunk of protocolStream) { + chunks.push(decoder.decode(chunk, { stream: true })); + } + + // Notice there is no or at all + expect(chunks).toEqual([ + 'id: test-2\n', + 'event: text\n', + 'data: "No reasoning, just content."\n\n', + 'id: test-2\n', + 'event: stop\n', + 'data: "stop"\n\n', + ]); + }); + + it('should handle empty stream with no chunks', async () => { + const mockStream = new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + + const protocolStream = OpenRouterReasoningStream(mockStream); + + const decoder = new TextDecoder(); + const chunks: string[] = []; + + // @ts-ignore + for await (const chunk of protocolStream) { + chunks.push(decoder.decode(chunk, { stream: true })); + } + + expect(chunks).toEqual([]); + }); + + it('should handle chunk with no choices', async () => { + const mockStream = new ReadableStream({ + start(controller) { + controller.enqueue({ + choices: [], + id: 'test-3', + }); + controller.close(); + }, + }); + + const protocolStream = OpenRouterReasoningStream(mockStream); + + const decoder = new TextDecoder(); + const chunks: string[] = []; + + // @ts-ignore + for await (const chunk of protocolStream) { + chunks.push(decoder.decode(chunk, { stream: true })); + } + + // This means the transform function sees no choice => fallback is "data" event + expect(chunks).toEqual([ + 'id: test-3\n', + 'event: data\n', + 'data: {"choices":[],"id":"test-3"}\n\n', + ]); + }); + + it('should handle consecutive reasoning-only chunks with no content', async () => { + const mockStream = new ReadableStream({ + start(controller) { + controller.enqueue({ + choices: [ + { + delta: { + role: 'assistant', + reasoning: 'Just reasoning first chunk', + }, + }, + ], + id: 'test-4', + }); + controller.enqueue({ + choices: [ + { + delta: { + role: 'assistant', + reasoning: 'Continuing reasoning second chunk', + }, + }, + ], + id: 'test-4', + }); + // finish + controller.enqueue({ + choices: [ + { + delta: null, + finish_reason: 'stop', + }, + ], + id: 'test-4', + }); + controller.close(); + }, + }); + + const protocolStream = OpenRouterReasoningStream(mockStream); + const decoder = new TextDecoder(); + const chunks: string[] = []; + + // @ts-ignore + for await (const chunk of protocolStream) { + chunks.push(decoder.decode(chunk, { stream: true })); + } + + // No content arrived => never closed the tag + expect(chunks).toEqual([ + 'id: test-4\n', + 'event: text\n', + 'data: "Just reasoning first chunk"\n\n', + + 'id: test-4\n', + 'event: text\n', + 'data: "Continuing reasoning second chunk"\n\n', + + 'id: test-4\n', + 'event: stop\n', + 'data: "stop"\n\n', + ]); + }); + + it('should handle reasonings, then partial content, then more content', async () => { + const mockStream = new ReadableStream({ + start(controller) { + // Reasoning chunk + controller.enqueue({ + choices: [ + { + delta: { + role: 'assistant', + reasoning: 'I am thinking step #1...', + }, + }, + ], + id: 'test-5', + }); + // Content chunk #1 => closes out + controller.enqueue({ + choices: [ + { + delta: { + role: 'assistant', + content: 'Partial answer. ', + }, + }, + ], + id: 'test-5', + }); + // Content chunk #2 => appended, no new + controller.enqueue({ + choices: [ + { + delta: { + role: 'assistant', + content: 'Second part of answer.', + }, + }, + ], + id: 'test-5', + }); + // Stop chunk + controller.enqueue({ + choices: [ + { + delta: null, + finish_reason: 'stop', + }, + ], + id: 'test-5', + }); + controller.close(); + }, + }); + + const protocolStream = OpenRouterReasoningStream(mockStream); + const decoder = new TextDecoder(); + const chunks: string[] = []; + + // @ts-ignore + for await (const chunk of protocolStream) { + chunks.push(decoder.decode(chunk, { stream: true })); + } + + // Notice how only the first content chunk triggers "" + expect(chunks).toEqual([ + 'id: test-5\n', + 'event: text\n', + 'data: "I am thinking step #1..."\n\n', + 'id: test-5\n', + 'event: text\n', + 'data: "Partial answer. "\n\n', + 'id: test-5\n', + 'event: text\n', + 'data: "Second part of answer."\n\n', + 'id: test-5\n', + 'event: stop\n', + 'data: "stop"\n\n', + ]); + }); +}); diff --git a/src/libs/agent-runtime/utils/streams/openrouter.ts b/src/libs/agent-runtime/utils/streams/openrouter.ts new file mode 100644 index 0000000000000..777d479fde8e3 --- /dev/null +++ b/src/libs/agent-runtime/utils/streams/openrouter.ts @@ -0,0 +1,82 @@ +import OpenAI from 'openai'; +import type { Stream } from 'openai/streaming'; + +import { ChatStreamCallbacks } from '../../types'; +import { + StreamProtocolChunk, + convertIterableToStream, + createCallbacksTransformer, + createSSEProtocolTransformer, +} from './protocol'; + +/** + * Create a closure to track whether we’ve inserted `` and/or closed it. + */ +function createOpenRouterReasoningTransformer() { + let reasoningStarted = false; + let contentStarted = false; + let insertedThink = false; + + return function transformOpenRouterChunk(chunk: OpenAI.ChatCompletionChunk): StreamProtocolChunk { + const item = chunk.choices?.[0]; + if (!item) { + return { data: chunk, id: chunk.id, type: 'data' }; + } + + // 1) If we have a finish_reason, treat it as stop. + if (item.finish_reason) { + return { data: item.finish_reason, id: chunk.id, type: 'stop' }; + } + + // 2) Then handle any delta + const { content, reasoning } = (item.delta as { content?: string; reasoning?: string }) || {}; + + const isContentNonEmpty = typeof content === 'string' && content.length > 0; + const isReasoningNonEmpty = typeof reasoning === 'string' && reasoning.length > 0; + + // 3) Reasoning logic + if (!contentStarted && isReasoningNonEmpty) { + if (!reasoningStarted) reasoningStarted = true; + if (!insertedThink) { + insertedThink = true; + return { data: `${reasoning}`, id: chunk.id, type: 'text' }; + } + return { data: reasoning, id: chunk.id, type: 'text' }; + } + + // 4) Content logic + if (isContentNonEmpty) { + if (!contentStarted) { + contentStarted = true; + if (reasoningStarted && insertedThink) { + return { data: `${content}`, id: chunk.id, type: 'text' }; + } + } + return { data: content, id: chunk.id, type: 'text' }; + } + + // 5) Fallback + return { data: chunk, id: chunk.id, type: 'data' }; + }; +} + +/** + * The main stream entry point for OpenRouter, similar to Qwen’s “QwenAIStream.” + */ +export function OpenRouterReasoningStream( + stream: Stream | ReadableStream, + callbacks?: ChatStreamCallbacks, +) { + // Convert the stream if it’s an AsyncIterable + const readableStream = + stream instanceof ReadableStream ? stream : convertIterableToStream(stream); + + // Create our chunk-by-chunk transformer + const transformFn = createOpenRouterReasoningTransformer(); + + // 1. Transform each chunk to a standard SSE protocol event + // 2. Pipe it through the user’s callback hooks + return readableStream + .pipeThrough(createSSEProtocolTransformer(transformFn)) + .pipeThrough(createCallbacksTransformer(callbacks)); +} From 109dac810da41322c247e4c2f71e970b265a51ce Mon Sep 17 00:00:00 2001 From: "TechnoHouse (deephbz)" <13776377+deephbz@users.noreply.github.com> Date: Wed, 12 Feb 2025 10:02:38 +0800 Subject: [PATCH 3/5] Revert openrouter stream transformer: Capture reasoning content inside openai stream processor --- src/libs/agent-runtime/openrouter/index.ts | 2 - src/libs/agent-runtime/utils/streams/index.ts | 1 - .../agent-runtime/utils/streams/openai.ts | 8 +- .../utils/streams/openrouter.test.ts | 359 ------------------ .../agent-runtime/utils/streams/openrouter.ts | 82 ---- 5 files changed, 6 insertions(+), 446 deletions(-) delete mode 100644 src/libs/agent-runtime/utils/streams/openrouter.test.ts delete mode 100644 src/libs/agent-runtime/utils/streams/openrouter.ts diff --git a/src/libs/agent-runtime/openrouter/index.ts b/src/libs/agent-runtime/openrouter/index.ts index 3e9b051336a0e..2767ca16c0306 100644 --- a/src/libs/agent-runtime/openrouter/index.ts +++ b/src/libs/agent-runtime/openrouter/index.ts @@ -2,7 +2,6 @@ import { LOBE_DEFAULT_MODEL_LIST } from '@/config/modelProviders'; import { ModelProvider } from '../types'; import { LobeOpenAICompatibleFactory } from '../utils/openaiCompatibleFactory'; -import { OpenRouterReasoningStream } from '../utils/streams'; import { OpenRouterModelCard } from './type'; export const LobeOpenRouterAI = LobeOpenAICompatibleFactory({ @@ -15,7 +14,6 @@ export const LobeOpenRouterAI = LobeOpenAICompatibleFactory({ stream: payload.stream ?? true, } as any; }, - handleStream: OpenRouterReasoningStream, }, constructorOptions: { defaultHeaders: { diff --git a/src/libs/agent-runtime/utils/streams/index.ts b/src/libs/agent-runtime/utils/streams/index.ts index ec7f404f7a79a..65ea36449bd06 100644 --- a/src/libs/agent-runtime/utils/streams/index.ts +++ b/src/libs/agent-runtime/utils/streams/index.ts @@ -4,7 +4,6 @@ export * from './bedrock'; export * from './google-ai'; export * from './ollama'; export * from './openai'; -export * from './openrouter'; export * from './protocol'; export * from './qwen'; export * from './spark'; diff --git a/src/libs/agent-runtime/utils/streams/openai.ts b/src/libs/agent-runtime/utils/streams/openai.ts index 1a1124875e4b1..f9f354ee16d8f 100644 --- a/src/libs/agent-runtime/utils/streams/openai.ts +++ b/src/libs/agent-runtime/utils/streams/openai.ts @@ -88,8 +88,12 @@ export const transformOpenAIStream = ( } if (item.delta) { - let reasoning_content = - 'reasoning_content' in item.delta ? item.delta.reasoning_content : null; + let reasoning_content = (() => { + if ('reasoning_content' in item.delta) return item.delta.reasoning_content; + if ('reasoning' in item.delta) return item.delta.reasoning; + return null; + })(); + let content = 'content' in item.delta ? item.delta.content : null; // DeepSeek reasoner will put thinking in the reasoning_content field diff --git a/src/libs/agent-runtime/utils/streams/openrouter.test.ts b/src/libs/agent-runtime/utils/streams/openrouter.test.ts deleted file mode 100644 index 9deb135f0d9fe..0000000000000 --- a/src/libs/agent-runtime/utils/streams/openrouter.test.ts +++ /dev/null @@ -1,359 +0,0 @@ -import { beforeAll, describe, expect, it, vi } from 'vitest'; - -import { OpenRouterReasoningStream } from './openrouter'; - -describe('OpenRouterReasoningStream', () => { - beforeAll(() => { - // No-op - }); - - it('should wrap first reasoning chunk with and subsequent reasoning is appended', async () => { - // Suppose we get two reasoning-only chunks, then content - const mockOpenAIStream = new ReadableStream({ - start(controller) { - // Reasoning chunk #1 - controller.enqueue({ - choices: [ - { - delta: { - role: 'assistant', - reasoning: 'Hello', - }, - index: 0, - }, - ], - id: 'test-1', - }); - // Reasoning chunk #2 - controller.enqueue({ - choices: [ - { - delta: { - role: 'assistant', - reasoning: ', world!', - }, - index: 1, - }, - ], - id: 'test-1', - }); - // Content chunk => triggers closing - controller.enqueue({ - choices: [ - { - delta: { - role: 'assistant', - content: 'Now real content', - }, - index: 2, - }, - ], - id: 'test-1', - }); - // Finally a stop chunk - controller.enqueue({ - choices: [ - { - delta: null, - finish_reason: 'stop', - index: 3, - }, - ], - id: 'test-1', - }); - - controller.close(); - }, - }); - - // Mock any callbacks you want to track - const onStartMock = vi.fn(); - const onTextMock = vi.fn(); - const onTokenMock = vi.fn(); - const onCompletionMock = vi.fn(); - - const protocolStream = OpenRouterReasoningStream(mockOpenAIStream, { - onStart: onStartMock, - onText: onTextMock, - onToken: onTokenMock, - onCompletion: onCompletionMock, - }); - - const decoder = new TextDecoder(); - const chunks: string[] = []; - - // @ts-ignore - for-await usage on a ReadableStream in Node - for await (const chunk of protocolStream) { - chunks.push(decoder.decode(chunk, { stream: true })); - } - - expect(chunks).toEqual([ - // chunk #1: SSE lines - 'id: test-1\n', - 'event: text\n', - 'data: "Hello"\n\n', - - // chunk #2: SSE lines - 'id: test-1\n', - 'event: text\n', - 'data: ", world!"\n\n', - - // chunk #3: SSE lines => content => closes out - 'id: test-1\n', - 'event: text\n', - 'data: "Now real content"\n\n', - - // chunk #4: SSE lines => stop - 'id: test-1\n', - 'event: stop\n', - 'data: "stop"\n\n', - ]); - - // Verify callbacks were triggered - expect(onStartMock).toHaveBeenCalledTimes(1); // Called once at stream start - expect(onTextMock).toHaveBeenCalledTimes(3); // We got 3 text events - expect(onTokenMock).toHaveBeenCalledTimes(3); // Each text is broken down into tokens if you want - expect(onCompletionMock).toHaveBeenCalledTimes(1); // stop event => completion - }); - - it('should output content immediately if reasoning is empty or absent', async () => { - // This simulates a chunk that only has content from the start - const mockStream = new ReadableStream({ - start(controller) { - controller.enqueue({ - choices: [ - { - delta: { - role: 'assistant', - content: 'No reasoning, just content.', - }, - }, - ], - id: 'test-2', - }); - controller.enqueue({ - choices: [ - { - delta: null, - finish_reason: 'stop', - }, - ], - id: 'test-2', - }); - - controller.close(); - }, - }); - - const protocolStream = OpenRouterReasoningStream(mockStream); - const decoder = new TextDecoder(); - const chunks: string[] = []; - - // @ts-ignore - for await (const chunk of protocolStream) { - chunks.push(decoder.decode(chunk, { stream: true })); - } - - // Notice there is no or at all - expect(chunks).toEqual([ - 'id: test-2\n', - 'event: text\n', - 'data: "No reasoning, just content."\n\n', - 'id: test-2\n', - 'event: stop\n', - 'data: "stop"\n\n', - ]); - }); - - it('should handle empty stream with no chunks', async () => { - const mockStream = new ReadableStream({ - start(controller) { - controller.close(); - }, - }); - - const protocolStream = OpenRouterReasoningStream(mockStream); - - const decoder = new TextDecoder(); - const chunks: string[] = []; - - // @ts-ignore - for await (const chunk of protocolStream) { - chunks.push(decoder.decode(chunk, { stream: true })); - } - - expect(chunks).toEqual([]); - }); - - it('should handle chunk with no choices', async () => { - const mockStream = new ReadableStream({ - start(controller) { - controller.enqueue({ - choices: [], - id: 'test-3', - }); - controller.close(); - }, - }); - - const protocolStream = OpenRouterReasoningStream(mockStream); - - const decoder = new TextDecoder(); - const chunks: string[] = []; - - // @ts-ignore - for await (const chunk of protocolStream) { - chunks.push(decoder.decode(chunk, { stream: true })); - } - - // This means the transform function sees no choice => fallback is "data" event - expect(chunks).toEqual([ - 'id: test-3\n', - 'event: data\n', - 'data: {"choices":[],"id":"test-3"}\n\n', - ]); - }); - - it('should handle consecutive reasoning-only chunks with no content', async () => { - const mockStream = new ReadableStream({ - start(controller) { - controller.enqueue({ - choices: [ - { - delta: { - role: 'assistant', - reasoning: 'Just reasoning first chunk', - }, - }, - ], - id: 'test-4', - }); - controller.enqueue({ - choices: [ - { - delta: { - role: 'assistant', - reasoning: 'Continuing reasoning second chunk', - }, - }, - ], - id: 'test-4', - }); - // finish - controller.enqueue({ - choices: [ - { - delta: null, - finish_reason: 'stop', - }, - ], - id: 'test-4', - }); - controller.close(); - }, - }); - - const protocolStream = OpenRouterReasoningStream(mockStream); - const decoder = new TextDecoder(); - const chunks: string[] = []; - - // @ts-ignore - for await (const chunk of protocolStream) { - chunks.push(decoder.decode(chunk, { stream: true })); - } - - // No content arrived => never closed the tag - expect(chunks).toEqual([ - 'id: test-4\n', - 'event: text\n', - 'data: "Just reasoning first chunk"\n\n', - - 'id: test-4\n', - 'event: text\n', - 'data: "Continuing reasoning second chunk"\n\n', - - 'id: test-4\n', - 'event: stop\n', - 'data: "stop"\n\n', - ]); - }); - - it('should handle reasonings, then partial content, then more content', async () => { - const mockStream = new ReadableStream({ - start(controller) { - // Reasoning chunk - controller.enqueue({ - choices: [ - { - delta: { - role: 'assistant', - reasoning: 'I am thinking step #1...', - }, - }, - ], - id: 'test-5', - }); - // Content chunk #1 => closes out - controller.enqueue({ - choices: [ - { - delta: { - role: 'assistant', - content: 'Partial answer. ', - }, - }, - ], - id: 'test-5', - }); - // Content chunk #2 => appended, no new - controller.enqueue({ - choices: [ - { - delta: { - role: 'assistant', - content: 'Second part of answer.', - }, - }, - ], - id: 'test-5', - }); - // Stop chunk - controller.enqueue({ - choices: [ - { - delta: null, - finish_reason: 'stop', - }, - ], - id: 'test-5', - }); - controller.close(); - }, - }); - - const protocolStream = OpenRouterReasoningStream(mockStream); - const decoder = new TextDecoder(); - const chunks: string[] = []; - - // @ts-ignore - for await (const chunk of protocolStream) { - chunks.push(decoder.decode(chunk, { stream: true })); - } - - // Notice how only the first content chunk triggers "" - expect(chunks).toEqual([ - 'id: test-5\n', - 'event: text\n', - 'data: "I am thinking step #1..."\n\n', - 'id: test-5\n', - 'event: text\n', - 'data: "Partial answer. "\n\n', - 'id: test-5\n', - 'event: text\n', - 'data: "Second part of answer."\n\n', - 'id: test-5\n', - 'event: stop\n', - 'data: "stop"\n\n', - ]); - }); -}); diff --git a/src/libs/agent-runtime/utils/streams/openrouter.ts b/src/libs/agent-runtime/utils/streams/openrouter.ts deleted file mode 100644 index 777d479fde8e3..0000000000000 --- a/src/libs/agent-runtime/utils/streams/openrouter.ts +++ /dev/null @@ -1,82 +0,0 @@ -import OpenAI from 'openai'; -import type { Stream } from 'openai/streaming'; - -import { ChatStreamCallbacks } from '../../types'; -import { - StreamProtocolChunk, - convertIterableToStream, - createCallbacksTransformer, - createSSEProtocolTransformer, -} from './protocol'; - -/** - * Create a closure to track whether we’ve inserted `` and/or closed it. - */ -function createOpenRouterReasoningTransformer() { - let reasoningStarted = false; - let contentStarted = false; - let insertedThink = false; - - return function transformOpenRouterChunk(chunk: OpenAI.ChatCompletionChunk): StreamProtocolChunk { - const item = chunk.choices?.[0]; - if (!item) { - return { data: chunk, id: chunk.id, type: 'data' }; - } - - // 1) If we have a finish_reason, treat it as stop. - if (item.finish_reason) { - return { data: item.finish_reason, id: chunk.id, type: 'stop' }; - } - - // 2) Then handle any delta - const { content, reasoning } = (item.delta as { content?: string; reasoning?: string }) || {}; - - const isContentNonEmpty = typeof content === 'string' && content.length > 0; - const isReasoningNonEmpty = typeof reasoning === 'string' && reasoning.length > 0; - - // 3) Reasoning logic - if (!contentStarted && isReasoningNonEmpty) { - if (!reasoningStarted) reasoningStarted = true; - if (!insertedThink) { - insertedThink = true; - return { data: `${reasoning}`, id: chunk.id, type: 'text' }; - } - return { data: reasoning, id: chunk.id, type: 'text' }; - } - - // 4) Content logic - if (isContentNonEmpty) { - if (!contentStarted) { - contentStarted = true; - if (reasoningStarted && insertedThink) { - return { data: `${content}`, id: chunk.id, type: 'text' }; - } - } - return { data: content, id: chunk.id, type: 'text' }; - } - - // 5) Fallback - return { data: chunk, id: chunk.id, type: 'data' }; - }; -} - -/** - * The main stream entry point for OpenRouter, similar to Qwen’s “QwenAIStream.” - */ -export function OpenRouterReasoningStream( - stream: Stream | ReadableStream, - callbacks?: ChatStreamCallbacks, -) { - // Convert the stream if it’s an AsyncIterable - const readableStream = - stream instanceof ReadableStream ? stream : convertIterableToStream(stream); - - // Create our chunk-by-chunk transformer - const transformFn = createOpenRouterReasoningTransformer(); - - // 1. Transform each chunk to a standard SSE protocol event - // 2. Pipe it through the user’s callback hooks - return readableStream - .pipeThrough(createSSEProtocolTransformer(transformFn)) - .pipeThrough(createCallbacksTransformer(callbacks)); -} From 88e822c731bdfa9662d4a773db48487ec3c893ca Mon Sep 17 00:00:00 2001 From: "TechnoHouse (deephbz)" <13776377+deephbz@users.noreply.github.com> Date: Wed, 12 Feb 2025 10:03:07 +0800 Subject: [PATCH 4/5] Address PR comments: r1 does not support functional calling --- src/config/modelProviders/openrouter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/modelProviders/openrouter.ts b/src/config/modelProviders/openrouter.ts index 508b03664d6fc..4c1173b08ce5b 100644 --- a/src/config/modelProviders/openrouter.ts +++ b/src/config/modelProviders/openrouter.ts @@ -219,7 +219,7 @@ const OpenRouter: ModelProviderCard = { description: 'DeepSeek-R1', displayName: 'DeepSeek R1', enabled: true, - functionCall: true, + functionCall: false, id: 'deepseek/deepseek-r1', pricing: { input: 3, From d014d346d459b5db1628ec20b83285e1e9da5090 Mon Sep 17 00:00:00 2001 From: "TechnoHouse (deephbz)" <13776377+deephbz@users.noreply.github.com> Date: Wed, 12 Feb 2025 10:13:42 +0800 Subject: [PATCH 5/5] add unittest for openrouter reasoning stream handling --- .../utils/streams/openai.test.ts | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) diff --git a/src/libs/agent-runtime/utils/streams/openai.test.ts b/src/libs/agent-runtime/utils/streams/openai.test.ts index 14629cfe601bf..8d3c2f77aea32 100644 --- a/src/libs/agent-runtime/utils/streams/openai.test.ts +++ b/src/libs/agent-runtime/utils/streams/openai.test.ts @@ -1375,5 +1375,206 @@ describe('OpenAIStream', () => { ].map((i) => `${i}\n`), ); }); + + it('should handle reasoning key from OpenRouter response', async () => { + const data = [ + { + id: '1', + object: 'chat.completion.chunk', + created: 1737563070, + model: 'deepseek-reasoner', + system_fingerprint: 'fp_1c5d8833bc', + choices: [ + { + index: 0, + delta: { role: 'assistant', reasoning: '' }, + logprobs: null, + finish_reason: null, + }, + ], + }, + { + id: '1', + object: 'chat.completion.chunk', + created: 1737563070, + model: 'deepseek-reasoner', + system_fingerprint: 'fp_1c5d8833bc', + choices: [ + { + index: 0, + delta: { reasoning: '您好' }, + logprobs: null, + finish_reason: null, + }, + ], + }, + { + id: '1', + object: 'chat.completion.chunk', + created: 1737563070, + model: 'deepseek-reasoner', + system_fingerprint: 'fp_1c5d8833bc', + choices: [ + { + index: 0, + delta: { reasoning: '!' }, + logprobs: null, + finish_reason: null, + }, + ], + }, + { + id: '1', + object: 'chat.completion.chunk', + created: 1737563070, + model: 'deepseek-reasoner', + system_fingerprint: 'fp_1c5d8833bc', + choices: [ + { + index: 0, + delta: { content: '你好', reasoning: null }, + logprobs: null, + finish_reason: null, + }, + ], + }, + { + id: '1', + object: 'chat.completion.chunk', + created: 1737563070, + model: 'deepseek-reasoner', + system_fingerprint: 'fp_1c5d8833bc', + choices: [ + { + index: 0, + delta: { content: '很高兴', reasoning: null }, + logprobs: null, + finish_reason: null, + }, + ], + }, + { + id: '1', + object: 'chat.completion.chunk', + created: 1737563070, + model: 'deepseek-reasoner', + system_fingerprint: 'fp_1c5d8833bc', + choices: [ + { + index: 0, + delta: { content: '为您', reasoning: null }, + logprobs: null, + finish_reason: null, + }, + ], + }, + { + id: '1', + object: 'chat.completion.chunk', + created: 1737563070, + model: 'deepseek-reasoner', + system_fingerprint: 'fp_1c5d8833bc', + choices: [ + { + index: 0, + delta: { content: '提供', reasoning: null }, + logprobs: null, + finish_reason: null, + }, + ], + }, + { + id: '1', + object: 'chat.completion.chunk', + created: 1737563070, + model: 'deepseek-reasoner', + system_fingerprint: 'fp_1c5d8833bc', + choices: [ + { + index: 0, + delta: { content: '帮助。', reasoning: null }, + logprobs: null, + finish_reason: null, + }, + ], + }, + { + id: '1', + object: 'chat.completion.chunk', + created: 1737563070, + model: 'deepseek-reasoner', + system_fingerprint: 'fp_1c5d8833bc', + choices: [ + { + index: 0, + delta: { content: '', reasoning: null }, + logprobs: null, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 6, + completion_tokens: 104, + total_tokens: 110, + prompt_tokens_details: { cached_tokens: 0 }, + completion_tokens_details: { reasoning_tokens: 70 }, + prompt_cache_hit_tokens: 0, + prompt_cache_miss_tokens: 6, + }, + }, + ]; + + const mockOpenAIStream = new ReadableStream({ + start(controller) { + data.forEach((chunk) => { + controller.enqueue(chunk); + }); + + controller.close(); + }, + }); + + const protocolStream = OpenAIStream(mockOpenAIStream); + + const decoder = new TextDecoder(); + const chunks = []; + + // @ts-ignore + for await (const chunk of protocolStream) { + chunks.push(decoder.decode(chunk, { stream: true })); + } + + expect(chunks).toEqual( + [ + 'id: 1', + 'event: reasoning', + `data: ""\n`, + 'id: 1', + 'event: reasoning', + `data: "您好"\n`, + 'id: 1', + 'event: reasoning', + `data: "!"\n`, + 'id: 1', + 'event: text', + `data: "你好"\n`, + 'id: 1', + 'event: text', + `data: "很高兴"\n`, + 'id: 1', + 'event: text', + `data: "为您"\n`, + 'id: 1', + 'event: text', + `data: "提供"\n`, + 'id: 1', + 'event: text', + `data: "帮助。"\n`, + 'id: 1', + 'event: stop', + `data: "stop"\n`, + ].map((i) => `${i}\n`), + ); + }); }); });