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
9 changes: 9 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,15 @@ export interface ApiHandler {
* @returns A promise resolving to the token count
*/
countTokens(content: Array<Anthropic.Messages.ContentBlockParam>): Promise<number>

/**
* Indicates whether this provider uses the Vercel AI SDK for streaming.
* AI SDK providers handle reasoning blocks differently and need to preserve
* them in conversation history for proper round-tripping.
*
* @returns true if the provider uses AI SDK, false otherwise
*/
isAiSdkProvider(): boolean
}

export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
Expand Down
8 changes: 8 additions & 0 deletions src/api/providers/base-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,4 +119,12 @@ export abstract class BaseProvider implements ApiHandler {

return countTokens(content, { useWorker: true })
}

/**
* Default implementation returns false.
* AI SDK providers should override this to return true.
*/
isAiSdkProvider(): boolean {
return false
}
}
4 changes: 4 additions & 0 deletions src/api/providers/cerebras.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,4 +156,8 @@ export class CerebrasHandler extends BaseProvider implements SingleCompletionHan

return text
}

override isAiSdkProvider(): boolean {
return true
}
}
4 changes: 4 additions & 0 deletions src/api/providers/deepseek.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,4 +166,8 @@ export class DeepSeekHandler extends BaseProvider implements SingleCompletionHan

return text
}

override isAiSdkProvider(): boolean {
return true
}
}
4 changes: 4 additions & 0 deletions src/api/providers/fake-ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,4 +78,8 @@ export class FakeAIHandler implements ApiHandler, SingleCompletionHandler {
completePrompt(prompt: string): Promise<string> {
return this.ai.completePrompt(prompt)
}

isAiSdkProvider(): boolean {
return false
}
}
4 changes: 4 additions & 0 deletions src/api/providers/fireworks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,4 +172,8 @@ export class FireworksHandler extends BaseProvider implements SingleCompletionHa

return text
}

override isAiSdkProvider(): boolean {
return true
}
}
4 changes: 4 additions & 0 deletions src/api/providers/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,4 +397,8 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl

return totalCost
}

override isAiSdkProvider(): boolean {
return true
}
}
4 changes: 4 additions & 0 deletions src/api/providers/groq.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,4 +174,8 @@ export class GroqHandler extends BaseProvider implements SingleCompletionHandler

return text
}

override isAiSdkProvider(): boolean {
return true
}
}
4 changes: 4 additions & 0 deletions src/api/providers/huggingface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,4 +208,8 @@ export class HuggingFaceHandler extends BaseProvider implements SingleCompletion

return text
}

override isAiSdkProvider(): boolean {
return true
}
}
4 changes: 4 additions & 0 deletions src/api/providers/mistral.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,4 +198,8 @@ export class MistralHandler extends BaseProvider implements SingleCompletionHand

return text
}

override isAiSdkProvider(): boolean {
return true
}
}
4 changes: 4 additions & 0 deletions src/api/providers/openai-compatible.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,4 +186,8 @@ export abstract class OpenAICompatibleHandler extends BaseProvider implements Si

return text
}

override isAiSdkProvider(): boolean {
return true
}
}
4 changes: 4 additions & 0 deletions src/api/providers/sambanova.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,4 +177,8 @@ export class SambaNovaHandler extends BaseProvider implements SingleCompletionHa

return text
}

override isAiSdkProvider(): boolean {
return true
}
}
4 changes: 4 additions & 0 deletions src/api/providers/vertex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,4 +402,8 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl

return totalCost
}

override isAiSdkProvider(): boolean {
return true
}
}
4 changes: 4 additions & 0 deletions src/api/providers/xai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,4 +187,8 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler
throw handleAiSdkError(error, "xAI")
}
}

override isAiSdkProvider(): boolean {
return true
}
}
140 changes: 140 additions & 0 deletions src/api/transform/__tests__/ai-sdk.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,97 @@ describe("AI SDK conversion utilities", () => {
content: [{ type: "text", text: "" }],
})
})

it("converts assistant reasoning blocks", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{ type: "reasoning" as any, text: "Thinking..." },
{ type: "text", text: "Answer" },
],
},
]

const result = convertToAiSdkMessages(messages)

expect(result).toHaveLength(1)
expect(result[0]).toEqual({
role: "assistant",
content: [
{ type: "reasoning", text: "Thinking..." },
{ type: "text", text: "Answer" },
],
})
})

it("converts assistant thinking blocks to reasoning", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{ type: "thinking" as any, thinking: "Deep thought", signature: "sig" },
{ type: "text", text: "OK" },
],
},
]

const result = convertToAiSdkMessages(messages)

expect(result).toHaveLength(1)
expect(result[0]).toEqual({
role: "assistant",
content: [
{ type: "reasoning", text: "Deep thought" },
{ type: "text", text: "OK" },
],
})
})

it("converts assistant message-level reasoning_content to reasoning part", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [{ type: "text", text: "Answer" }],
reasoning_content: "Thinking...",
} as any,
]

const result = convertToAiSdkMessages(messages)

expect(result).toHaveLength(1)
expect(result[0]).toEqual({
role: "assistant",
content: [
{ type: "reasoning", text: "Thinking..." },
{ type: "text", text: "Answer" },
],
})
})

it("prefers message-level reasoning_content over reasoning blocks", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "assistant",
content: [
{ type: "reasoning" as any, text: "BLOCK" },
{ type: "text", text: "Answer" },
],
reasoning_content: "MSG",
} as any,
]

const result = convertToAiSdkMessages(messages)

expect(result).toHaveLength(1)
expect(result[0]).toEqual({
role: "assistant",
content: [
{ type: "reasoning", text: "MSG" },
{ type: "text", text: "Answer" },
],
})
})
})

describe("convertToolsForAiSdk", () => {
Expand Down Expand Up @@ -817,5 +908,54 @@ describe("AI SDK conversion utilities", () => {

expect(result[0].content).toBe("\nHello")
})

it("should strip reasoning parts and flatten text for string-only models", () => {
const messages = [
{
role: "assistant" as const,
content: [
{ type: "reasoning" as const, text: "I am thinking about this..." },
{ type: "text" as const, text: "Here is my answer" },
],
},
]

const result = flattenAiSdkMessagesToStringContent(messages)

// Reasoning should be stripped, only text should remain
expect(result[0].content).toBe("Here is my answer")
})

it("should handle messages with only reasoning parts", () => {
const messages = [
{
role: "assistant" as const,
content: [{ type: "reasoning" as const, text: "Only reasoning, no text" }],
},
]

const result = flattenAiSdkMessagesToStringContent(messages)

// Should flatten to empty string when only reasoning is present
expect(result[0].content).toBe("")
})

it("should not flatten if tool calls are present with reasoning", () => {
const messages = [
{
role: "assistant" as const,
content: [
{ type: "reasoning" as const, text: "Thinking..." },
{ type: "text" as const, text: "Using tool" },
{ type: "tool-call" as const, toolCallId: "abc", toolName: "test", input: {} },
],
},
]

const result = flattenAiSdkMessagesToStringContent(messages)

// Should not flatten because there's a tool call
expect(result[0]).toEqual(messages[0])
})
})
})
1 change: 1 addition & 0 deletions src/api/transform/__tests__/image-cleaning.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ describe("maybeRemoveImageBlocks", () => {
}),
createMessage: vitest.fn(),
countTokens: vitest.fn(),
isAiSdkProvider: vitest.fn().mockReturnValue(false),
}
}

Expand Down
54 changes: 49 additions & 5 deletions src/api/transform/ai-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ export function convertToAiSdkMessages(
}
} else if (message.role === "assistant") {
const textParts: string[] = []
const reasoningParts: string[] = []
const reasoningContent = (() => {
const maybe = (message as unknown as { reasoning_content?: unknown }).reasoning_content
return typeof maybe === "string" && maybe.length > 0 ? maybe : undefined
})()
const toolCalls: Array<{
type: "tool-call"
toolCallId: string
Expand All @@ -136,21 +141,57 @@ export function convertToAiSdkMessages(
for (const part of message.content) {
if (part.type === "text") {
textParts.push(part.text)
} else if (part.type === "tool_use") {
continue
}

if (part.type === "tool_use") {
toolCalls.push({
type: "tool-call",
toolCallId: part.id,
toolName: part.name,
input: part.input,
})
continue
}

// Some providers (DeepSeek, Gemini, etc.) require reasoning to be round-tripped.
// Task stores reasoning as a content block (type: "reasoning") and Anthropic extended
// thinking as (type: "thinking"). Convert both to AI SDK's reasoning part.
if ((part as unknown as { type?: string }).type === "reasoning") {
// If message-level reasoning_content is present, treat it as canonical and
// avoid mixing it with content-block reasoning (which can cause duplication).
if (reasoningContent) continue

const text = (part as unknown as { text?: string }).text
if (typeof text === "string" && text.length > 0) {
reasoningParts.push(text)
}
continue
}

if ((part as unknown as { type?: string }).type === "thinking") {
if (reasoningContent) continue

const thinking = (part as unknown as { thinking?: string }).thinking
if (typeof thinking === "string" && thinking.length > 0) {
reasoningParts.push(thinking)
}
continue
}
}

const content: Array<
| { type: "reasoning"; text: string }
| { type: "text"; text: string }
| { type: "tool-call"; toolCallId: string; toolName: string; input: unknown }
> = []

if (reasoningContent) {
content.push({ type: "reasoning", text: reasoningContent })
} else if (reasoningParts.length > 0) {
content.push({ type: "reasoning", text: reasoningParts.join("") })
}

if (textParts.length > 0) {
content.push({ type: "text", text: textParts.join("\n") })
}
Expand Down Expand Up @@ -226,10 +267,13 @@ export function flattenAiSdkMessagesToStringContent(
// Handle assistant messages
if (message.role === "assistant" && flattenAssistantMessages && Array.isArray(message.content)) {
const parts = message.content as Array<{ type: string; text?: string }>
// Only flatten if all parts are text (no tool calls)
const allText = parts.every((part) => part.type === "text")
if (allText && parts.length > 0) {
const textContent = parts.map((part) => part.text || "").join("\n")
// Only flatten if all parts are text or reasoning (no tool calls)
// Reasoning parts are included in text to avoid sending multipart content to string-only models
const allTextOrReasoning = parts.every((part) => part.type === "text" || part.type === "reasoning")
if (allTextOrReasoning && parts.length > 0) {
// Extract only text parts for the flattened content (reasoning is stripped for string-only models)
const textParts = parts.filter((part) => part.type === "text")
const textContent = textParts.map((part) => part.text || "").join("\n")
return {
...message,
content: textContent,
Expand Down
Loading
Loading