Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/ollama-adapter/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
resolveOverride,
type OllamaAdapterOverride,
} from "./overrides";
import { createThinkSplitState, reclassifyThinkingEvents } from "./think-tags";

type OllamaChatBody = {
options?: Record<string, unknown>;
Expand Down Expand Up @@ -78,12 +79,22 @@ export const createOllamaAdapter: AdapterFactory = (
): ProviderAdapter => {
const config = parseOllamaAdapterConfig(quirks);
const inner = createOpenAIAdapter(source);
// One split state per adapter instance: the registry resolves a fresh
// adapter per request (see `createAdapterRegistry`'s own doc comment),
// so this safely tracks "are we inside a `<think>` span" across every
// chunk of one response without leaking state between requests.
const streamThinkState = createThinkSplitState();
const jsonThinkState = createThinkSplitState();
return {
...inner,
buildRequest: (messages, model, options) =>
applyOverride(
inner.buildRequest(messages, model, options),
resolveOverride(config, model),
),
parseResponse: (sseData) =>
reclassifyThinkingEvents(inner.parseResponse(sseData), streamThinkState),
parseJSONResponse: (body) =>
reclassifyThinkingEvents(inner.parseJSONResponse(body), jsonThinkState),
};
};
5 changes: 5 additions & 0 deletions packages/ollama-adapter/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,8 @@ export {
parseOllamaAdapterConfig,
resolveOverride,
} from "./overrides";
export {
createThinkSplitState,
reclassifyThinkingEvents,
type ThinkSplitState,
} from "./think-tags";
115 changes: 115 additions & 0 deletions packages/ollama-adapter/src/think-tags.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, expect, test } from "bun:test";
import type { InferenceEvent } from "@intx/types/runtime";

import { createThinkSplitState, reclassifyThinkingEvents } from "./think-tags";

function textDelta(token: string, seq = 1): InferenceEvent {
return {
type: "inference.text.delta",
seq,
data: { token, partial: { text: token }, index: 0 },
};
}

describe("reclassifyThinkingEvents", () => {
test("a whole <think>...</think> span in one token becomes thinking-delta, not text-delta", () => {
const state = createThinkSplitState();
const out = reclassifyThinkingEvents(
[textDelta("<think>plan the approach</think>Here is the answer.")],
state,
);
expect(out).toHaveLength(2);
expect(out[0]?.type).toBe("inference.thinking.delta");
expect((out[0]?.data as { token: string }).token).toBe("plan the approach");
expect(out[1]?.type).toBe("inference.text.delta");
expect((out[1]?.data as { token: string }).token).toBe(
"Here is the answer.",
);
});

test("a <think> span split across multiple chunks stays classified as thinking across the boundary", () => {
const state = createThinkSplitState();
const first = reclassifyThinkingEvents(
[textDelta("<think>step one, ")],
state,
);
const second = reclassifyThinkingEvents(
[textDelta("step two</think>final reply")],
state,
);

expect(first).toHaveLength(1);
expect(first[0]?.type).toBe("inference.thinking.delta");
expect(second).toHaveLength(2);
expect(second[0]?.type).toBe("inference.thinking.delta");
expect((second[0]?.data as { token: string }).token).toBe("step two");
expect(second[1]?.type).toBe("inference.text.delta");
expect((second[1]?.data as { token: string }).token).toBe("final reply");
});

test("ordinary text with no <think> tag passes through as text-delta unchanged", () => {
const state = createThinkSplitState();
const out = reclassifyThinkingEvents(
[textDelta("just a normal reply")],
state,
);
expect(out).toEqual([textDelta("just a normal reply")]);
});

test("non-text events (tool calls, done) pass through untouched", () => {
const state = createThinkSplitState();
const toolCallStart: InferenceEvent = {
type: "inference.tool_call.start",
seq: 1,
data: {
callId: "call-1",
name: "slack__post_message",
partial: { text: "" },
},
};
const out = reclassifyThinkingEvents([toolCallStart], state);
expect(out).toEqual([toolCallStart]);
});

test("thinking events never share an index with the text stream, so the harness's per-index blockMap can't collide them", () => {
const state = createThinkSplitState();
const out = reclassifyThinkingEvents(
[textDelta("<think>internal notes</think>visible reply", 3)],
state,
);
const thinkingEvent = out.find(
(event) => event.type === "inference.thinking.delta",
);
const textEvent = out.find(
(event) => event.type === "inference.text.delta",
);
expect((thinkingEvent?.data as { index?: number }).index).not.toBe(
(textEvent?.data as { index?: number }).index,
);
expect((textEvent?.data as { index?: number }).index).toBe(0);
});

test("cumulative partial.text/partial.thinking reflect only their own kind, never the raw tags", () => {
const state = createThinkSplitState();
const out = reclassifyThinkingEvents(
[textDelta("<think>internal notes</think>visible reply")],
state,
);
const thinkingEvent = out.find(
(event) => event.type === "inference.thinking.delta",
);
const textEvent = out.find(
(event) => event.type === "inference.text.delta",
);
expect(
(thinkingEvent?.data as { partial: { thinking?: string } }).partial
.thinking,
).toBe("internal notes");
expect(
(textEvent?.data as { partial: { text: string } }).partial.text,
).toBe("visible reply");
expect(
(textEvent?.data as { partial: { text: string } }).partial.text,
).not.toContain("<think>");
});
});
147 changes: 147 additions & 0 deletions packages/ollama-adapter/src/think-tags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// Ollama's OpenAI-compatible endpoint never populates the `reasoning`/
// `reasoning_content` delta fields `@intx/inference`'s OpenAI provider
// looks for (see `providers/openai.js`'s `reasoningFieldNames` handling).
// gpt-oss and qwen instead emit their chain-of-thought inline inside the
// ordinary `content` field, wrapped in `<think>…</think>`. Left alone, that
// text is indistinguishable from the reply and rides every hop downstream
// as a genuine `inference.text.delta` — this is the CL-6654 leak. This
// module reclassifies it into `inference.thinking.delta` before anything
// else ever sees it, at the one place that already knows these tokens came
// from Ollama.
import type { InferenceEvent } from "@intx/types/runtime";

/** Carries the split state across every chunk of one streamed response —
* a `<think>` tag can land at a chunk boundary, so "are we inside a
* thinking span" has to survive from one `parseResponse` call to the next. */
export type ThinkSplitState = {
inThink: boolean;
/** Once a `<think>` tag has appeared at all, every later text-delta gets
* its `partial` recomputed from this module's own tally rather than the
* built-in adapter's — otherwise a token after the closing tag would
* still carry the raw-tagged cumulative text the built-in parser tracked
* on its own. Before the first tag, events pass through byte-identical. */
everInThink: boolean;
textAcc: string;
thinkingAcc: string;
};

export function createThinkSplitState(): ThinkSplitState {
return { inThink: false, everInThink: false, textAcc: "", thinkingAcc: "" };
}

const THINK_OPEN = "<think>";
const THINK_CLOSE = "</think>";

/** The harness (`@intx/inference`'s `dist/harness.js`) keys its per-index
* `blockMap` by `event.data.index` and throws a `ProtocolMismatchError` the
* moment two different block kinds land at the same index — so the
* thinking half of a split can never reuse the text index the built-in
* OpenAI adapter already assigned to Ollama's one undifferentiated content
* stream. Ollama's own indexer starts at 0 and only counts up (one index
* per real block: text, then any tool calls), so a fixed negative
* sentinel is guaranteed to never collide with one it hands out. */
const THINKING_BLOCK_INDEX = -1;

type TokenSplit = {
readonly textToken: string;
readonly thinkingToken: string;
};

/** Peels `<think>`/`</think>` spans out of one token, folding the result
* into the running cumulative text/thinking strings. A tag never splits
* across two tokens in practice, but a token straddling the tag boundary
* (open and content in the same token, or close and content) is still
* handled correctly by looping until the whole token is consumed. */
function splitToken(state: ThinkSplitState, token: string): TokenSplit {
let remaining = token;
let textToken = "";
let thinkingToken = "";

while (remaining.length > 0) {
if (!state.inThink) {
const openIndex = remaining.indexOf(THINK_OPEN);
if (openIndex === -1) {
textToken += remaining;
break;
}
textToken += remaining.slice(0, openIndex);
remaining = remaining.slice(openIndex + THINK_OPEN.length);
state.inThink = true;
} else {
const closeIndex = remaining.indexOf(THINK_CLOSE);
if (closeIndex === -1) {
thinkingToken += remaining;
break;
}
thinkingToken += remaining.slice(0, closeIndex);
remaining = remaining.slice(closeIndex + THINK_CLOSE.length);
state.inThink = false;
}
}

state.textAcc += textToken;
state.thinkingAcc += thinkingToken;
return { textToken, thinkingToken };
}

/**
* Rewrites one `parseResponse`/`parseJSONResponse` result so a
* `<think>…</think>` span in an `inference.text.delta`'s token becomes an
* `inference.thinking.delta` instead — every other event (tool calls,
* usage, `inference.done`, ...) passes through untouched. `state` is
* mutated in place so a caller threads the same instance across every
* chunk of one response.
*/
export function reclassifyThinkingEvents(
events: readonly InferenceEvent[],
state: ThinkSplitState,
): InferenceEvent[] {
const output: InferenceEvent[] = [];

for (const event of events) {
if (event.type !== "inference.text.delta") {
output.push(event);
continue;
}

const token = event.data.token;
if (!state.inThink && !state.everInThink && !token.includes(THINK_OPEN)) {
output.push(event);
continue;
}
state.everInThink = true;

const { textToken, thinkingToken } = splitToken(state, token);
const partial = {
text: state.textAcc,
...(state.thinkingAcc !== "" ? { thinking: state.thinkingAcc } : {}),
};

if (thinkingToken !== "") {
output.push({
type: "inference.thinking.delta",
seq: event.seq,
data: {
token: thinkingToken,
partial,
index: THINKING_BLOCK_INDEX,
},
});
}
if (textToken !== "") {
output.push({
type: "inference.text.delta",
seq: event.seq,
data: {
token: textToken,
partial,
...(event.data.index !== undefined
? { index: event.data.index }
: {}),
},
});
}
}

return output;
}
Loading