diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts
index 83a5c7b36e..fec3aff267 100644
--- a/src/api/providers/native-ollama.ts
+++ b/src/api/providers/native-ollama.ts
@@ -6,6 +6,7 @@ import { BaseProvider } from "./base-provider"
import type { ApiHandlerOptions } from "../../shared/api"
import { getOllamaModels } from "./fetchers/ollama"
import { XmlMatcher } from "../../utils/xml-matcher"
+import { SquareBracketMatcher } from "../../utils/square-bracket-matcher"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
interface OllamaChatOptions {
@@ -173,20 +174,32 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio
const client = this.ensureClient()
const { id: modelId, info: modelInfo } = await this.fetchModel()
const useR1Format = modelId.toLowerCase().includes("deepseek-r1")
+ const useMagistralFormat = modelId.toLowerCase().includes("magistral")
const ollamaMessages: Message[] = [
{ role: "system", content: systemPrompt },
...convertToOllamaMessages(messages),
]
- const matcher = new XmlMatcher(
- "think",
- (chunk) =>
- ({
- type: chunk.matched ? "reasoning" : "text",
- text: chunk.data,
- }) as const,
- )
+ // Magistral models use square bracket syntax [THINK]...[/THINK] for reasoning blocks
+ // instead of the XML-style ... tags used by other models
+ const matcher = useMagistralFormat
+ ? new SquareBracketMatcher(
+ "THINK",
+ (chunk) =>
+ ({
+ type: chunk.matched ? "reasoning" : "text",
+ text: chunk.data,
+ }) as const,
+ )
+ : new XmlMatcher(
+ "think",
+ (chunk) =>
+ ({
+ type: chunk.matched ? "reasoning" : "text",
+ text: chunk.data,
+ }) as const,
+ )
try {
// Build options object conditionally
diff --git a/src/utils/__tests__/square-bracket-matcher.spec.ts b/src/utils/__tests__/square-bracket-matcher.spec.ts
new file mode 100644
index 0000000000..22b152454d
--- /dev/null
+++ b/src/utils/__tests__/square-bracket-matcher.spec.ts
@@ -0,0 +1,194 @@
+import { SquareBracketMatcher } from "../square-bracket-matcher"
+
+describe("SquareBracketMatcher", () => {
+ it("matches square bracket tags at position 0", () => {
+ const matcher = new SquareBracketMatcher("THINK")
+ const chunks = [...matcher.update("[THINK]data[/THINK]"), ...matcher.final()]
+ expect(chunks).toEqual([
+ {
+ data: "data",
+ matched: true,
+ },
+ ])
+ })
+
+ it("handles uppercase tag names", () => {
+ const matcher = new SquareBracketMatcher("THINK")
+ const chunks = [...matcher.update("[THINK]reasoning content[/THINK]"), ...matcher.final()]
+ expect(chunks).toEqual([
+ {
+ data: "reasoning content",
+ matched: true,
+ },
+ ])
+ })
+
+ it("handles lowercase tag names", () => {
+ const matcher = new SquareBracketMatcher("think")
+ const chunks = [...matcher.update("[think]reasoning content[/think]"), ...matcher.final()]
+ expect(chunks).toEqual([
+ {
+ data: "reasoning content",
+ matched: true,
+ },
+ ])
+ })
+
+ it("handles mixed content", () => {
+ const matcher = new SquareBracketMatcher("THINK")
+ const chunks = [...matcher.update("Before [THINK]reasoning[/THINK] After"), ...matcher.final()]
+ expect(chunks).toEqual([
+ {
+ data: "Before ",
+ matched: false,
+ },
+ {
+ data: "reasoning",
+ matched: true,
+ },
+ {
+ data: " After",
+ matched: false,
+ },
+ ])
+ })
+
+ it("handles streaming push", () => {
+ const matcher = new SquareBracketMatcher("THINK")
+ const chunks = [
+ ...matcher.update("["),
+ ...matcher.update("THINK"),
+ ...matcher.update("]"),
+ ...matcher.update("reasoning"),
+ ...matcher.update(" content"),
+ ...matcher.update("[/"),
+ ...matcher.update("THINK"),
+ ...matcher.update("]"),
+ ...matcher.final(),
+ ]
+ expect(chunks).toEqual([
+ {
+ data: "reasoning content",
+ matched: true,
+ },
+ ])
+ })
+
+ it("handles nested tags", () => {
+ const matcher = new SquareBracketMatcher("THINK")
+ const chunks = [...matcher.update("[THINK]X[THINK]Y[/THINK]Z[/THINK]"), ...matcher.final()]
+ expect(chunks).toEqual([
+ {
+ data: "X[THINK]Y[/THINK]Z",
+ matched: true,
+ },
+ ])
+ })
+
+ it("handles invalid tag format", () => {
+ const matcher = new SquareBracketMatcher("THINK")
+ const chunks = [...matcher.update("[INVALID]data[/INVALID]"), ...matcher.final()]
+ expect(chunks).toEqual([
+ {
+ data: "[INVALID]data[/INVALID]",
+ matched: false,
+ },
+ ])
+ })
+
+ it("handles unclosed tags", () => {
+ const matcher = new SquareBracketMatcher("THINK")
+ const chunks = [...matcher.update("[THINK]data"), ...matcher.final()]
+ expect(chunks).toEqual([
+ {
+ data: "data",
+ matched: true,
+ },
+ ])
+ })
+
+ it("handles wrong matching position", () => {
+ const matcher = new SquareBracketMatcher("THINK")
+ const chunks = [...matcher.update("prefix[THINK]data[/THINK]"), ...matcher.final()]
+ expect(chunks).toEqual([
+ {
+ data: "prefix",
+ matched: false,
+ },
+ {
+ data: "data",
+ matched: true,
+ },
+ ])
+ })
+
+ it("handles multiple sequential tags", () => {
+ const matcher = new SquareBracketMatcher("THINK")
+ const chunks = [...matcher.update("[THINK]first[/THINK] middle [THINK]second[/THINK]"), ...matcher.final()]
+ expect(chunks).toEqual([
+ {
+ data: "first",
+ matched: true,
+ },
+ {
+ data: " middle ",
+ matched: false,
+ },
+ {
+ data: "second",
+ matched: true,
+ },
+ ])
+ })
+
+ it("transforms output when transform function is provided", () => {
+ const matcher = new SquareBracketMatcher("THINK", (chunk) => ({
+ type: chunk.matched ? "reasoning" : "text",
+ text: chunk.data,
+ }))
+ const chunks = [...matcher.update("Before [THINK]reasoning[/THINK] After"), ...matcher.final()]
+ expect(chunks).toEqual([
+ {
+ type: "text",
+ text: "Before ",
+ },
+ {
+ type: "reasoning",
+ text: "reasoning",
+ },
+ {
+ type: "text",
+ text: " After",
+ },
+ ])
+ })
+
+ it("handles Magistral-style reasoning blocks", () => {
+ const matcher = new SquareBracketMatcher("THINK")
+ const input = `Let me analyze this problem.
+
+[THINK]
+I need to understand what the user is asking for.
+They want to fix an issue with Magistral model's reasoning tags.
+The tags use square brackets instead of angle brackets.
+[/THINK]
+
+Based on my analysis, here's the solution...`
+
+ const chunks = [...matcher.update(input), ...matcher.final()]
+ expect(chunks).toEqual([
+ {
+ data: "Let me analyze this problem.\n\n",
+ matched: false,
+ },
+ {
+ data: "\nI need to understand what the user is asking for.\nThey want to fix an issue with Magistral model's reasoning tags.\nThe tags use square brackets instead of angle brackets.\n",
+ matched: true,
+ },
+ {
+ data: "\n\nBased on my analysis, here's the solution...",
+ matched: false,
+ },
+ ])
+ })
+})
diff --git a/src/utils/square-bracket-matcher.ts b/src/utils/square-bracket-matcher.ts
new file mode 100644
index 0000000000..a945a80548
--- /dev/null
+++ b/src/utils/square-bracket-matcher.ts
@@ -0,0 +1,141 @@
+export interface SquareBracketMatcherResult {
+ matched: boolean
+ data: string
+}
+
+/**
+ * Matcher for square bracket tags like [THINK]...[/THINK]
+ * Used by models like Magistral that use square bracket syntax instead of angle brackets
+ */
+export class SquareBracketMatcher {
+ private buffer = ""
+ private insideTag = false
+ private tagDepth = 0
+ private results: Result[] = []
+
+ constructor(
+ readonly tagName: string,
+ readonly transform?: (chunks: SquareBracketMatcherResult) => Result,
+ readonly position = 0,
+ ) {}
+
+ private emit(matched: boolean, data: string) {
+ if (!data) return
+ const chunk: SquareBracketMatcherResult = { matched, data }
+ this.results.push(this.transform ? this.transform(chunk) : (chunk as Result))
+ }
+
+ private processComplete() {
+ const openTag = `[${this.tagName}]`
+ const closeTag = `[/${this.tagName}]`
+ let processed = false
+
+ while (true) {
+ if (!this.insideTag) {
+ // Look for opening tag
+ const openIndex = this.buffer.indexOf(openTag)
+ if (openIndex === -1) {
+ // No opening tag found
+ break
+ }
+
+ if (openIndex > 0) {
+ // Emit text before tag
+ this.emit(false, this.buffer.substring(0, openIndex))
+ this.buffer = this.buffer.substring(openIndex)
+ processed = true
+ }
+
+ // Now we have opening tag at start
+ this.buffer = this.buffer.substring(openTag.length)
+ this.insideTag = true
+ this.tagDepth = 1
+ processed = true
+ } else {
+ // Inside tag, look for closing tag
+ let pos = 0
+ let contentStart = 0
+
+ while (pos < this.buffer.length) {
+ const nextOpen = this.buffer.indexOf(openTag, pos)
+ const nextClose = this.buffer.indexOf(closeTag, pos)
+
+ if (nextClose === -1) {
+ // No closing tag found yet
+ break
+ }
+
+ if (nextOpen !== -1 && nextOpen < nextClose) {
+ // Found nested opening tag
+ this.tagDepth++
+ pos = nextOpen + openTag.length
+ } else {
+ // Found closing tag
+ this.tagDepth--
+ if (this.tagDepth === 0) {
+ // Complete match found
+ const content = this.buffer.substring(contentStart, nextClose)
+ this.emit(true, content)
+ this.buffer = this.buffer.substring(nextClose + closeTag.length)
+ this.insideTag = false
+ processed = true
+ break
+ } else {
+ // Still nested
+ pos = nextClose + closeTag.length
+ }
+ }
+ }
+
+ if (this.insideTag) {
+ // Still inside tag, no complete match yet
+ break
+ }
+ }
+ }
+
+ return processed
+ }
+
+ update(chunk: string): Result[] {
+ this.buffer += chunk
+ this.results = []
+
+ // Process any complete tag pairs
+ this.processComplete()
+
+ // For streaming, only emit unmatched text if we're sure it won't be part of a tag
+ if (!this.insideTag && this.buffer && !this.buffer.includes("[")) {
+ // No potential tags, emit as text
+ this.emit(false, this.buffer)
+ this.buffer = ""
+ }
+
+ const results = this.results
+ this.results = []
+ return results
+ }
+
+ final(chunk?: string): Result[] {
+ if (chunk) {
+ this.buffer += chunk
+ }
+
+ this.results = []
+
+ // Process any remaining complete pairs
+ this.processComplete()
+
+ // Emit any remaining buffer
+ if (this.buffer) {
+ this.emit(this.insideTag, this.buffer)
+ this.buffer = ""
+ }
+
+ const results = this.results
+ this.results = []
+ this.insideTag = false
+ this.tagDepth = 0
+ return results
+ }
+}