From 929b838e0fb1dea5261bd8fe407b3d94eeb543ef Mon Sep 17 00:00:00 2001 From: Yaosanqi137 Date: Tue, 7 Apr 2026 23:20:50 +0800 Subject: [PATCH] fix(api-ai): support responses style openai payloads --- .../providers/openai-compatible.provider.ts | 95 ++++++++++++++++--- .../test/openai-compatible-provider.spec.ts | 80 ++++++++++++++++ 2 files changed, 161 insertions(+), 14 deletions(-) create mode 100644 apps/api/test/openai-compatible-provider.spec.ts diff --git a/apps/api/src/ai/providers/openai-compatible.provider.ts b/apps/api/src/ai/providers/openai-compatible.provider.ts index 0c52099..1ba4eff 100644 --- a/apps/api/src/ai/providers/openai-compatible.provider.ts +++ b/apps/api/src/ai/providers/openai-compatible.provider.ts @@ -122,6 +122,20 @@ export class OpenAiCompatibleProvider implements AiChannelExecutor { } private extractAssistantText(payload: unknown): string { + const chatCompletionText = this.extractChatCompletionText(payload); + if (chatCompletionText) { + return chatCompletionText; + } + + const responsesText = this.extractResponsesApiText(payload); + if (responsesText) { + return responsesText; + } + + return ""; + } + + private extractChatCompletionText(payload: unknown): string { if (!this.isRecord(payload)) { return ""; } @@ -137,11 +151,49 @@ export class OpenAiCompatibleProvider implements AiChannelExecutor { } const message = firstChoice["message"]; - if (!this.isRecord(message)) { + if (this.isRecord(message)) { + const messageContent = this.extractMessageContent(message["content"]); + if (messageContent) { + return messageContent; + } + } + + if (typeof firstChoice["text"] === "string") { + return firstChoice["text"]; + } + + return ""; + } + + private extractResponsesApiText(payload: unknown): string { + if (!this.isRecord(payload)) { return ""; } - return this.extractMessageContent(message["content"]); + if (typeof payload["output_text"] === "string") { + return payload["output_text"]; + } + + const output = payload["output"]; + if (!Array.isArray(output)) { + return ""; + } + + return output + .map((item) => { + if (!this.isRecord(item)) { + return ""; + } + + if (typeof item["text"] === "string") { + return item["text"]; + } + + return this.extractMessageContent(item["content"]); + }) + .filter((item) => item.length > 0) + .join("\n") + .trim(); } private extractMessageContent(content: unknown): string { @@ -154,19 +206,34 @@ export class OpenAiCompatibleProvider implements AiChannelExecutor { } return content - .map((item) => { - if (!this.isRecord(item)) { - return ""; - } - - if (typeof item["text"] === "string") { - return item["text"]; - } - - return ""; - }) + .map((item) => this.extractContentPartText(item)) .filter((item) => item.length > 0) - .join("\n"); + .join("\n") + .trim(); + } + + private extractContentPartText(item: unknown): string { + if (!this.isRecord(item)) { + return ""; + } + + if (typeof item["text"] === "string") { + return item["text"]; + } + + if (this.isRecord(item["text"]) && typeof item["text"]["value"] === "string") { + return item["text"]["value"]; + } + + if (typeof item["content"] === "string") { + return item["content"]; + } + + if (this.isRecord(item["content"]) && typeof item["content"]["text"] === "string") { + return item["content"]["text"]; + } + + return ""; } private extractModel(payload: unknown): string | null { diff --git a/apps/api/test/openai-compatible-provider.spec.ts b/apps/api/test/openai-compatible-provider.spec.ts new file mode 100644 index 0000000..7654669 --- /dev/null +++ b/apps/api/test/openai-compatible-provider.spec.ts @@ -0,0 +1,80 @@ +import { AiChannel } from "../generated/prisma/client"; +import { OpenAiCompatibleProvider } from "../src/ai/providers/openai-compatible.provider"; + +describe("OpenAiCompatibleProvider", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + it("should read text from responses style payload when chat content is empty", async () => { + const provider = new OpenAiCompatibleProvider(); + const fetchMock = jest.fn(async (_input: unknown, init?: RequestInit) => { + expect(init?.method).toBe("POST"); + + return new Response( + JSON.stringify({ + id: "resp_123", + object: "response", + model: "gpt-5.4", + output: [ + { + id: "msg_123", + type: "message", + role: "assistant", + content: [ + { + type: "output_text", + text: "今天优先先完成截止时间最近的任务。" + } + ] + } + ], + usage: { + prompt_tokens: 15, + completion_tokens: 9, + total_tokens: 24 + } + }), + { + status: 200, + headers: { + "content-type": "application/json" + } + } + ); + }); + + global.fetch = fetchMock as typeof global.fetch; + + const result = await provider.execute( + { + channel: AiChannel.USER_KEY, + source: "binding", + sourceId: "binding_user_key_1", + providerName: "airouter", + model: "gpt-5.4", + configId: null, + configName: null, + endpoint: "https://api.airouter.io/v1", + apiKey: "sk_test" + }, + { + userId: "user_1", + message: "帮我安排今天的任务", + sessionId: null + } + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result.content).toBe("今天优先先完成截止时间最近的任务。"); + expect(result.model).toBe("gpt-5.4"); + expect(result.usage).toEqual({ + promptTokens: 15, + completionTokens: 9, + totalTokens: 24 + }); + }); +});