feat(text): support Responses API

This commit is contained in:
若麒
2026-08-14 16:54:40 +08:00
parent f1b6cacd7f
commit 94ccab0898
10 changed files with 457 additions and 52 deletions
+96 -26
View File
@@ -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: "<chat|responses>",
choices: ["chat", "responses"] as const,
description: "API to call (default: chat)",
},
model: { type: "string", valueHint: "<model>", 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 <text> [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<ResponsesResponse>({
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<ChatResponse>({
path: chatPath(),
@@ -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<string, unknown> | undefined {
return typeof value === "object" && value !== null
? (value as Record<string, unknown>)
: undefined;
}
function stringProperty(record: Record<string, unknown> | 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,
);
}
@@ -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+<chat\|responses>/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<string, unknown> }>(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 () => {
@@ -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<BailianError | undefined> {
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.",
);
});
});
+5
View File
@@ -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 {
+1
View File
@@ -14,6 +14,7 @@ export {
memorySearchPath,
mcpWebSearchPath,
profileSchemaPath,
responsesPath,
speechRecognizePath,
speechSynthesizePath,
taskPath,
+38
View File
@@ -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<Record<string, unknown>>;
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 {
@@ -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");
});
});
+1 -1
View File
@@ -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) |
+29 -24
View File
@@ -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 <text> [flags]` |
| Field | Value |
| ------------------ | -------------------------------------------------------- |
| **Name** | `text chat` |
| **Description** | Send a text model request (OpenAI compatible, DashScope) |
| **Authentication** | API Key |
| **Usage** | `bl text chat --message <text> [flags]` |
#### Flags
| Flag | Type | Required | Description |
| ------------------------ | ------ | -------- | --------------------------------------------------------------------------- |
| `--model <model>` | string | no | Model ID (default: qwen3.8-max) |
| `--message <text>` | array | no | Message text (repeatable, prefix role: to set role); or use --messages-file |
| `--messages-file <path>` | string | no | JSON file with messages array (use - for stdin) |
| `--system <text>` | string | no | System prompt |
| `--max-tokens <n>` | number | no | Maximum tokens to generate (default: 4096) |
| `--temperature <n>` | number | no | Sampling temperature (0.0, 2.0] |
| `--top-p <n>` | number | no | Nucleus sampling threshold |
| `--stream` | switch | no | Stream response tokens (default: on in TTY) |
| `--tool <json-or-path>` | array | no | Tool definition as JSON or file path (repeatable) |
| `--enable-thinking` | switch | no | Enable thinking/reasoning mode (for qwen3/qwq models) |
| `--thinking-budget <n>` | number | no | Max tokens for thinking (default: 4096) |
| `--api-key <key>` | string | no | API key |
| `--base-url <url>` | string | no | API base URL |
| Flag | Type | Required | Description |
| ------------------------- | ------ | -------- | --------------------------------------------------------------------------- |
| `--api <chat\|responses>` | string | no | API to call (default: chat) |
| `--model <model>` | string | no | Model ID (default: qwen3.8-max) |
| `--message <text>` | array | no | Message text (repeatable, prefix role: to set role); or use --messages-file |
| `--messages-file <path>` | string | no | JSON file with messages array (use - for stdin) |
| `--system <text>` | string | no | System prompt |
| `--max-tokens <n>` | number | no | Maximum tokens to generate (default: 4096) |
| `--temperature <n>` | number | no | Sampling temperature (0.0, 2.0] |
| `--top-p <n>` | number | no | Nucleus sampling threshold |
| `--stream` | switch | no | Stream response tokens (default: on in TTY) |
| `--tool <json-or-path>` | array | no | Tool definition as JSON or file path (repeatable) |
| `--enable-thinking` | switch | no | Enable thinking/reasoning mode (for qwen3/qwq models) |
| `--thinking-budget <n>` | number | no | Max tokens for thinking (default: 4096) |
| `--api-key <key>` | string | no | API key |
| `--base-url <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"
```