-
-
Notifications
You must be signed in to change notification settings - Fork 11.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
✨ feat: Support display OpenRouter's "reasoning" output by transforming
its "reasoning" to be consistent with most other platforms: reasoning outputs wrapped by <think> XML tag.
- Loading branch information
Showing
4 changed files
with
129 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,121 @@ | ||
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 `<think>` and/or closed it. | ||
*/ | ||
function createOpenRouterReasoningTransformer() { | ||
let reasoningStarted = false; | ||
let contentStarted = false; | ||
let insertedThink = false; | ||
|
||
return function transformOpenRouterChunk(chunk: OpenAI.ChatCompletionChunk): StreamProtocolChunk { | ||
const choice = chunk.choices?.[0]; | ||
if (!choice || !choice.delta) { | ||
// No delta => just emit generic "data" | ||
return { | ||
data: chunk, | ||
id: chunk.id, | ||
type: 'data', | ||
}; | ||
} | ||
|
||
const { content, reasoning } = choice.delta as { | ||
content?: string | null; | ||
reasoning?: string | null; | ||
}; | ||
|
||
// Convert empty string, null, or undefined to a simple “nothing” check: | ||
const isContentNonEmpty = typeof content === 'string' && content.length > 0; | ||
const isReasoningNonEmpty = typeof reasoning === 'string' && reasoning.length > 0; | ||
|
||
// Prepare an output string that we will treat as the “transformed content” for this chunk | ||
let transformed = ''; | ||
|
||
if (!contentStarted && isReasoningNonEmpty) { | ||
// We are still in the “reasoning” phase | ||
if (!reasoningStarted) { | ||
reasoningStarted = true; | ||
} | ||
if (!insertedThink) { | ||
// First piece of reasoning => prepend <think> | ||
transformed = `<think>${reasoning}`; | ||
insertedThink = true; | ||
} else { | ||
// Subsequent reasoning => just append text | ||
transformed = reasoning; | ||
} | ||
|
||
return { | ||
data: transformed, | ||
id: chunk.id, | ||
type: 'text', // SSE “event: text” | ||
}; | ||
} else if (isContentNonEmpty) { | ||
// We now have actual content | ||
if (!contentStarted) { | ||
contentStarted = true; | ||
// If we had been doing reasoning, close it | ||
if (reasoningStarted && insertedThink) { | ||
transformed = `</think>${content}`; | ||
} else { | ||
transformed = content; | ||
} | ||
} else { | ||
// Already started content => just append new chunk | ||
transformed = content; | ||
} | ||
|
||
return { | ||
data: transformed, | ||
id: chunk.id, | ||
type: 'text', | ||
}; | ||
} | ||
|
||
// If this chunk indicates finishing | ||
if (choice.finish_reason) { | ||
return { | ||
data: choice.finish_reason, | ||
id: chunk.id, | ||
type: 'stop', | ||
}; | ||
} | ||
|
||
// Fallback: if we have no “content” or “reasoning,” or it’s empty | ||
return { | ||
data: choice.delta, | ||
id: chunk.id, | ||
type: 'data', | ||
}; | ||
}; | ||
} | ||
|
||
/** | ||
* The main stream entry point for OpenRouter, similar to Qwen’s “QwenAIStream.” | ||
*/ | ||
export function OpenRouterReasoningStream( | ||
stream: Stream<OpenAI.ChatCompletionChunk> | 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)); | ||
} |