diff --git a/.changeset/fix-deepseek-tool-message.md b/.changeset/fix-deepseek-tool-message.md new file mode 100644 index 0000000..69e6496 --- /dev/null +++ b/.changeset/fix-deepseek-tool-message.md @@ -0,0 +1,5 @@ +--- +"opencode-bridge-copilot-chat": patch +--- + +Serialize tool-only Chat Completions messages with provider-compatible empty content instead of `null`. diff --git a/src/provider/content.test.ts b/src/provider/content.test.ts new file mode 100644 index 0000000..1b0007c --- /dev/null +++ b/src/provider/content.test.ts @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { chatContent } from "./content"; + +test("uses an empty string for textless Chat Completions content", () => { + assert.equal(chatContent("", []), ""); +}); + +test("preserves text and image content parts", () => { + const image = { type: "image_url" as const, image_url: { url: "data:image/png;base64,AA==" } }; + assert.deepEqual(chatContent("Describe this image", [image]), [ + { type: "text", text: "Describe this image" }, + image, + ]); +}); diff --git a/src/provider/content.ts b/src/provider/content.ts new file mode 100644 index 0000000..49bb28b --- /dev/null +++ b/src/provider/content.ts @@ -0,0 +1,13 @@ +export interface ContentPart { + type: "text" | "image_url"; + text?: string; + image_url?: { url: string }; +} + +export type ChatContent = string | ContentPart[]; + +export function chatContent(value: string, images: readonly ContentPart[]): ChatContent { + return images.length + ? [...(value ? [{ type: "text" as const, text: value }] : []), ...images] + : value; +} diff --git a/src/provider/messages.ts b/src/provider/messages.ts index 6ab5efd..c19b0b1 100644 --- a/src/provider/messages.ts +++ b/src/provider/messages.ts @@ -1,16 +1,13 @@ import * as vscode from "vscode"; +import { chatContent, type ChatContent, type ContentPart } from "./content"; -const MAX_INLINE_IMAGE_BYTES = 3_750_000; +export type { ChatContent, ContentPart } from "./content"; -export interface ContentPart { - type: "text" | "image_url"; - text?: string; - image_url?: { url: string }; -} +const MAX_INLINE_IMAGE_BYTES = 3_750_000; export interface ChatMessage { role: "user" | "assistant" | "tool"; - content: string | ContentPart[] | null; + content: ChatContent; tool_calls?: Array<{ id: string; type: "function"; function: { name: string; arguments: string } }>; tool_call_id?: string; reasoning_content?: string; @@ -47,9 +44,7 @@ export function convertChatMessages(messages: readonly vscode.LanguageModelChatR } } const value = text.join("\n"); - const content: string | ContentPart[] | null = images.length - ? [...(value ? [{ type: "text" as const, text: value }] : []), ...images] - : value || null; + const content = chatContent(value, images); const current: ChatMessage = { role, content, diff --git a/src/provider/request.test.ts b/src/provider/request.test.ts index 61415d9..e481eb6 100644 --- a/src/provider/request.test.ts +++ b/src/provider/request.test.ts @@ -66,6 +66,31 @@ test("builds a Chat Completions request with usage and optional tools", () => { }); }); +test("keeps tool-only assistant content provider-compatible", () => { + const chatModel = { ...model, endpoint: "chat-completions" as const }; + const body = buildRequestBody( + chatModel, + [{ + role: "assistant", + content: "", + tool_calls: [{ id: "call-1", type: "function", function: { name: "lookup", arguments: "{}" } }], + }], + [], + [], + [], + undefined, + 1_024, + "auto", + ); + + assert.deepEqual(body.messages, [{ + role: "assistant", + content: "", + tool_calls: [{ id: "call-1", type: "function", function: { name: "lookup", arguments: "{}" } }], + }]); + assert.equal(JSON.stringify(body).includes('"content":null'), false); +}); + test("extension request fields override catalog request options", () => { const body = buildRequestBody(model, [], [{ type: "message", role: "user", content: "Hello" }], [], [], undefined, 512, "auto"); const merged = mergeRequestBody({