mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat: migrate DeepSeek to @ai-sdk/deepseek + fix AI SDK tool streaming (#11079)
This commit is contained in:
parent
f848795775
commit
4b1d78fe0a
8 changed files with 811 additions and 455 deletions
17
pnpm-lock.yaml
generated
17
pnpm-lock.yaml
generated
|
|
@ -743,6 +743,9 @@ importers:
|
|||
|
||||
src:
|
||||
dependencies:
|
||||
'@ai-sdk/deepseek':
|
||||
specifier: ^2.0.14
|
||||
version: 2.0.14(zod@3.25.76)
|
||||
'@anthropic-ai/bedrock-sdk':
|
||||
specifier: ^0.10.2
|
||||
version: 0.10.4
|
||||
|
|
@ -1387,6 +1390,12 @@ packages:
|
|||
'@adobe/css-tools@4.4.2':
|
||||
resolution: {integrity: sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==}
|
||||
|
||||
'@ai-sdk/deepseek@2.0.14':
|
||||
resolution: {integrity: sha512-1vXh8sVwRJYd1JO57qdy1rACucaNLDoBRCwOER3EbPgSF2vNVPcdJywGutA01Bhn7Cta+UJQ+k5y/yzMAIpP2w==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/gateway@3.0.25':
|
||||
resolution: {integrity: sha512-j0AQeA7hOVqwImykQlganf/Euj3uEXf0h3G0O4qKTDpEwE+EZGIPnVimCWht5W91lAetPZSfavDyvfpuPDd2PQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -10810,6 +10819,12 @@ snapshots:
|
|||
|
||||
'@adobe/css-tools@4.4.2': {}
|
||||
|
||||
'@ai-sdk/deepseek@2.0.14(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 3.0.5
|
||||
'@ai-sdk/provider-utils': 4.0.10(zod@3.25.76)
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/gateway@3.0.25(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 3.0.5
|
||||
|
|
@ -14671,7 +14686,7 @@ snapshots:
|
|||
sirv: 3.0.1
|
||||
tinyglobby: 0.2.14
|
||||
tinyrainbow: 2.0.0
|
||||
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
|
||||
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
|
||||
|
||||
'@vitest/utils@3.2.4':
|
||||
dependencies:
|
||||
|
|
|
|||
|
|
@ -1,125 +1,28 @@
|
|||
// Mocks must come first, before imports
|
||||
const mockCreate = vi.fn()
|
||||
vi.mock("openai", () => {
|
||||
// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls
|
||||
const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({
|
||||
mockStreamText: vi.fn(),
|
||||
mockGenerateText: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("ai", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("ai")>()
|
||||
return {
|
||||
__esModule: true,
|
||||
default: vi.fn().mockImplementation(() => ({
|
||||
chat: {
|
||||
completions: {
|
||||
create: mockCreate.mockImplementation(async (options) => {
|
||||
if (!options.stream) {
|
||||
return {
|
||||
id: "test-completion",
|
||||
choices: [
|
||||
{
|
||||
message: { role: "assistant", content: "Test response", refusal: null },
|
||||
finish_reason: "stop",
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
total_tokens: 15,
|
||||
prompt_tokens_details: {
|
||||
cache_miss_tokens: 8,
|
||||
cached_tokens: 2,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a reasoning_content test by looking at model
|
||||
const isReasonerModel = options.model?.includes("deepseek-reasoner")
|
||||
const isToolCallTest = options.tools?.length > 0
|
||||
|
||||
// Return async iterator for streaming
|
||||
return {
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
// For reasoner models, emit reasoning_content first
|
||||
if (isReasonerModel) {
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: { reasoning_content: "Let me think about this..." },
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: null,
|
||||
}
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: { reasoning_content: " I'll analyze step by step." },
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: null,
|
||||
}
|
||||
}
|
||||
|
||||
// For tool call tests with reasoner, emit tool call
|
||||
if (isReasonerModel && isToolCallTest) {
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_123",
|
||||
function: {
|
||||
name: "get_weather",
|
||||
arguments: '{"location":"SF"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: null,
|
||||
}
|
||||
} else {
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: { content: "Test response" },
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: null,
|
||||
}
|
||||
}
|
||||
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {},
|
||||
index: 0,
|
||||
finish_reason: isToolCallTest ? "tool_calls" : "stop",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
total_tokens: 15,
|
||||
prompt_tokens_details: {
|
||||
cache_miss_tokens: 8,
|
||||
cached_tokens: 2,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}),
|
||||
},
|
||||
},
|
||||
})),
|
||||
...actual,
|
||||
streamText: mockStreamText,
|
||||
generateText: mockGenerateText,
|
||||
}
|
||||
})
|
||||
|
||||
import OpenAI from "openai"
|
||||
vi.mock("@ai-sdk/deepseek", () => ({
|
||||
createDeepSeek: vi.fn(() => {
|
||||
// Return a function that returns a mock language model
|
||||
return vi.fn(() => ({
|
||||
modelId: "deepseek-chat",
|
||||
provider: "deepseek",
|
||||
}))
|
||||
}),
|
||||
}))
|
||||
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import { deepSeekDefaultModelId, type ModelInfo } from "@roo-code/types"
|
||||
|
|
@ -148,15 +51,6 @@ describe("DeepSeekHandler", () => {
|
|||
expect(handler.getModel().id).toBe(mockOptions.apiModelId)
|
||||
})
|
||||
|
||||
it.skip("should throw error if API key is missing", () => {
|
||||
expect(() => {
|
||||
new DeepSeekHandler({
|
||||
...mockOptions,
|
||||
deepSeekApiKey: undefined,
|
||||
})
|
||||
}).toThrow("DeepSeek API key is required")
|
||||
})
|
||||
|
||||
it("should use default model ID if not provided", () => {
|
||||
const handlerWithoutModel = new DeepSeekHandler({
|
||||
...mockOptions,
|
||||
|
|
@ -171,12 +65,6 @@ describe("DeepSeekHandler", () => {
|
|||
deepSeekBaseUrl: undefined,
|
||||
})
|
||||
expect(handlerWithoutBaseUrl).toBeInstanceOf(DeepSeekHandler)
|
||||
// The base URL is passed to OpenAI client internally
|
||||
expect(OpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: "https://api.deepseek.com",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should use custom base URL if provided", () => {
|
||||
|
|
@ -186,18 +74,6 @@ describe("DeepSeekHandler", () => {
|
|||
deepSeekBaseUrl: customBaseUrl,
|
||||
})
|
||||
expect(handlerWithCustomUrl).toBeInstanceOf(DeepSeekHandler)
|
||||
// The custom base URL is passed to OpenAI client
|
||||
expect(OpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: customBaseUrl,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should set includeMaxTokens to true", () => {
|
||||
// Create a new handler and verify OpenAI client was called with includeMaxTokens
|
||||
const _handler = new DeepSeekHandler(mockOptions)
|
||||
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: mockOptions.deepSeekApiKey }))
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -296,6 +172,31 @@ describe("DeepSeekHandler", () => {
|
|||
]
|
||||
|
||||
it("should handle streaming responses", async () => {
|
||||
// Mock the fullStream async generator
|
||||
// Note: processAiSdkStreamPart expects 'text' property for text-delta type
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
|
||||
// Mock usage and providerMetadata promises
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
const mockProviderMetadata = Promise.resolve({
|
||||
deepseek: {
|
||||
promptCacheHitTokens: 2,
|
||||
promptCacheMissTokens: 8,
|
||||
},
|
||||
})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
|
|
@ -309,6 +210,28 @@ describe("DeepSeekHandler", () => {
|
|||
})
|
||||
|
||||
it("should include usage information", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
const mockProviderMetadata = Promise.resolve({
|
||||
deepseek: {
|
||||
promptCacheHitTokens: 2,
|
||||
promptCacheMissTokens: 8,
|
||||
},
|
||||
})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
|
|
@ -321,7 +244,30 @@ describe("DeepSeekHandler", () => {
|
|||
expect(usageChunks[0].outputTokens).toBe(5)
|
||||
})
|
||||
|
||||
it("should include cache metrics in usage information", async () => {
|
||||
it("should include cache metrics in usage information from providerMetadata", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
// DeepSeek provides cache metrics via providerMetadata
|
||||
const mockProviderMetadata = Promise.resolve({
|
||||
deepseek: {
|
||||
promptCacheHitTokens: 2,
|
||||
promptCacheMissTokens: 8,
|
||||
},
|
||||
})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
|
|
@ -330,29 +276,76 @@ describe("DeepSeekHandler", () => {
|
|||
|
||||
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
|
||||
expect(usageChunks.length).toBeGreaterThan(0)
|
||||
expect(usageChunks[0].cacheWriteTokens).toBe(8)
|
||||
expect(usageChunks[0].cacheReadTokens).toBe(2)
|
||||
expect(usageChunks[0].cacheWriteTokens).toBe(8) // promptCacheMissTokens
|
||||
expect(usageChunks[0].cacheReadTokens).toBe(2) // promptCacheHitTokens
|
||||
})
|
||||
})
|
||||
|
||||
describe("completePrompt", () => {
|
||||
it("should complete a prompt using generateText", async () => {
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: "Test completion",
|
||||
})
|
||||
|
||||
const result = await handler.completePrompt("Test prompt")
|
||||
|
||||
expect(result).toBe("Test completion")
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
prompt: "Test prompt",
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("processUsageMetrics", () => {
|
||||
it("should correctly process usage metrics including cache information", () => {
|
||||
it("should correctly process usage metrics including cache information from providerMetadata", () => {
|
||||
// We need to access the protected method, so we'll create a test subclass
|
||||
class TestDeepSeekHandler extends DeepSeekHandler {
|
||||
public testProcessUsageMetrics(usage: any) {
|
||||
return this.processUsageMetrics(usage)
|
||||
public testProcessUsageMetrics(usage: any, providerMetadata?: any) {
|
||||
return this.processUsageMetrics(usage, providerMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
const testHandler = new TestDeepSeekHandler(mockOptions)
|
||||
|
||||
const usage = {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 50,
|
||||
total_tokens: 150,
|
||||
prompt_tokens_details: {
|
||||
cache_miss_tokens: 80,
|
||||
cached_tokens: 20,
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
}
|
||||
|
||||
// DeepSeek provides cache metrics via providerMetadata
|
||||
const providerMetadata = {
|
||||
deepseek: {
|
||||
promptCacheHitTokens: 20,
|
||||
promptCacheMissTokens: 80,
|
||||
},
|
||||
}
|
||||
|
||||
const result = testHandler.testProcessUsageMetrics(usage, providerMetadata)
|
||||
|
||||
expect(result.type).toBe("usage")
|
||||
expect(result.inputTokens).toBe(100)
|
||||
expect(result.outputTokens).toBe(50)
|
||||
expect(result.cacheWriteTokens).toBe(80) // promptCacheMissTokens
|
||||
expect(result.cacheReadTokens).toBe(20) // promptCacheHitTokens
|
||||
})
|
||||
|
||||
it("should handle usage with details.cachedInputTokens when providerMetadata is not available", () => {
|
||||
class TestDeepSeekHandler extends DeepSeekHandler {
|
||||
public testProcessUsageMetrics(usage: any, providerMetadata?: any) {
|
||||
return this.processUsageMetrics(usage, providerMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
const testHandler = new TestDeepSeekHandler(mockOptions)
|
||||
|
||||
const usage = {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
details: {
|
||||
cachedInputTokens: 25,
|
||||
reasoningTokens: 30,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -361,24 +354,24 @@ describe("DeepSeekHandler", () => {
|
|||
expect(result.type).toBe("usage")
|
||||
expect(result.inputTokens).toBe(100)
|
||||
expect(result.outputTokens).toBe(50)
|
||||
expect(result.cacheWriteTokens).toBe(80)
|
||||
expect(result.cacheReadTokens).toBe(20)
|
||||
expect(result.cacheReadTokens).toBe(25) // from details.cachedInputTokens
|
||||
expect(result.cacheWriteTokens).toBeUndefined()
|
||||
expect(result.reasoningTokens).toBe(30)
|
||||
})
|
||||
|
||||
it("should handle missing cache metrics gracefully", () => {
|
||||
class TestDeepSeekHandler extends DeepSeekHandler {
|
||||
public testProcessUsageMetrics(usage: any) {
|
||||
return this.processUsageMetrics(usage)
|
||||
public testProcessUsageMetrics(usage: any, providerMetadata?: any) {
|
||||
return this.processUsageMetrics(usage, providerMetadata)
|
||||
}
|
||||
}
|
||||
|
||||
const testHandler = new TestDeepSeekHandler(mockOptions)
|
||||
|
||||
const usage = {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 50,
|
||||
total_tokens: 150,
|
||||
// No prompt_tokens_details
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
// No details or providerMetadata
|
||||
}
|
||||
|
||||
const result = testHandler.testProcessUsageMetrics(usage)
|
||||
|
|
@ -391,7 +384,7 @@ describe("DeepSeekHandler", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("interleaved thinking mode", () => {
|
||||
describe("reasoning content with deepseek-reasoner", () => {
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
|
|
@ -405,12 +398,41 @@ describe("DeepSeekHandler", () => {
|
|||
},
|
||||
]
|
||||
|
||||
it("should handle reasoning_content in streaming responses for deepseek-reasoner", async () => {
|
||||
it("should handle reasoning content in streaming responses for deepseek-reasoner", async () => {
|
||||
const reasonerHandler = new DeepSeekHandler({
|
||||
...mockOptions,
|
||||
apiModelId: "deepseek-reasoner",
|
||||
})
|
||||
|
||||
// Mock the fullStream async generator with reasoning content
|
||||
// Note: processAiSdkStreamPart expects 'text' property for reasoning type
|
||||
async function* mockFullStream() {
|
||||
yield { type: "reasoning", text: "Let me think about this..." }
|
||||
yield { type: "reasoning", text: " I'll analyze step by step." }
|
||||
yield { type: "text-delta", text: "Test response" }
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
details: {
|
||||
reasoningTokens: 15,
|
||||
},
|
||||
})
|
||||
|
||||
const mockProviderMetadata = Promise.resolve({
|
||||
deepseek: {
|
||||
promptCacheHitTokens: 2,
|
||||
promptCacheMissTokens: 8,
|
||||
},
|
||||
})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const stream = reasonerHandler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
|
|
@ -419,54 +441,91 @@ describe("DeepSeekHandler", () => {
|
|||
|
||||
// Should have reasoning chunks
|
||||
const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning")
|
||||
expect(reasoningChunks.length).toBeGreaterThan(0)
|
||||
expect(reasoningChunks.length).toBe(2)
|
||||
expect(reasoningChunks[0].text).toBe("Let me think about this...")
|
||||
expect(reasoningChunks[1].text).toBe(" I'll analyze step by step.")
|
||||
|
||||
// Should also have text chunks
|
||||
const textChunks = chunks.filter((chunk) => chunk.type === "text")
|
||||
expect(textChunks.length).toBe(1)
|
||||
expect(textChunks[0].text).toBe("Test response")
|
||||
})
|
||||
|
||||
it("should pass thinking parameter for deepseek-reasoner model", async () => {
|
||||
it("should include reasoningTokens in usage for deepseek-reasoner", async () => {
|
||||
const reasonerHandler = new DeepSeekHandler({
|
||||
...mockOptions,
|
||||
apiModelId: "deepseek-reasoner",
|
||||
})
|
||||
|
||||
async function* mockFullStream() {
|
||||
yield { type: "reasoning", text: "Thinking..." }
|
||||
yield { type: "text-delta", text: "Answer" }
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
details: {
|
||||
reasoningTokens: 15,
|
||||
},
|
||||
})
|
||||
|
||||
const mockProviderMetadata = Promise.resolve({})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const stream = reasonerHandler.createMessage(systemPrompt, messages)
|
||||
for await (const _chunk of stream) {
|
||||
// Consume the stream
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Verify that the thinking parameter was passed to the API
|
||||
// Note: mockCreate receives two arguments - request options and path options
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
thinking: { type: "enabled" },
|
||||
}),
|
||||
{}, // Empty path options for non-Azure URLs
|
||||
)
|
||||
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
|
||||
expect(usageChunks.length).toBe(1)
|
||||
expect(usageChunks[0].reasoningTokens).toBe(15)
|
||||
})
|
||||
|
||||
it("should NOT pass thinking parameter for deepseek-chat model", async () => {
|
||||
const chatHandler = new DeepSeekHandler({
|
||||
...mockOptions,
|
||||
apiModelId: "deepseek-chat",
|
||||
})
|
||||
|
||||
const stream = chatHandler.createMessage(systemPrompt, messages)
|
||||
for await (const _chunk of stream) {
|
||||
// Consume the stream
|
||||
}
|
||||
|
||||
// Verify that the thinking parameter was NOT passed to the API
|
||||
const callArgs = mockCreate.mock.calls[0][0]
|
||||
expect(callArgs.thinking).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should handle tool calls with reasoning_content", async () => {
|
||||
it("should handle tool calls with reasoning content", async () => {
|
||||
const reasonerHandler = new DeepSeekHandler({
|
||||
...mockOptions,
|
||||
apiModelId: "deepseek-reasoner",
|
||||
})
|
||||
|
||||
// Mock stream with reasoning followed by tool call via streaming events
|
||||
// (tool-input-start/delta/end, NOT tool-call which is ignored to prevent duplicates)
|
||||
async function* mockFullStream() {
|
||||
yield { type: "reasoning", text: "Let me think about this..." }
|
||||
yield { type: "reasoning", text: " I'll analyze step by step." }
|
||||
yield { type: "tool-input-start", id: "call_123", toolName: "get_weather" }
|
||||
yield { type: "tool-input-delta", id: "call_123", delta: '{"location":"SF"}' }
|
||||
yield { type: "tool-input-end", id: "call_123" }
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
details: {
|
||||
reasoningTokens: 15,
|
||||
},
|
||||
})
|
||||
|
||||
const mockProviderMetadata = Promise.resolve({
|
||||
deepseek: {
|
||||
promptCacheHitTokens: 2,
|
||||
promptCacheMissTokens: 8,
|
||||
},
|
||||
})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const tools: any[] = [
|
||||
{
|
||||
type: "function",
|
||||
|
|
@ -486,12 +545,239 @@ describe("DeepSeekHandler", () => {
|
|||
|
||||
// Should have reasoning chunks
|
||||
const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning")
|
||||
expect(reasoningChunks.length).toBeGreaterThan(0)
|
||||
expect(reasoningChunks.length).toBe(2)
|
||||
|
||||
// Should have tool call chunks
|
||||
const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial")
|
||||
expect(toolCallChunks.length).toBeGreaterThan(0)
|
||||
expect(toolCallChunks[0].name).toBe("get_weather")
|
||||
// Should have tool call streaming chunks (start/delta/end, NOT tool_call)
|
||||
const toolCallStartChunks = chunks.filter((chunk) => chunk.type === "tool_call_start")
|
||||
expect(toolCallStartChunks.length).toBe(1)
|
||||
expect(toolCallStartChunks[0].name).toBe("get_weather")
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool handling", () => {
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text" as const, text: "Hello!" }],
|
||||
},
|
||||
]
|
||||
|
||||
it("should handle tool calls in streaming", async () => {
|
||||
async function* mockFullStream() {
|
||||
yield {
|
||||
type: "tool-input-start",
|
||||
id: "tool-call-1",
|
||||
toolName: "read_file",
|
||||
}
|
||||
yield {
|
||||
type: "tool-input-delta",
|
||||
id: "tool-call-1",
|
||||
delta: '{"path":"test.ts"}',
|
||||
}
|
||||
yield {
|
||||
type: "tool-input-end",
|
||||
id: "tool-call-1",
|
||||
}
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
const mockProviderMetadata = Promise.resolve({})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages, {
|
||||
taskId: "test-task",
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "read_file",
|
||||
description: "Read a file",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { path: { type: "string" } },
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const toolCallStartChunks = chunks.filter((c) => c.type === "tool_call_start")
|
||||
const toolCallDeltaChunks = chunks.filter((c) => c.type === "tool_call_delta")
|
||||
const toolCallEndChunks = chunks.filter((c) => c.type === "tool_call_end")
|
||||
|
||||
expect(toolCallStartChunks.length).toBe(1)
|
||||
expect(toolCallStartChunks[0].id).toBe("tool-call-1")
|
||||
expect(toolCallStartChunks[0].name).toBe("read_file")
|
||||
|
||||
expect(toolCallDeltaChunks.length).toBe(1)
|
||||
expect(toolCallDeltaChunks[0].delta).toBe('{"path":"test.ts"}')
|
||||
|
||||
expect(toolCallEndChunks.length).toBe(1)
|
||||
expect(toolCallEndChunks[0].id).toBe("tool-call-1")
|
||||
})
|
||||
|
||||
it("should ignore tool-call events to prevent duplicate tools in UI", async () => {
|
||||
// tool-call events are intentionally ignored because tool-input-start/delta/end
|
||||
// already provide complete tool call information. Emitting tool-call would cause
|
||||
// duplicate tools in the UI for AI SDK providers (e.g., DeepSeek, Moonshot).
|
||||
async function* mockFullStream() {
|
||||
yield {
|
||||
type: "tool-call",
|
||||
toolCallId: "tool-call-1",
|
||||
toolName: "read_file",
|
||||
input: { path: "test.ts" },
|
||||
}
|
||||
}
|
||||
|
||||
const mockUsage = Promise.resolve({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
const mockProviderMetadata = Promise.resolve({})
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
fullStream: mockFullStream(),
|
||||
usage: mockUsage,
|
||||
providerMetadata: mockProviderMetadata,
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages, {
|
||||
taskId: "test-task",
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "read_file",
|
||||
description: "Read a file",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { path: { type: "string" } },
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// tool-call events are ignored, so no tool_call chunks should be emitted
|
||||
const toolCallChunks = chunks.filter((c) => c.type === "tool_call")
|
||||
expect(toolCallChunks.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getMaxOutputTokens", () => {
|
||||
it("should return maxTokens from model info", () => {
|
||||
class TestDeepSeekHandler extends DeepSeekHandler {
|
||||
public testGetMaxOutputTokens() {
|
||||
return this.getMaxOutputTokens()
|
||||
}
|
||||
}
|
||||
|
||||
const testHandler = new TestDeepSeekHandler(mockOptions)
|
||||
const result = testHandler.testGetMaxOutputTokens()
|
||||
|
||||
// Default model maxTokens is 8192
|
||||
expect(result).toBe(8192)
|
||||
})
|
||||
|
||||
it("should use modelMaxTokens when provided", () => {
|
||||
class TestDeepSeekHandler extends DeepSeekHandler {
|
||||
public testGetMaxOutputTokens() {
|
||||
return this.getMaxOutputTokens()
|
||||
}
|
||||
}
|
||||
|
||||
const customMaxTokens = 5000
|
||||
const testHandler = new TestDeepSeekHandler({
|
||||
...mockOptions,
|
||||
modelMaxTokens: customMaxTokens,
|
||||
})
|
||||
|
||||
const result = testHandler.testGetMaxOutputTokens()
|
||||
expect(result).toBe(customMaxTokens)
|
||||
})
|
||||
|
||||
it("should fall back to modelInfo.maxTokens when modelMaxTokens is not provided", () => {
|
||||
class TestDeepSeekHandler extends DeepSeekHandler {
|
||||
public testGetMaxOutputTokens() {
|
||||
return this.getMaxOutputTokens()
|
||||
}
|
||||
}
|
||||
|
||||
const testHandler = new TestDeepSeekHandler(mockOptions)
|
||||
const result = testHandler.testGetMaxOutputTokens()
|
||||
|
||||
// deepseek-chat has maxTokens of 8192
|
||||
expect(result).toBe(8192)
|
||||
})
|
||||
})
|
||||
|
||||
describe("mapToolChoice", () => {
|
||||
it("should handle string tool choices", () => {
|
||||
class TestDeepSeekHandler extends DeepSeekHandler {
|
||||
public testMapToolChoice(toolChoice: any) {
|
||||
return this.mapToolChoice(toolChoice)
|
||||
}
|
||||
}
|
||||
|
||||
const testHandler = new TestDeepSeekHandler(mockOptions)
|
||||
|
||||
expect(testHandler.testMapToolChoice("auto")).toBe("auto")
|
||||
expect(testHandler.testMapToolChoice("none")).toBe("none")
|
||||
expect(testHandler.testMapToolChoice("required")).toBe("required")
|
||||
expect(testHandler.testMapToolChoice("unknown")).toBe("auto")
|
||||
})
|
||||
|
||||
it("should handle object tool choice with function name", () => {
|
||||
class TestDeepSeekHandler extends DeepSeekHandler {
|
||||
public testMapToolChoice(toolChoice: any) {
|
||||
return this.mapToolChoice(toolChoice)
|
||||
}
|
||||
}
|
||||
|
||||
const testHandler = new TestDeepSeekHandler(mockOptions)
|
||||
|
||||
const result = testHandler.testMapToolChoice({
|
||||
type: "function",
|
||||
function: { name: "my_tool" },
|
||||
})
|
||||
|
||||
expect(result).toEqual({ type: "tool", toolName: "my_tool" })
|
||||
})
|
||||
|
||||
it("should return undefined for null or undefined", () => {
|
||||
class TestDeepSeekHandler extends DeepSeekHandler {
|
||||
public testMapToolChoice(toolChoice: any) {
|
||||
return this.mapToolChoice(toolChoice)
|
||||
}
|
||||
}
|
||||
|
||||
const testHandler = new TestDeepSeekHandler(mockOptions)
|
||||
|
||||
expect(testHandler.testMapToolChoice(null)).toBeUndefined()
|
||||
expect(testHandler.testMapToolChoice(undefined)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -419,7 +419,10 @@ describe("MoonshotHandler", () => {
|
|||
expect(toolCallEndChunks[0].id).toBe("tool-call-1")
|
||||
})
|
||||
|
||||
it("should handle complete tool calls", async () => {
|
||||
it("should ignore tool-call events to prevent duplicate tools in UI", async () => {
|
||||
// tool-call events are intentionally ignored because tool-input-start/delta/end
|
||||
// already provide complete tool call information. Emitting tool-call would cause
|
||||
// duplicate tools in the UI for AI SDK providers (e.g., DeepSeek, Moonshot).
|
||||
async function* mockFullStream() {
|
||||
yield {
|
||||
type: "tool-call",
|
||||
|
|
@ -464,11 +467,9 @@ describe("MoonshotHandler", () => {
|
|||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// tool-call events are ignored, so no tool_call chunks should be emitted
|
||||
const toolCallChunks = chunks.filter((c) => c.type === "tool_call")
|
||||
expect(toolCallChunks.length).toBe(1)
|
||||
expect(toolCallChunks[0].id).toBe("tool-call-1")
|
||||
expect(toolCallChunks[0].name).toBe("read_file")
|
||||
expect(toolCallChunks[0].arguments).toBe('{"path":"test.ts"}')
|
||||
expect(toolCallChunks.length).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,150 +1,192 @@
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { createDeepSeek } from "@ai-sdk/deepseek"
|
||||
import { streamText, generateText, ToolSet } from "ai"
|
||||
|
||||
import {
|
||||
deepSeekModels,
|
||||
deepSeekDefaultModelId,
|
||||
DEEP_SEEK_DEFAULT_TEMPERATURE,
|
||||
OPENAI_AZURE_AI_INFERENCE_PATH,
|
||||
} from "@roo-code/types"
|
||||
import { deepSeekModels, deepSeekDefaultModelId, DEEP_SEEK_DEFAULT_TEMPERATURE, type ModelInfo } from "@roo-code/types"
|
||||
|
||||
import type { ApiHandlerOptions } from "../../shared/api"
|
||||
|
||||
import { convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart } from "../transform/ai-sdk"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
|
||||
import { getModelParams } from "../transform/model-params"
|
||||
import { convertToR1Format } from "../transform/r1-format"
|
||||
|
||||
import { OpenAiHandler } from "./openai"
|
||||
import type { ApiHandlerCreateMessageMetadata } from "../index"
|
||||
import { DEFAULT_HEADERS } from "./constants"
|
||||
import { BaseProvider } from "./base-provider"
|
||||
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
|
||||
|
||||
// Custom interface for DeepSeek params to support thinking mode
|
||||
type DeepSeekChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParamsStreaming & {
|
||||
thinking?: { type: "enabled" | "disabled" }
|
||||
}
|
||||
/**
|
||||
* DeepSeek provider using the dedicated @ai-sdk/deepseek package.
|
||||
* Provides native support for reasoning (deepseek-reasoner) and prompt caching.
|
||||
*/
|
||||
export class DeepSeekHandler extends BaseProvider implements SingleCompletionHandler {
|
||||
protected options: ApiHandlerOptions
|
||||
protected provider: ReturnType<typeof createDeepSeek>
|
||||
|
||||
export class DeepSeekHandler extends OpenAiHandler {
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
super({
|
||||
...options,
|
||||
openAiApiKey: options.deepSeekApiKey ?? "not-provided",
|
||||
openAiModelId: options.apiModelId ?? deepSeekDefaultModelId,
|
||||
openAiBaseUrl: options.deepSeekBaseUrl ?? "https://api.deepseek.com",
|
||||
openAiStreamingEnabled: true,
|
||||
includeMaxTokens: true,
|
||||
super()
|
||||
this.options = options
|
||||
|
||||
// Create the DeepSeek provider using AI SDK
|
||||
this.provider = createDeepSeek({
|
||||
baseURL: options.deepSeekBaseUrl ?? "https://api.deepseek.com/v1",
|
||||
apiKey: options.deepSeekApiKey ?? "not-provided",
|
||||
headers: DEFAULT_HEADERS,
|
||||
})
|
||||
}
|
||||
|
||||
override getModel() {
|
||||
override getModel(): { id: string; info: ModelInfo; maxTokens?: number; temperature?: number } {
|
||||
const id = this.options.apiModelId ?? deepSeekDefaultModelId
|
||||
const info = deepSeekModels[id as keyof typeof deepSeekModels] || deepSeekModels[deepSeekDefaultModelId]
|
||||
const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options })
|
||||
return { id, info, ...params }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the language model for the configured model ID.
|
||||
*/
|
||||
protected getLanguageModel() {
|
||||
const { id } = this.getModel()
|
||||
return this.provider(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Process usage metrics from the AI SDK response, including DeepSeek's cache metrics.
|
||||
* DeepSeek provides cache hit/miss info via providerMetadata.
|
||||
*/
|
||||
protected processUsageMetrics(
|
||||
usage: {
|
||||
inputTokens?: number
|
||||
outputTokens?: number
|
||||
details?: {
|
||||
cachedInputTokens?: number
|
||||
reasoningTokens?: number
|
||||
}
|
||||
},
|
||||
providerMetadata?: {
|
||||
deepseek?: {
|
||||
promptCacheHitTokens?: number
|
||||
promptCacheMissTokens?: number
|
||||
}
|
||||
},
|
||||
): ApiStreamUsageChunk {
|
||||
// Extract cache metrics from DeepSeek's providerMetadata
|
||||
const cacheReadTokens = providerMetadata?.deepseek?.promptCacheHitTokens ?? usage.details?.cachedInputTokens
|
||||
const cacheWriteTokens = providerMetadata?.deepseek?.promptCacheMissTokens
|
||||
|
||||
return {
|
||||
type: "usage",
|
||||
inputTokens: usage.inputTokens || 0,
|
||||
outputTokens: usage.outputTokens || 0,
|
||||
cacheReadTokens,
|
||||
cacheWriteTokens,
|
||||
reasoningTokens: usage.details?.reasoningTokens,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map OpenAI tool_choice to AI SDK toolChoice format.
|
||||
*/
|
||||
protected mapToolChoice(
|
||||
toolChoice: any,
|
||||
): "auto" | "none" | "required" | { type: "tool"; toolName: string } | undefined {
|
||||
if (!toolChoice) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Handle string values
|
||||
if (typeof toolChoice === "string") {
|
||||
switch (toolChoice) {
|
||||
case "auto":
|
||||
return "auto"
|
||||
case "none":
|
||||
return "none"
|
||||
case "required":
|
||||
return "required"
|
||||
default:
|
||||
return "auto"
|
||||
}
|
||||
}
|
||||
|
||||
// Handle object values (OpenAI ChatCompletionNamedToolChoice format)
|
||||
if (typeof toolChoice === "object" && "type" in toolChoice) {
|
||||
if (toolChoice.type === "function" && "function" in toolChoice && toolChoice.function?.name) {
|
||||
return { type: "tool", toolName: toolChoice.function.name }
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the max tokens parameter to include in the request.
|
||||
*/
|
||||
protected getMaxOutputTokens(): number | undefined {
|
||||
const { info } = this.getModel()
|
||||
return this.options.modelMaxTokens || info.maxTokens || undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a message stream using the AI SDK.
|
||||
* The AI SDK automatically handles reasoning for deepseek-reasoner model.
|
||||
*/
|
||||
override async *createMessage(
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): ApiStream {
|
||||
const modelId = this.options.apiModelId ?? deepSeekDefaultModelId
|
||||
const { info: modelInfo } = this.getModel()
|
||||
const { temperature } = this.getModel()
|
||||
const languageModel = this.getLanguageModel()
|
||||
|
||||
// Check if this is a thinking-enabled model (deepseek-reasoner)
|
||||
const isThinkingModel = modelId.includes("deepseek-reasoner")
|
||||
// Convert messages to AI SDK format
|
||||
const aiSdkMessages = convertToAiSdkMessages(messages)
|
||||
|
||||
// Convert messages to R1 format (merges consecutive same-role messages)
|
||||
// This is required for DeepSeek which does not support successive messages with the same role
|
||||
// For thinking models (deepseek-reasoner), enable mergeToolResultText to preserve reasoning_content
|
||||
// during tool call sequences. Without this, environment_details text after tool_results would
|
||||
// create user messages that cause DeepSeek to drop all previous reasoning_content.
|
||||
// See: https://api-docs.deepseek.com/guides/thinking_mode
|
||||
const convertedMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages], {
|
||||
mergeToolResultText: isThinkingModel,
|
||||
// Convert tools to OpenAI format first, then to AI SDK format
|
||||
const openAiTools = this.convertToolsForOpenAI(metadata?.tools)
|
||||
const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined
|
||||
|
||||
// Build the request options
|
||||
const requestOptions: Parameters<typeof streamText>[0] = {
|
||||
model: languageModel,
|
||||
system: systemPrompt,
|
||||
messages: aiSdkMessages,
|
||||
temperature: this.options.modelTemperature ?? temperature ?? DEEP_SEEK_DEFAULT_TEMPERATURE,
|
||||
maxOutputTokens: this.getMaxOutputTokens(),
|
||||
tools: aiSdkTools,
|
||||
toolChoice: this.mapToolChoice(metadata?.tool_choice),
|
||||
}
|
||||
|
||||
// Use streamText for streaming responses
|
||||
const result = streamText(requestOptions)
|
||||
|
||||
// Process the full stream to get all events including reasoning
|
||||
for await (const part of result.fullStream) {
|
||||
for (const chunk of processAiSdkStreamPart(part)) {
|
||||
yield chunk
|
||||
}
|
||||
}
|
||||
|
||||
// Yield usage metrics at the end, including cache metrics from providerMetadata
|
||||
const usage = await result.usage
|
||||
const providerMetadata = await result.providerMetadata
|
||||
if (usage) {
|
||||
yield this.processUsageMetrics(usage, providerMetadata as any)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete a prompt using the AI SDK generateText.
|
||||
*/
|
||||
async completePrompt(prompt: string): Promise<string> {
|
||||
const { temperature } = this.getModel()
|
||||
const languageModel = this.getLanguageModel()
|
||||
|
||||
const { text } = await generateText({
|
||||
model: languageModel,
|
||||
prompt,
|
||||
maxOutputTokens: this.getMaxOutputTokens(),
|
||||
temperature: this.options.modelTemperature ?? temperature ?? DEEP_SEEK_DEFAULT_TEMPERATURE,
|
||||
})
|
||||
|
||||
const requestOptions: DeepSeekChatCompletionParams = {
|
||||
model: modelId,
|
||||
temperature: this.options.modelTemperature ?? DEEP_SEEK_DEFAULT_TEMPERATURE,
|
||||
messages: convertedMessages,
|
||||
stream: true as const,
|
||||
stream_options: { include_usage: true },
|
||||
// Enable thinking mode for deepseek-reasoner or when tools are used with thinking model
|
||||
...(isThinkingModel && { thinking: { type: "enabled" } }),
|
||||
tools: this.convertToolsForOpenAI(metadata?.tools),
|
||||
tool_choice: metadata?.tool_choice,
|
||||
parallel_tool_calls: metadata?.parallelToolCalls ?? true,
|
||||
}
|
||||
|
||||
// Add max_tokens if needed
|
||||
this.addMaxTokensIfNeeded(requestOptions, modelInfo)
|
||||
|
||||
// Check if base URL is Azure AI Inference (for DeepSeek via Azure)
|
||||
const isAzureAiInference = this._isAzureAiInference(this.options.deepSeekBaseUrl)
|
||||
|
||||
let stream
|
||||
try {
|
||||
stream = await this.client.chat.completions.create(
|
||||
requestOptions,
|
||||
isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {},
|
||||
)
|
||||
} catch (error) {
|
||||
const { handleOpenAIError } = await import("./utils/openai-error-handler")
|
||||
throw handleOpenAIError(error, "DeepSeek")
|
||||
}
|
||||
|
||||
let lastUsage
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices?.[0]?.delta ?? {}
|
||||
|
||||
// Handle regular text content
|
||||
if (delta.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle reasoning_content from DeepSeek's interleaved thinking
|
||||
// This is the proper way DeepSeek sends thinking content in streaming
|
||||
if ("reasoning_content" in delta && delta.reasoning_content) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
text: (delta.reasoning_content as string) || "",
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tool calls
|
||||
if (delta.tool_calls) {
|
||||
for (const toolCall of delta.tool_calls) {
|
||||
yield {
|
||||
type: "tool_call_partial",
|
||||
index: toolCall.index,
|
||||
id: toolCall.id,
|
||||
name: toolCall.function?.name,
|
||||
arguments: toolCall.function?.arguments,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
lastUsage = chunk.usage
|
||||
}
|
||||
}
|
||||
|
||||
if (lastUsage) {
|
||||
yield this.processUsageMetrics(lastUsage, modelInfo)
|
||||
}
|
||||
}
|
||||
|
||||
// Override to handle DeepSeek's usage metrics, including caching.
|
||||
protected override processUsageMetrics(usage: any, _modelInfo?: any): ApiStreamUsageChunk {
|
||||
return {
|
||||
type: "usage",
|
||||
inputTokens: usage?.prompt_tokens || 0,
|
||||
outputTokens: usage?.completion_tokens || 0,
|
||||
cacheWriteTokens: usage?.prompt_tokens_details?.cache_miss_tokens,
|
||||
cacheReadTokens: usage?.prompt_tokens_details?.cached_tokens,
|
||||
}
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -419,7 +419,10 @@ describe("AI SDK conversion utilities", () => {
|
|||
expect(chunks[0]).toEqual({ type: "tool_call_end", id: "call_1" })
|
||||
})
|
||||
|
||||
it("processes complete tool-call chunks", () => {
|
||||
it("ignores tool-call chunks to prevent duplicate tools in UI", () => {
|
||||
// tool-call is intentionally ignored because tool-input-start/delta/end already
|
||||
// provide complete tool call information. Emitting tool-call would cause duplicate
|
||||
// tools in the UI for AI SDK providers (e.g., DeepSeek, Moonshot).
|
||||
const part = {
|
||||
type: "tool-call" as const,
|
||||
toolCallId: "call_1",
|
||||
|
|
@ -428,13 +431,7 @@ describe("AI SDK conversion utilities", () => {
|
|||
}
|
||||
const chunks = [...processAiSdkStreamPart(part)]
|
||||
|
||||
expect(chunks).toHaveLength(1)
|
||||
expect(chunks[0]).toEqual({
|
||||
type: "tool_call",
|
||||
id: "call_1",
|
||||
name: "read_file",
|
||||
arguments: '{"path":"test.ts"}',
|
||||
})
|
||||
expect(chunks).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("processes source chunks with URL", () => {
|
||||
|
|
|
|||
|
|
@ -228,16 +228,6 @@ export function* processAiSdkStreamPart(part: ExtendedStreamPart): Generator<Api
|
|||
}
|
||||
break
|
||||
|
||||
case "tool-call":
|
||||
// Complete tool call - emit for compatibility
|
||||
yield {
|
||||
type: "tool_call",
|
||||
id: part.toolCallId,
|
||||
name: part.toolName,
|
||||
arguments: typeof part.input === "string" ? part.input : JSON.stringify(part.input),
|
||||
}
|
||||
break
|
||||
|
||||
case "source":
|
||||
// Handle both URL and document source types
|
||||
if ("url" in part) {
|
||||
|
|
@ -262,7 +252,10 @@ export function* processAiSdkStreamPart(part: ExtendedStreamPart): Generator<Api
|
|||
}
|
||||
break
|
||||
|
||||
// Ignore lifecycle events that don't need to yield chunks
|
||||
// Ignore lifecycle events that don't need to yield chunks.
|
||||
// Note: tool-call is intentionally ignored because tool-input-start/delta/end already
|
||||
// provide complete tool call information. Emitting tool-call would cause duplicate
|
||||
// tools in the UI for AI SDK providers (e.g., DeepSeek, Moonshot).
|
||||
case "text-start":
|
||||
case "text-end":
|
||||
case "reasoning-start":
|
||||
|
|
@ -275,8 +268,8 @@ export function* processAiSdkStreamPart(part: ExtendedStreamPart): Generator<Api
|
|||
case "file":
|
||||
case "tool-result":
|
||||
case "tool-error":
|
||||
case "tool-call":
|
||||
case "raw":
|
||||
// These events don't need to be yielded
|
||||
break
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -390,6 +390,127 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
this.userMessageContent.push(toolResult)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a tool call streaming event (tool_call_start, tool_call_delta, or tool_call_end).
|
||||
* This is used both for processing events from NativeToolCallParser (legacy providers)
|
||||
* and for direct AI SDK events (DeepSeek, Moonshot, etc.).
|
||||
*
|
||||
* @param event - The tool call event to process
|
||||
*/
|
||||
private handleToolCallEvent(
|
||||
event:
|
||||
| { type: "tool_call_start"; id: string; name: string }
|
||||
| { type: "tool_call_delta"; id: string; delta: string }
|
||||
| { type: "tool_call_end"; id: string },
|
||||
): void {
|
||||
if (event.type === "tool_call_start") {
|
||||
// Guard against duplicate tool_call_start events for the same tool ID.
|
||||
// This can occur due to stream retry, reconnection, or API quirks.
|
||||
// Without this check, duplicate tool_use blocks with the same ID would
|
||||
// be added to assistantMessageContent, causing API 400 errors:
|
||||
// "tool_use ids must be unique"
|
||||
if (this.streamingToolCallIndices.has(event.id)) {
|
||||
console.warn(
|
||||
`[Task#${this.taskId}] Ignoring duplicate tool_call_start for ID: ${event.id} (tool: ${event.name})`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize streaming in NativeToolCallParser
|
||||
NativeToolCallParser.startStreamingToolCall(event.id, event.name as ToolName)
|
||||
|
||||
// Before adding a new tool, finalize any preceding text block
|
||||
// This prevents the text block from blocking tool presentation
|
||||
const lastBlock = this.assistantMessageContent[this.assistantMessageContent.length - 1]
|
||||
if (lastBlock?.type === "text" && lastBlock.partial) {
|
||||
lastBlock.partial = false
|
||||
}
|
||||
|
||||
// Track the index where this tool will be stored
|
||||
const toolUseIndex = this.assistantMessageContent.length
|
||||
this.streamingToolCallIndices.set(event.id, toolUseIndex)
|
||||
|
||||
// Create initial partial tool use
|
||||
const partialToolUse: ToolUse = {
|
||||
type: "tool_use",
|
||||
name: event.name as ToolName,
|
||||
params: {},
|
||||
partial: true,
|
||||
}
|
||||
|
||||
// Store the ID for native protocol
|
||||
;(partialToolUse as any).id = event.id
|
||||
|
||||
// Add to content and present
|
||||
this.assistantMessageContent.push(partialToolUse)
|
||||
this.userMessageContentReady = false
|
||||
presentAssistantMessage(this)
|
||||
} else if (event.type === "tool_call_delta") {
|
||||
// Process chunk using streaming JSON parser
|
||||
const partialToolUse = NativeToolCallParser.processStreamingChunk(event.id, event.delta)
|
||||
|
||||
if (partialToolUse) {
|
||||
// Get the index for this tool call
|
||||
const toolUseIndex = this.streamingToolCallIndices.get(event.id)
|
||||
if (toolUseIndex !== undefined) {
|
||||
// Store the ID for native protocol
|
||||
;(partialToolUse as any).id = event.id
|
||||
|
||||
// Update the existing tool use with new partial data
|
||||
this.assistantMessageContent[toolUseIndex] = partialToolUse
|
||||
|
||||
// Present updated tool use
|
||||
presentAssistantMessage(this)
|
||||
}
|
||||
}
|
||||
} else if (event.type === "tool_call_end") {
|
||||
// Finalize the streaming tool call
|
||||
const finalToolUse = NativeToolCallParser.finalizeStreamingToolCall(event.id)
|
||||
|
||||
// Get the index for this tool call
|
||||
const toolUseIndex = this.streamingToolCallIndices.get(event.id)
|
||||
|
||||
if (finalToolUse) {
|
||||
// Store the tool call ID
|
||||
;(finalToolUse as any).id = event.id
|
||||
|
||||
// Get the index and replace partial with final
|
||||
if (toolUseIndex !== undefined) {
|
||||
this.assistantMessageContent[toolUseIndex] = finalToolUse
|
||||
}
|
||||
|
||||
// Clean up tracking
|
||||
this.streamingToolCallIndices.delete(event.id)
|
||||
|
||||
// Mark that we have new content to process
|
||||
this.userMessageContentReady = false
|
||||
|
||||
// Present the finalized tool call
|
||||
presentAssistantMessage(this)
|
||||
} else if (toolUseIndex !== undefined) {
|
||||
// finalizeStreamingToolCall returned null (malformed JSON or missing args)
|
||||
// Mark the tool as non-partial so it's presented as complete, but execution
|
||||
// will be short-circuited in presentAssistantMessage with a structured tool_result.
|
||||
const existingToolUse = this.assistantMessageContent[toolUseIndex]
|
||||
if (existingToolUse && existingToolUse.type === "tool_use") {
|
||||
existingToolUse.partial = false
|
||||
// Ensure it has the ID for native protocol
|
||||
;(existingToolUse as any).id = event.id
|
||||
}
|
||||
|
||||
// Clean up tracking
|
||||
this.streamingToolCallIndices.delete(event.id)
|
||||
|
||||
// Mark that we have new content to process
|
||||
this.userMessageContentReady = false
|
||||
|
||||
// Present the tool call - validation will handle missing params
|
||||
presentAssistantMessage(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
didRejectTool = false
|
||||
didAlreadyUseTool = false
|
||||
didToolFailInCurrentTurn = false
|
||||
|
|
@ -2859,119 +2980,19 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
})
|
||||
|
||||
for (const event of events) {
|
||||
if (event.type === "tool_call_start") {
|
||||
// Guard against duplicate tool_call_start events for the same tool ID.
|
||||
// This can occur due to stream retry, reconnection, or API quirks.
|
||||
// Without this check, duplicate tool_use blocks with the same ID would
|
||||
// be added to assistantMessageContent, causing API 400 errors:
|
||||
// "tool_use ids must be unique"
|
||||
if (this.streamingToolCallIndices.has(event.id)) {
|
||||
console.warn(
|
||||
`[Task#${this.taskId}] Ignoring duplicate tool_call_start for ID: ${event.id} (tool: ${event.name})`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Initialize streaming in NativeToolCallParser
|
||||
NativeToolCallParser.startStreamingToolCall(event.id, event.name as ToolName)
|
||||
|
||||
// Before adding a new tool, finalize any preceding text block
|
||||
// This prevents the text block from blocking tool presentation
|
||||
const lastBlock =
|
||||
this.assistantMessageContent[this.assistantMessageContent.length - 1]
|
||||
if (lastBlock?.type === "text" && lastBlock.partial) {
|
||||
lastBlock.partial = false
|
||||
}
|
||||
|
||||
// Track the index where this tool will be stored
|
||||
const toolUseIndex = this.assistantMessageContent.length
|
||||
this.streamingToolCallIndices.set(event.id, toolUseIndex)
|
||||
|
||||
// Create initial partial tool use
|
||||
const partialToolUse: ToolUse = {
|
||||
type: "tool_use",
|
||||
name: event.name as ToolName,
|
||||
params: {},
|
||||
partial: true,
|
||||
}
|
||||
|
||||
// Store the ID for native protocol
|
||||
;(partialToolUse as any).id = event.id
|
||||
|
||||
// Add to content and present
|
||||
this.assistantMessageContent.push(partialToolUse)
|
||||
this.userMessageContentReady = false
|
||||
presentAssistantMessage(this)
|
||||
} else if (event.type === "tool_call_delta") {
|
||||
// Process chunk using streaming JSON parser
|
||||
const partialToolUse = NativeToolCallParser.processStreamingChunk(
|
||||
event.id,
|
||||
event.delta,
|
||||
)
|
||||
|
||||
if (partialToolUse) {
|
||||
// Get the index for this tool call
|
||||
const toolUseIndex = this.streamingToolCallIndices.get(event.id)
|
||||
if (toolUseIndex !== undefined) {
|
||||
// Store the ID for native protocol
|
||||
;(partialToolUse as any).id = event.id
|
||||
|
||||
// Update the existing tool use with new partial data
|
||||
this.assistantMessageContent[toolUseIndex] = partialToolUse
|
||||
|
||||
// Present updated tool use
|
||||
presentAssistantMessage(this)
|
||||
}
|
||||
}
|
||||
} else if (event.type === "tool_call_end") {
|
||||
// Finalize the streaming tool call
|
||||
const finalToolUse = NativeToolCallParser.finalizeStreamingToolCall(event.id)
|
||||
|
||||
// Get the index for this tool call
|
||||
const toolUseIndex = this.streamingToolCallIndices.get(event.id)
|
||||
|
||||
if (finalToolUse) {
|
||||
// Store the tool call ID
|
||||
;(finalToolUse as any).id = event.id
|
||||
|
||||
// Get the index and replace partial with final
|
||||
if (toolUseIndex !== undefined) {
|
||||
this.assistantMessageContent[toolUseIndex] = finalToolUse
|
||||
}
|
||||
|
||||
// Clean up tracking
|
||||
this.streamingToolCallIndices.delete(event.id)
|
||||
|
||||
// Mark that we have new content to process
|
||||
this.userMessageContentReady = false
|
||||
|
||||
// Present the finalized tool call
|
||||
presentAssistantMessage(this)
|
||||
} else if (toolUseIndex !== undefined) {
|
||||
// finalizeStreamingToolCall returned null (malformed JSON or missing args)
|
||||
// Mark the tool as non-partial so it's presented as complete, but execution
|
||||
// will be short-circuited in presentAssistantMessage with a structured tool_result.
|
||||
const existingToolUse = this.assistantMessageContent[toolUseIndex]
|
||||
if (existingToolUse && existingToolUse.type === "tool_use") {
|
||||
existingToolUse.partial = false
|
||||
// Ensure it has the ID for native protocol
|
||||
;(existingToolUse as any).id = event.id
|
||||
}
|
||||
|
||||
// Clean up tracking
|
||||
this.streamingToolCallIndices.delete(event.id)
|
||||
|
||||
// Mark that we have new content to process
|
||||
this.userMessageContentReady = false
|
||||
|
||||
// Present the tool call - validation will handle missing params
|
||||
presentAssistantMessage(this)
|
||||
}
|
||||
}
|
||||
this.handleToolCallEvent(event)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Direct handlers for AI SDK tool streaming events (DeepSeek, Moonshot, etc.)
|
||||
// These providers emit tool_call_start/delta/end directly instead of tool_call_partial
|
||||
case "tool_call_start":
|
||||
case "tool_call_delta":
|
||||
case "tool_call_end":
|
||||
this.handleToolCallEvent(chunk)
|
||||
break
|
||||
|
||||
case "tool_call": {
|
||||
// Legacy: Handle complete tool calls (for backward compatibility)
|
||||
// Convert native tool call to ToolUse format
|
||||
|
|
|
|||
|
|
@ -450,6 +450,7 @@
|
|||
"clean": "rimraf README.md CHANGELOG.md LICENSE dist logs mock .turbo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/deepseek": "^2.0.14",
|
||||
"@anthropic-ai/bedrock-sdk": "^0.10.2",
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.7.0",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue