Skip to content

Commit 94ccab0

Browse files
committed
feat(text): support Responses API
1 parent f1b6cac commit 94ccab0

10 files changed

Lines changed: 457 additions & 52 deletions

File tree

packages/commands/src/commands/text/chat.ts

Lines changed: 96 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,35 @@
11
import {
22
defineCommand,
33
chatPath,
4+
responsesPath,
45
parseSSE,
56
detectOutputFormat,
67
readTextFromPathOrStdin,
78
type ChatMessage,
89
type ChatRequest,
910
type ChatResponse,
11+
type ResponsesRequest,
12+
type ResponsesResponse,
13+
type ResponsesStreamEvent,
1014
type StreamChunk,
1115
type FlagsDef,
1216
type ParsedFlags,
1317
} from "bailian-cli-core";
1418
import { ansi, emitResult, emitBare } from "bailian-cli-runtime";
1519
import { readFileSync } from "fs";
20+
import {
21+
assertResponsesStreamCompleted,
22+
inspectResponsesStreamEvent,
23+
extractResponsesText,
24+
} from "./responses.ts";
1625

1726
const CHAT_FLAGS = {
27+
api: {
28+
type: "string",
29+
valueHint: "<chat|responses>",
30+
choices: ["chat", "responses"] as const,
31+
description: "API to call (default: chat)",
32+
},
1833
model: { type: "string", valueHint: "<model>", description: "Model ID (default: qwen3.8-max)" },
1934
message: {
2035
type: "array",
@@ -72,31 +87,31 @@ function parseMessages(flags: ChatFlags): ParsedMessages {
7287
if (flags.messagesFile) {
7388
const raw = readTextFromPathOrStdin(flags.messagesFile);
7489
const parsed = JSON.parse(raw) as Array<{ role: string; content: string }>;
75-
for (const m of parsed) {
76-
if (m.role === "system") {
77-
system = typeof m.content === "string" ? m.content : "";
90+
for (const parsedMessage of parsed) {
91+
if (parsedMessage.role === "system") {
92+
system = typeof parsedMessage.content === "string" ? parsedMessage.content : "";
7893
} else {
79-
messages.push(m as ChatMessage);
94+
messages.push(parsedMessage as ChatMessage);
8095
}
8196
}
8297
}
8398

8499
if (flags.message) {
85100
const validRoles = new Set(["system", "user", "assistant"]);
86-
const msgs = flags.message;
87-
for (const m of msgs) {
88-
const colonIdx = m.indexOf(":");
89-
const maybeRole = colonIdx !== -1 ? m.slice(0, colonIdx) : "";
101+
const messageValues = flags.message;
102+
for (const messageValue of messageValues) {
103+
const colonIndex = messageValue.indexOf(":");
104+
const maybeRole = colonIndex !== -1 ? messageValue.slice(0, colonIndex) : "";
90105

91106
if (validRoles.has(maybeRole)) {
92-
const content = m.slice(colonIdx + 1);
107+
const content = messageValue.slice(colonIndex + 1);
93108
if (maybeRole === "system") {
94109
system = content;
95110
} else {
96111
messages.push({ role: maybeRole as "user" | "assistant", content });
97112
}
98113
} else {
99-
messages.push({ role: "user", content: m });
114+
messages.push({ role: "user", content: messageValue });
100115
}
101116
}
102117
}
@@ -105,24 +120,33 @@ function parseMessages(flags: ChatFlags): ParsedMessages {
105120
}
106121

107122
export default defineCommand({
108-
description: "Send a chat completion (OpenAI compatible, DashScope)",
123+
description: "Send a text model request (OpenAI compatible, DashScope)",
109124
auth: "apiKey",
110125
usageArgs: "--message <text> [flags]",
111126
flags: CHAT_FLAGS,
112127
exampleArgs: [
113128
'--message "What is Qwen?"',
129+
`--api responses --model qwen3.8-max --tool '{"type":"web_search"}' --message "Search for recent Alibaba Cloud news"`,
114130
'--model qwen-max --system "You are a coding assistant." --message "Write fizzbuzz in Python"',
115131
'--message "Hello" --message "assistant:Hi!" --message "How are you?"',
116132
"--messages-file - --stream",
117133
'--message "Hello" --output json',
118134
'--model qwq-plus --message "Solve 1+1" --enable-thinking',
119135
],
120-
validate: (f) =>
121-
!f.message && !f.messagesFile ? "Provide --message or --messages-file." : undefined,
136+
validate: (flags) => {
137+
if (!flags.message && !flags.messagesFile) {
138+
return "Provide --message or --messages-file.";
139+
}
140+
if (flags.api === "responses" && flags.thinkingBudget !== undefined) {
141+
return "--thinking-budget is not supported by the Responses API.";
142+
}
143+
return undefined;
144+
},
122145
async run(ctx) {
123146
const { settings, flags } = ctx;
124147
const { system, messages } = parseMessages(flags);
125148

149+
const api = flags.api ?? "chat";
126150
const model = flags.model || settings.defaultTextModel || "qwen3.8-max";
127151
const shouldStream = flags.stream || process.stdout.isTTY;
128152
const format = detectOutputFormat(settings.output);
@@ -134,29 +158,39 @@ export default defineCommand({
134158
}
135159
allMessages.push(...messages);
136160

137-
const body: ChatRequest = {
138-
model,
139-
messages: allMessages,
140-
max_tokens: flags.maxTokens ?? 4096,
141-
stream: shouldStream,
142-
};
161+
let body: ChatRequest | ResponsesRequest;
162+
if (api === "responses") {
163+
body = {
164+
model,
165+
input: allMessages,
166+
max_output_tokens: flags.maxTokens ?? 4096,
167+
stream: shouldStream,
168+
};
169+
} else {
170+
body = {
171+
model,
172+
messages: allMessages,
173+
max_tokens: flags.maxTokens ?? 4096,
174+
stream: shouldStream,
175+
};
176+
}
143177

144178
if (flags.temperature !== undefined) body.temperature = flags.temperature;
145179
if (flags.topP !== undefined) body.top_p = flags.topP;
146180

147181
if (flags.enableThinking) {
148182
body.enable_thinking = true;
149-
if (flags.thinkingBudget !== undefined) {
183+
if (api === "chat" && "messages" in body && flags.thinkingBudget !== undefined) {
150184
body.thinking_budget = flags.thinkingBudget;
151185
}
152186
}
153187

154188
if (flags.tool) {
155-
const tools = flags.tool.map((t) => {
189+
const tools = flags.tool.map((toolValue) => {
156190
try {
157-
return JSON.parse(t);
191+
return JSON.parse(toolValue);
158192
} catch {
159-
const raw = readFileSync(t, "utf-8");
193+
const raw = readFileSync(toolValue, "utf-8");
160194
return JSON.parse(raw);
161195
}
162196
});
@@ -169,24 +203,45 @@ export default defineCommand({
169203
}
170204

171205
if (shouldStream) {
172-
const res = await ctx.client.request({
173-
path: chatPath(),
206+
const responseStream = await ctx.client.request({
207+
path: api === "responses" ? responsesPath() : chatPath(),
174208
method: "POST",
175209
body,
176210
stream: true,
177211
});
178212

179213
let textContent = "";
180214
let inThinking = false;
215+
let responsesCompleted = false;
181216
const writesStreamingStdout = format === "text";
182217
const isTTY = process.stdout.isTTY;
183218
const statusOut =
184219
format === "json" ? process.stderr : isTTY ? process.stdout : process.stderr;
185220
const resultOut = process.stdout;
186221
const statusColor = ansi(statusOut);
187222

188-
for await (const event of parseSSE(res)) {
223+
for await (const event of parseSSE(responseStream)) {
189224
if (event.data === "[DONE]") break;
225+
if (api === "responses") {
226+
let parsedEvent: ResponsesStreamEvent;
227+
try {
228+
parsedEvent = JSON.parse(event.data) as ResponsesStreamEvent;
229+
} catch {
230+
continue;
231+
}
232+
233+
const update = inspectResponsesStreamEvent(parsedEvent);
234+
if (update.delta) {
235+
textContent += update.delta;
236+
if (writesStreamingStdout) resultOut.write(update.delta);
237+
}
238+
if (update.completed) {
239+
responsesCompleted = true;
240+
break;
241+
}
242+
continue;
243+
}
244+
190245
try {
191246
const parsed = JSON.parse(event.data) as StreamChunk;
192247

@@ -216,13 +271,28 @@ export default defineCommand({
216271
// Skip unparseable chunks
217272
}
218273
}
274+
if (api === "responses") assertResponsesStreamCompleted(responsesCompleted);
219275
if (inThinking) statusOut.write(statusColor.reset);
220276

221277
if (format === "json") {
222278
emitResult({ content: textContent }, format);
223279
} else {
224280
resultOut.write("\n");
225281
}
282+
} else if (api === "responses") {
283+
const response = await ctx.client.requestJson<ResponsesResponse>({
284+
path: responsesPath(),
285+
method: "POST",
286+
body,
287+
});
288+
289+
const text = extractResponsesText(response);
290+
291+
if (settings.quiet || format === "text") {
292+
emitBare(text);
293+
} else {
294+
emitResult(response, format);
295+
}
226296
} else {
227297
const response = await ctx.client.requestJson<ChatResponse>({
228298
path: chatPath(),
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import {
2+
BailianError,
3+
ExitCode,
4+
type ResponsesResponse,
5+
type ResponsesStreamEvent,
6+
} from "bailian-cli-core";
7+
8+
export interface ResponsesStreamUpdate {
9+
delta: string;
10+
completed: boolean;
11+
}
12+
13+
export function extractResponsesText(response: ResponsesResponse): string {
14+
return response.output
15+
.filter((outputItem) => outputItem.type === "message")
16+
.flatMap((outputItem) => outputItem.content ?? [])
17+
.filter((contentItem) => contentItem.type === "output_text")
18+
.map((contentItem) => contentItem.text ?? "")
19+
.join("");
20+
}
21+
22+
export function extractResponsesStreamDelta(event: ResponsesStreamEvent): string {
23+
return event.type === "response.output_text.delta" ? (event.delta ?? "") : "";
24+
}
25+
26+
function asRecord(value: unknown): Record<string, unknown> | undefined {
27+
return typeof value === "object" && value !== null
28+
? (value as Record<string, unknown>)
29+
: undefined;
30+
}
31+
32+
function stringProperty(record: Record<string, unknown> | undefined, property: string) {
33+
const value = record?.[property];
34+
return typeof value === "string" && value.trim() ? value : undefined;
35+
}
36+
37+
function responsesErrorMessage(event: ResponsesStreamEvent): string | undefined {
38+
const response = asRecord(event.response);
39+
const responseError = asRecord(response?.error);
40+
const eventError = asRecord(event.error);
41+
return (
42+
stringProperty(responseError, "message") ??
43+
stringProperty(eventError, "message") ??
44+
stringProperty(event, "message")
45+
);
46+
}
47+
48+
export function inspectResponsesStreamEvent(event: ResponsesStreamEvent): ResponsesStreamUpdate {
49+
if (event.type === "response.failed" || event.type === "error") {
50+
throw new BailianError(responsesErrorMessage(event) ?? "Response failed.", ExitCode.GENERAL);
51+
}
52+
53+
if (event.type === "response.incomplete") {
54+
const response = asRecord(event.response);
55+
const incompleteDetails = asRecord(response?.incomplete_details);
56+
const reason = stringProperty(incompleteDetails, "reason");
57+
throw new BailianError(
58+
responsesErrorMessage(event) ??
59+
(reason ? `Response incomplete: ${reason}` : "Response incomplete."),
60+
ExitCode.GENERAL,
61+
);
62+
}
63+
64+
return {
65+
delta: extractResponsesStreamDelta(event),
66+
completed: event.type === "response.completed",
67+
};
68+
}
69+
70+
export function assertResponsesStreamCompleted(completed: boolean): void {
71+
if (completed) return;
72+
throw new BailianError(
73+
"Stream disconnected before completion: stream closed before response.completed.",
74+
ExitCode.GENERAL,
75+
);
76+
}

0 commit comments

Comments
 (0)