mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
fix: handle empty responses from Docker Model Runner and similar APIs
- Add fallback to yield empty text chunk when no content is received - Prevents "The language model did not provide any assistant messages" error - Fixes issue with Docker Model Runner integration - Add tests for empty response handling Fixes #8226
This commit is contained in:
parent
0e1b23d09c
commit
f4873804ca
3 changed files with 273 additions and 0 deletions
246
src/api/providers/__tests__/base-openai-compatible.spec.ts
Normal file
246
src/api/providers/__tests__/base-openai-compatible.spec.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
|
||||
import { BaseOpenAiCompatibleProvider } from "../base-openai-compatible-provider"
|
||||
import { type ModelInfo } from "@roo-code/types"
|
||||
|
||||
// Create a concrete implementation for testing
|
||||
class TestOpenAiCompatibleProvider extends BaseOpenAiCompatibleProvider<"test-model"> {
|
||||
constructor(options: any) {
|
||||
super({
|
||||
providerName: "TestProvider",
|
||||
baseURL: "https://test.api.com/v1",
|
||||
defaultProviderModelId: "test-model",
|
||||
providerModels: {
|
||||
"test-model": {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 8192,
|
||||
supportsPromptCache: false,
|
||||
supportsComputerUse: false,
|
||||
supportsStreaming: true,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
} as ModelInfo,
|
||||
},
|
||||
...options,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe("BaseOpenAiCompatibleProvider", () => {
|
||||
let provider: TestOpenAiCompatibleProvider
|
||||
let mockCreate: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
mockCreate = vi.fn()
|
||||
vi.spyOn(OpenAI.Chat.Completions.prototype, "create").mockImplementation(mockCreate)
|
||||
})
|
||||
|
||||
describe("createMessage", () => {
|
||||
it("should handle empty response from API gracefully", async () => {
|
||||
provider = new TestOpenAiCompatibleProvider({
|
||||
apiKey: "test-key",
|
||||
})
|
||||
|
||||
// Mock an empty stream response (no content in chunks)
|
||||
const mockStream = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
// Yield chunks with no content
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
// No content field
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
// Yield usage information
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {},
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
mockCreate.mockResolvedValue(mockStream)
|
||||
|
||||
const systemPrompt = "You are a helpful assistant"
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: "Hello",
|
||||
},
|
||||
]
|
||||
|
||||
const stream = provider.createMessage(systemPrompt, messages)
|
||||
const chunks = []
|
||||
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Should have at least one text chunk (even if empty) and one usage chunk
|
||||
expect(chunks).toHaveLength(2)
|
||||
|
||||
// Should have a usage chunk
|
||||
const usageChunk = chunks.find((c) => c.type === "usage")
|
||||
expect(usageChunk).toEqual({
|
||||
type: "usage",
|
||||
inputTokens: 100,
|
||||
outputTokens: 0,
|
||||
})
|
||||
|
||||
// Should have an empty text chunk (added by our fix)
|
||||
const textChunk = chunks.find((c) => c.type === "text")
|
||||
expect(textChunk).toEqual({
|
||||
type: "text",
|
||||
text: "",
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle normal response with content", async () => {
|
||||
provider = new TestOpenAiCompatibleProvider({
|
||||
apiKey: "test-key",
|
||||
})
|
||||
|
||||
// Mock a normal stream response with content
|
||||
const mockStream = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
content: "Hello, ",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
content: "world!",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {},
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 10,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
mockCreate.mockResolvedValue(mockStream)
|
||||
|
||||
const systemPrompt = "You are a helpful assistant"
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: "Hello",
|
||||
},
|
||||
]
|
||||
|
||||
const stream = provider.createMessage(systemPrompt, messages)
|
||||
const chunks = []
|
||||
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Should have text chunks and usage chunk
|
||||
expect(chunks).toHaveLength(3)
|
||||
|
||||
// First two chunks should be text
|
||||
expect(chunks[0]).toEqual({
|
||||
type: "text",
|
||||
text: "Hello, ",
|
||||
})
|
||||
expect(chunks[1]).toEqual({
|
||||
type: "text",
|
||||
text: "world!",
|
||||
})
|
||||
|
||||
// Last chunk should be usage information
|
||||
expect(chunks[2]).toEqual({
|
||||
type: "usage",
|
||||
inputTokens: 100,
|
||||
outputTokens: 10,
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle response with only usage and no content", async () => {
|
||||
provider = new TestOpenAiCompatibleProvider({
|
||||
apiKey: "test-key",
|
||||
})
|
||||
|
||||
// Mock a stream response with only usage, no content
|
||||
const mockStream = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {},
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 50,
|
||||
completion_tokens: 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
mockCreate.mockResolvedValue(mockStream)
|
||||
|
||||
const systemPrompt = "You are a helpful assistant"
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: "Test",
|
||||
},
|
||||
]
|
||||
|
||||
const stream = provider.createMessage(systemPrompt, messages)
|
||||
const chunks = []
|
||||
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Should have empty text chunk and usage chunk
|
||||
expect(chunks).toHaveLength(2)
|
||||
|
||||
// Should have a usage chunk
|
||||
const usageChunk = chunks.find((c) => c.type === "usage")
|
||||
expect(usageChunk).toEqual({
|
||||
type: "usage",
|
||||
inputTokens: 50,
|
||||
outputTokens: 0,
|
||||
})
|
||||
|
||||
// Should have an empty text chunk (added by our fix)
|
||||
const textChunk = chunks.find((c) => c.type === "text")
|
||||
expect(textChunk).toEqual({
|
||||
type: "text",
|
||||
text: "",
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -98,11 +98,13 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
|
|||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): ApiStream {
|
||||
const stream = await this.createStream(systemPrompt, messages, metadata)
|
||||
let hasContent = false
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta
|
||||
|
||||
if (delta?.content) {
|
||||
hasContent = true
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
|
|
@ -117,6 +119,16 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no content was received, yield an empty text chunk to prevent
|
||||
// "The language model did not provide any assistant messages" error
|
||||
// This can happen with some Docker Model Runner configurations
|
||||
if (!hasContent) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: "",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async completePrompt(prompt: string): Promise<string> {
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan
|
|||
}
|
||||
|
||||
let assistantText = ""
|
||||
let hasContent = false
|
||||
|
||||
try {
|
||||
const params: OpenAI.Chat.ChatCompletionCreateParamsStreaming & { draft_model?: string } = {
|
||||
|
|
@ -113,6 +114,7 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan
|
|||
const delta = chunk.choices[0]?.delta
|
||||
|
||||
if (delta?.content) {
|
||||
hasContent = true
|
||||
assistantText += delta.content
|
||||
for (const processedChunk of matcher.update(delta.content)) {
|
||||
yield processedChunk
|
||||
|
|
@ -121,9 +123,22 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan
|
|||
}
|
||||
|
||||
for (const processedChunk of matcher.final()) {
|
||||
if (processedChunk.text) {
|
||||
hasContent = true
|
||||
}
|
||||
yield processedChunk
|
||||
}
|
||||
|
||||
// If no content was received, yield an empty text chunk to prevent
|
||||
// "The language model did not provide any assistant messages" error
|
||||
// This can happen with some model configurations
|
||||
if (!hasContent) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: "",
|
||||
}
|
||||
}
|
||||
|
||||
let outputTokens = 0
|
||||
try {
|
||||
outputTokens = await this.countTokens([{ type: "text", text: assistantText }])
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue