diff --git a/packages/commands/src/commands/text/chat.ts b/packages/commands/src/commands/text/chat.ts index 0beb55d9..66c37a1e 100644 --- a/packages/commands/src/commands/text/chat.ts +++ b/packages/commands/src/commands/text/chat.ts @@ -1,20 +1,35 @@ import { defineCommand, chatPath, + responsesPath, parseSSE, detectOutputFormat, readTextFromPathOrStdin, type ChatMessage, type ChatRequest, type ChatResponse, + type ResponsesRequest, + type ResponsesResponse, + type ResponsesStreamEvent, type StreamChunk, type FlagsDef, type ParsedFlags, } from "bailian-cli-core"; import { ansi, emitResult, emitBare } from "bailian-cli-runtime"; import { readFileSync } from "fs"; +import { + assertResponsesStreamCompleted, + inspectResponsesStreamEvent, + extractResponsesText, +} from "./responses.ts"; const CHAT_FLAGS = { + api: { + type: "string", + valueHint: "", + choices: ["chat", "responses"] as const, + description: "API to call (default: chat)", + }, model: { type: "string", valueHint: "", description: "Model ID (default: qwen3.8-max)" }, message: { type: "array", @@ -72,31 +87,31 @@ function parseMessages(flags: ChatFlags): ParsedMessages { if (flags.messagesFile) { const raw = readTextFromPathOrStdin(flags.messagesFile); const parsed = JSON.parse(raw) as Array<{ role: string; content: string }>; - for (const m of parsed) { - if (m.role === "system") { - system = typeof m.content === "string" ? m.content : ""; + for (const parsedMessage of parsed) { + if (parsedMessage.role === "system") { + system = typeof parsedMessage.content === "string" ? parsedMessage.content : ""; } else { - messages.push(m as ChatMessage); + messages.push(parsedMessage as ChatMessage); } } } if (flags.message) { const validRoles = new Set(["system", "user", "assistant"]); - const msgs = flags.message; - for (const m of msgs) { - const colonIdx = m.indexOf(":"); - const maybeRole = colonIdx !== -1 ? m.slice(0, colonIdx) : ""; + const messageValues = flags.message; + for (const messageValue of messageValues) { + const colonIndex = messageValue.indexOf(":"); + const maybeRole = colonIndex !== -1 ? messageValue.slice(0, colonIndex) : ""; if (validRoles.has(maybeRole)) { - const content = m.slice(colonIdx + 1); + const content = messageValue.slice(colonIndex + 1); if (maybeRole === "system") { system = content; } else { messages.push({ role: maybeRole as "user" | "assistant", content }); } } else { - messages.push({ role: "user", content: m }); + messages.push({ role: "user", content: messageValue }); } } } @@ -105,24 +120,33 @@ function parseMessages(flags: ChatFlags): ParsedMessages { } export default defineCommand({ - description: "Send a chat completion (OpenAI compatible, DashScope)", + description: "Send a text model request (OpenAI compatible, DashScope)", auth: "apiKey", usageArgs: "--message [flags]", flags: CHAT_FLAGS, exampleArgs: [ '--message "What is Qwen?"', + `--api responses --model qwen3.8-max --tool '{"type":"web_search"}' --message "Search for recent Alibaba Cloud news"`, '--model qwen-max --system "You are a coding assistant." --message "Write fizzbuzz in Python"', '--message "Hello" --message "assistant:Hi!" --message "How are you?"', "--messages-file - --stream", '--message "Hello" --output json', '--model qwq-plus --message "Solve 1+1" --enable-thinking', ], - validate: (f) => - !f.message && !f.messagesFile ? "Provide --message or --messages-file." : undefined, + validate: (flags) => { + if (!flags.message && !flags.messagesFile) { + return "Provide --message or --messages-file."; + } + if (flags.api === "responses" && flags.thinkingBudget !== undefined) { + return "--thinking-budget is not supported by the Responses API."; + } + return undefined; + }, async run(ctx) { const { settings, flags } = ctx; const { system, messages } = parseMessages(flags); + const api = flags.api ?? "chat"; const model = flags.model || settings.defaultTextModel || "qwen3.8-max"; const shouldStream = flags.stream || process.stdout.isTTY; const format = detectOutputFormat(settings.output); @@ -134,29 +158,39 @@ export default defineCommand({ } allMessages.push(...messages); - const body: ChatRequest = { - model, - messages: allMessages, - max_tokens: flags.maxTokens ?? 4096, - stream: shouldStream, - }; + let body: ChatRequest | ResponsesRequest; + if (api === "responses") { + body = { + model, + input: allMessages, + max_output_tokens: flags.maxTokens ?? 4096, + stream: shouldStream, + }; + } else { + body = { + model, + messages: allMessages, + max_tokens: flags.maxTokens ?? 4096, + stream: shouldStream, + }; + } if (flags.temperature !== undefined) body.temperature = flags.temperature; if (flags.topP !== undefined) body.top_p = flags.topP; if (flags.enableThinking) { body.enable_thinking = true; - if (flags.thinkingBudget !== undefined) { + if (api === "chat" && "messages" in body && flags.thinkingBudget !== undefined) { body.thinking_budget = flags.thinkingBudget; } } if (flags.tool) { - const tools = flags.tool.map((t) => { + const tools = flags.tool.map((toolValue) => { try { - return JSON.parse(t); + return JSON.parse(toolValue); } catch { - const raw = readFileSync(t, "utf-8"); + const raw = readFileSync(toolValue, "utf-8"); return JSON.parse(raw); } }); @@ -169,8 +203,8 @@ export default defineCommand({ } if (shouldStream) { - const res = await ctx.client.request({ - path: chatPath(), + const responseStream = await ctx.client.request({ + path: api === "responses" ? responsesPath() : chatPath(), method: "POST", body, stream: true, @@ -178,6 +212,7 @@ export default defineCommand({ let textContent = ""; let inThinking = false; + let responsesCompleted = false; const writesStreamingStdout = format === "text"; const isTTY = process.stdout.isTTY; const statusOut = @@ -185,8 +220,28 @@ export default defineCommand({ const resultOut = process.stdout; const statusColor = ansi(statusOut); - for await (const event of parseSSE(res)) { + for await (const event of parseSSE(responseStream)) { if (event.data === "[DONE]") break; + if (api === "responses") { + let parsedEvent: ResponsesStreamEvent; + try { + parsedEvent = JSON.parse(event.data) as ResponsesStreamEvent; + } catch { + continue; + } + + const update = inspectResponsesStreamEvent(parsedEvent); + if (update.delta) { + textContent += update.delta; + if (writesStreamingStdout) resultOut.write(update.delta); + } + if (update.completed) { + responsesCompleted = true; + break; + } + continue; + } + try { const parsed = JSON.parse(event.data) as StreamChunk; @@ -216,6 +271,7 @@ export default defineCommand({ // Skip unparseable chunks } } + if (api === "responses") assertResponsesStreamCompleted(responsesCompleted); if (inThinking) statusOut.write(statusColor.reset); if (format === "json") { @@ -223,6 +279,20 @@ export default defineCommand({ } else { resultOut.write("\n"); } + } else if (api === "responses") { + const response = await ctx.client.requestJson({ + path: responsesPath(), + method: "POST", + body, + }); + + const text = extractResponsesText(response); + + if (settings.quiet || format === "text") { + emitBare(text); + } else { + emitResult(response, format); + } } else { const response = await ctx.client.requestJson({ path: chatPath(), diff --git a/packages/commands/src/commands/text/responses.ts b/packages/commands/src/commands/text/responses.ts new file mode 100644 index 00000000..9f0c6212 --- /dev/null +++ b/packages/commands/src/commands/text/responses.ts @@ -0,0 +1,76 @@ +import { + BailianError, + ExitCode, + type ResponsesResponse, + type ResponsesStreamEvent, +} from "bailian-cli-core"; + +export interface ResponsesStreamUpdate { + delta: string; + completed: boolean; +} + +export function extractResponsesText(response: ResponsesResponse): string { + return response.output + .filter((outputItem) => outputItem.type === "message") + .flatMap((outputItem) => outputItem.content ?? []) + .filter((contentItem) => contentItem.type === "output_text") + .map((contentItem) => contentItem.text ?? "") + .join(""); +} + +export function extractResponsesStreamDelta(event: ResponsesStreamEvent): string { + return event.type === "response.output_text.delta" ? (event.delta ?? "") : ""; +} + +function asRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null + ? (value as Record) + : undefined; +} + +function stringProperty(record: Record | undefined, property: string) { + const value = record?.[property]; + return typeof value === "string" && value.trim() ? value : undefined; +} + +function responsesErrorMessage(event: ResponsesStreamEvent): string | undefined { + const response = asRecord(event.response); + const responseError = asRecord(response?.error); + const eventError = asRecord(event.error); + return ( + stringProperty(responseError, "message") ?? + stringProperty(eventError, "message") ?? + stringProperty(event, "message") + ); +} + +export function inspectResponsesStreamEvent(event: ResponsesStreamEvent): ResponsesStreamUpdate { + if (event.type === "response.failed" || event.type === "error") { + throw new BailianError(responsesErrorMessage(event) ?? "Response failed.", ExitCode.GENERAL); + } + + if (event.type === "response.incomplete") { + const response = asRecord(event.response); + const incompleteDetails = asRecord(response?.incomplete_details); + const reason = stringProperty(incompleteDetails, "reason"); + throw new BailianError( + responsesErrorMessage(event) ?? + (reason ? `Response incomplete: ${reason}` : "Response incomplete."), + ExitCode.GENERAL, + ); + } + + return { + delta: extractResponsesStreamDelta(event), + completed: event.type === "response.completed", + }; +} + +export function assertResponsesStreamCompleted(completed: boolean): void { + if (completed) return; + throw new BailianError( + "Stream disconnected before completion: stream closed before response.completed.", + ExitCode.GENERAL, + ); +} diff --git a/packages/commands/tests/e2e/text-chat.e2e.test.ts b/packages/commands/tests/e2e/text-chat.e2e.test.ts index 56f1d8fc..77761024 100644 --- a/packages/commands/tests/e2e/text-chat.e2e.test.ts +++ b/packages/commands/tests/e2e/text-chat.e2e.test.ts @@ -10,8 +10,37 @@ describe("e2e: text chat", () => { test("text chat --help 正常退出", async () => { const { stderr, exitCode } = await runCommandE2e(TEXT_CHAT_ROUTES, ["text", "chat", "--help"]); expect(exitCode, stderr).toBe(0); + expect(stderr).toMatch(/--api\s+/i); expect(stderr).toMatch(/chat|--message|model|stream/i); }); + + test("text chat 拒绝未知的 --api 值", async () => { + const { stderr, exitCode } = await runCommandE2e(TEXT_CHAT_ROUTES, [ + "text", + "chat", + "--api", + "legacy", + "--message", + "hello", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/--api|chat.*responses/i); + }); + + test("text chat 在 Responses 模式拒绝 --thinking-budget", async () => { + const { stderr, exitCode } = await runCommandE2e(TEXT_CHAT_ROUTES, [ + "text", + "chat", + "--api", + "responses", + "--message", + "hello", + "--thinking-budget", + "8", + ]); + expect(exitCode).toBe(2); + expect(stderr).toMatch(/thinking-budget.*Responses/i); + }); }); describe.skipIf(!isDashScopeE2EReady())("e2e: text chat(DashScope)", () => { @@ -45,7 +74,42 @@ describe.skipIf(!isDashScopeE2EReady())("e2e: text chat(DashScope)", () => { request?: { model?: string; messages?: Array<{ content?: string }> }; }>(stdout); expect(data.request?.model).toBe("qwen3.8-max"); - expect(data.request?.messages?.some((m) => m.content === "干跑")).toBe(true); + expect(data.request?.messages?.some((message) => message.content === "干跑")).toBe(true); + }); + + test("text chat --api responses --dry-run 生成 Responses 请求", async () => { + const { stdout, stderr, exitCode } = await runCommandE2e(TEXT_CHAT_ROUTES, [ + "text", + "chat", + "--dry-run", + "--api", + "responses", + "--model", + "qwen3.8-max", + "--system", + "system", + "--message", + "hello", + "--max-tokens", + "8", + "--tool", + '{"type":"web_search"}', + "--output", + "json", + ]); + expect(exitCode, stderr).toBe(0); + const data = parseStdoutJson<{ request?: Record }>(stdout); + expect(data.request).toMatchObject({ + model: "qwen3.8-max", + input: [ + { role: "system", content: "system" }, + { role: "user", content: "hello" }, + ], + max_output_tokens: 8, + tools: [{ type: "web_search" }], + }); + expect(data.request).not.toHaveProperty("messages"); + expect(data.request).not.toHaveProperty("max_tokens"); }); test("【qwen3.8-max】文本对话", async () => { diff --git a/packages/commands/tests/text-responses.test.ts b/packages/commands/tests/text-responses.test.ts new file mode 100644 index 00000000..ef5a5321 --- /dev/null +++ b/packages/commands/tests/text-responses.test.ts @@ -0,0 +1,138 @@ +import { BailianError, ExitCode } from "bailian-cli-core"; +import { afterEach, describe, expect, test, vi } from "vite-plus/test"; +import chatCommand from "../src/commands/text/chat.ts"; +import { + extractResponsesStreamDelta, + extractResponsesText, +} from "../src/commands/text/responses.ts"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +function responsesStreamResponse(events: unknown[]): Response { + const body = events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""); + return new Response(body, { headers: { "content-type": "text/event-stream" } }); +} + +async function runResponsesStream(events: unknown[]): Promise { + vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + try { + await chatCommand.run({ + settings: { output: "json" }, + flags: { + api: "responses", + message: ["hello"], + stream: true, + }, + client: { + request: async () => responsesStreamResponse(events), + }, + } as never); + return undefined; + } catch (error) { + expect(error).toBeInstanceOf(BailianError); + return error as BailianError; + } +} + +describe("Responses output", () => { + test("extracts only output_text from message items", () => { + const text = extractResponsesText({ + id: "resp_1", + object: "response", + status: "completed", + output: [ + { type: "reasoning", summary: [{ type: "summary_text", text: "thinking" }] }, + { type: "web_search_call", status: "completed" }, + { type: "message", content: [{ type: "output_text", text: "first" }] }, + { + type: "message", + content: [ + { type: "refusal", text: "ignored" }, + { type: "output_text", text: " second" }, + ], + }, + ], + }); + + expect(text).toBe("first second"); + }); + + test("extracts only response.output_text.delta stream events", () => { + expect(extractResponsesStreamDelta({ type: "response.output_text.delta", delta: "hi" })).toBe( + "hi", + ); + expect(extractResponsesStreamDelta({ type: "response.web_search_call.completed" })).toBe(""); + }); + + test("streaming response.completed finishes successfully", async () => { + const error = await runResponsesStream([ + { type: "response.output_text.delta", sequence_number: 1, delta: "done" }, + { + type: "response.completed", + sequence_number: 2, + response: { + id: "resp_completed", + object: "response", + status: "completed", + output: [], + error: null, + incomplete_details: null, + }, + }, + ]); + + expect(error).toBeUndefined(); + }); + + test("streaming response.failed throws the original service message", async () => { + const error = await runResponsesStream([ + { + type: "response.failed", + sequence_number: 1, + response: { + id: "resp_failed", + object: "response", + status: "failed", + output: [], + error: { code: "quota_exceeded", message: "provider quota exceeded" }, + }, + }, + ]); + + expect(error?.exitCode).toBe(ExitCode.GENERAL); + expect(error?.message).toBe("provider quota exceeded"); + }); + + test("streaming response.incomplete exits non-zero with the service reason", async () => { + const error = await runResponsesStream([ + { + type: "response.incomplete", + sequence_number: 1, + response: { + id: "resp_incomplete", + object: "response", + status: "incomplete", + output: [], + incomplete_details: { reason: "max_output_tokens" }, + }, + }, + ]); + + expect(error?.exitCode).toBe(ExitCode.GENERAL); + expect(error?.message).toBe("Response incomplete: max_output_tokens"); + }); + + test("stream ending before response.completed exits non-zero", async () => { + const error = await runResponsesStream([ + { type: "response.output_text.delta", sequence_number: 1, delta: "partial" }, + ]); + + expect(error?.exitCode).toBe(ExitCode.GENERAL); + expect(error?.message).toBe( + "Stream disconnected before completion: stream closed before response.completed.", + ); + }); +}); diff --git a/packages/core/src/client/endpoints.ts b/packages/core/src/client/endpoints.ts index 119c7bda..4991dfdb 100644 --- a/packages/core/src/client/endpoints.ts +++ b/packages/core/src/client/endpoints.ts @@ -6,6 +6,11 @@ export function chatPath(): string { return "/compatible-mode/v1/chat/completions"; } +// ---- Responses (OpenAI Compatible) ---- +export function responsesPath(): string { + return "/compatible-mode/v1/responses"; +} + // ---- Image Generation (DashScope) ---- /** Async image API used by wan2.6-t2i / wan2.6-image (T2I) and similar message-format models. */ export function imagePath(): string { diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index 9fa3d039..b85e7001 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -14,6 +14,7 @@ export { memorySearchPath, mcpWebSearchPath, profileSchemaPath, + responsesPath, speechRecognizePath, speechSynthesizePath, taskPath, diff --git a/packages/core/src/types/api.ts b/packages/core/src/types/api.ts index 5c10b4a9..fdc337ea 100644 --- a/packages/core/src/types/api.ts +++ b/packages/core/src/types/api.ts @@ -108,6 +108,44 @@ export interface StreamChunk { }; } +// ---- Responses (OpenAI Compatible) ---- + +export interface ResponsesRequest { + model: string; + input: ChatMessage[]; + max_output_tokens?: number; + temperature?: number; + top_p?: number; + stream?: boolean; + tools?: Array>; + enable_thinking?: boolean; +} + +export interface ResponsesOutputContent { + type: string; + text?: string; +} + +export interface ResponsesOutputItem { + type: string; + content?: ResponsesOutputContent[]; + [key: string]: unknown; +} + +export interface ResponsesResponse { + id: string; + object: "response"; + status: string; + output: ResponsesOutputItem[]; + [key: string]: unknown; +} + +export interface ResponsesStreamEvent { + type: string; + delta?: string; + [key: string]: unknown; +} + // ---- Image (DashScope) ---- export interface DashScopeImageRequest { diff --git a/packages/core/tests/responses-api.test.ts b/packages/core/tests/responses-api.test.ts new file mode 100644 index 00000000..96fd8a6e --- /dev/null +++ b/packages/core/tests/responses-api.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, test } from "vite-plus/test"; +import { responsesPath } from "../src/index.ts"; + +describe("Responses API", () => { + test("uses the OpenAI-compatible Responses endpoint", () => { + expect(responsesPath()).toBe("/compatible-mode/v1/responses"); + }); +}); diff --git a/skills/bailian-cli/reference/index.md b/skills/bailian-cli/reference/index.md index e9df5cca..6ec713b7 100644 --- a/skills/bailian-cli/reference/index.md +++ b/skills/bailian-cli/reference/index.md @@ -56,7 +56,7 @@ Use this index for the skill-scoped quick index and global flags. | `bl skill list` | No Auth | List registry skills and diff against local installs | [skill.md](skill.md) | | `bl skill remove` | No Auth | Remove locally installed skills (registry is untouched) | [skill.md](skill.md) | | `bl skill update` | No Auth | Update installed skills to the latest registry versions | [skill.md](skill.md) | -| `bl text chat` | API Key | Send a chat completion (OpenAI compatible, DashScope) | [text.md](text.md) | +| `bl text chat` | API Key | Send a text model request (OpenAI compatible, DashScope) | [text.md](text.md) | | `bl token-plan add-member` | AK/SK | Add a member to a Token Plan organization | [token-plan.md](token-plan.md) | | `bl token-plan assign-seats` | AK/SK | Batch assign Token Plan seats to members | [token-plan.md](token-plan.md) | | `bl token-plan create-key` | AK/SK | Create a Token Plan API key for a seat | [token-plan.md](token-plan.md) | diff --git a/skills/bailian-cli/reference/text.md b/skills/bailian-cli/reference/text.md index 408521f5..6b5f949a 100644 --- a/skills/bailian-cli/reference/text.md +++ b/skills/bailian-cli/reference/text.md @@ -7,38 +7,39 @@ Index: [index.md](index.md) ## Commands in this group -| Command | Authentication | Description | -| -------------- | -------------- | ----------------------------------------------------- | -| `bl text chat` | API Key | Send a chat completion (OpenAI compatible, DashScope) | +| Command | Authentication | Description | +| -------------- | -------------- | -------------------------------------------------------- | +| `bl text chat` | API Key | Send a text model request (OpenAI compatible, DashScope) | ## Command details ### `bl text chat` -| Field | Value | -| ------------------ | ----------------------------------------------------- | -| **Name** | `text chat` | -| **Description** | Send a chat completion (OpenAI compatible, DashScope) | -| **Authentication** | API Key | -| **Usage** | `bl text chat --message [flags]` | +| Field | Value | +| ------------------ | -------------------------------------------------------- | +| **Name** | `text chat` | +| **Description** | Send a text model request (OpenAI compatible, DashScope) | +| **Authentication** | API Key | +| **Usage** | `bl text chat --message [flags]` | #### Flags -| Flag | Type | Required | Description | -| ------------------------ | ------ | -------- | --------------------------------------------------------------------------- | -| `--model ` | string | no | Model ID (default: qwen3.8-max) | -| `--message ` | array | no | Message text (repeatable, prefix role: to set role); or use --messages-file | -| `--messages-file ` | string | no | JSON file with messages array (use - for stdin) | -| `--system ` | string | no | System prompt | -| `--max-tokens ` | number | no | Maximum tokens to generate (default: 4096) | -| `--temperature ` | number | no | Sampling temperature (0.0, 2.0] | -| `--top-p ` | number | no | Nucleus sampling threshold | -| `--stream` | switch | no | Stream response tokens (default: on in TTY) | -| `--tool ` | array | no | Tool definition as JSON or file path (repeatable) | -| `--enable-thinking` | switch | no | Enable thinking/reasoning mode (for qwen3/qwq models) | -| `--thinking-budget ` | number | no | Max tokens for thinking (default: 4096) | -| `--api-key ` | string | no | API key | -| `--base-url ` | string | no | API base URL | +| Flag | Type | Required | Description | +| ------------------------- | ------ | -------- | --------------------------------------------------------------------------- | +| `--api ` | string | no | API to call (default: chat) | +| `--model ` | string | no | Model ID (default: qwen3.8-max) | +| `--message ` | array | no | Message text (repeatable, prefix role: to set role); or use --messages-file | +| `--messages-file ` | string | no | JSON file with messages array (use - for stdin) | +| `--system ` | string | no | System prompt | +| `--max-tokens ` | number | no | Maximum tokens to generate (default: 4096) | +| `--temperature ` | number | no | Sampling temperature (0.0, 2.0] | +| `--top-p ` | number | no | Nucleus sampling threshold | +| `--stream` | switch | no | Stream response tokens (default: on in TTY) | +| `--tool ` | array | no | Tool definition as JSON or file path (repeatable) | +| `--enable-thinking` | switch | no | Enable thinking/reasoning mode (for qwen3/qwq models) | +| `--thinking-budget ` | number | no | Max tokens for thinking (default: 4096) | +| `--api-key ` | string | no | API key | +| `--base-url ` | string | no | API base URL | #### Examples @@ -46,6 +47,10 @@ Index: [index.md](index.md) bl text chat --message "What is Qwen?" ``` +```bash +bl text chat --api responses --model qwen3.8-max --tool '{"type":"web_search"}' --message "Search for recent Alibaba Cloud news" +``` + ```bash bl text chat --model qwen-max --system "You are a coding assistant." --message "Write fizzbuzz in Python" ```