Skip to content

Commit 9914a5f

Browse files
author
Test User
committed
feat(cli): ask/chat/batch 增加 --format json 结构化输出
- ask --format json: 输出 {content, thinking, usage, model, elapsed_ms} - chat --format json: 仅单条模式支持, 多轮会显式报错 - batch --format json: stdout 输出聚合汇总 {input_file, output_file, record_count, success/failure_count, format_type, model, summary} - 顶层 help 追加标准化 Exit Codes 表和 agent-friendly 约定说明 - 三命令均做 --format 取值校验, 错误提示给出修复建议 配合上一 commit 的 stderr 进度分离, agent/脚本可直接 stdout 解析 JSON。
1 parent 1b0ce33 commit 9914a5f

3 files changed

Lines changed: 154 additions & 4 deletions

File tree

flexllm/cli/__init__.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,25 @@
2323
flexllm test # 测试 LLM 连接
2424
2525
配置: ~/.flexllm/config.yaml (运行 flexllm init 创建)
26-
环境变量: FLEXLLM_BASE_URL, FLEXLLM_API_KEY, FLEXLLM_MODEL""",
26+
环境变量: FLEXLLM_BASE_URL, FLEXLLM_API_KEY, FLEXLLM_MODEL
27+
28+
\b
29+
Exit Codes (Agent-friendly, cross-version stable):
30+
0 成功
31+
1 通用错误
32+
2 参数/用法错误(非法值、缺少必选)
33+
3 资源未找到(模型/配置/文件)
34+
4 认证失败(API Key 无效、额度不足)
35+
5 冲突(资源已存在)
36+
6 网络错误(常为 retryable)
37+
7 依赖缺失(缺 pip 包)
38+
8 文件 IO 错误
39+
10 Dry-run 成功(非实际执行)
40+
41+
\b
42+
Agent-friendly JSON 输出:
43+
核心命令支持 --format json(ask/chat/batch),stdout 为结构化数据,
44+
stderr 为进度/日志;错误在非 TTY 自动以 JSON 输出到 stderr。""",
2745
add_completion=True,
2846
no_args_is_help=True,
2947
)

flexllm/cli/chat_helpers.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,11 @@ def single_chat(
2222
user_template=None,
2323
thinking=None,
2424
extract=False,
25+
output_format="text",
2526
):
2627
"""单次对话"""
28+
import json
29+
import time
2730

2831
async def _run():
2932
from flexllm import LLMClient
@@ -39,6 +42,26 @@ async def _run():
3942
if thinking is not None:
4043
kwargs["thinking"] = thinking
4144

45+
if output_format == "json":
46+
t0 = time.perf_counter()
47+
result = await client.chat_completions(messages, **kwargs)
48+
elapsed_ms = int((time.perf_counter() - t0) * 1000)
49+
output = str(result) if not isinstance(result, str) else result
50+
thinking_text = None
51+
usage = None
52+
if hasattr(result, "data") and isinstance(result.data, dict):
53+
thinking_text = result.data.get("thinking")
54+
usage = result.data.get("usage")
55+
payload = {
56+
"content": output,
57+
"thinking": thinking_text,
58+
"usage": usage,
59+
"model": model,
60+
"elapsed_ms": elapsed_ms,
61+
}
62+
print(json.dumps(payload, ensure_ascii=False))
63+
return
64+
4265
if stream and not extract:
4366
print("Assistant: ", end="", flush=True)
4467
async for chunk in client.chat_completions_stream(messages, **kwargs):

flexllm/cli/commands.py

Lines changed: 112 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,13 @@ def ask(
5757
files: Annotated[
5858
list[str] | None, Option("-f", "--file", help="附加文件内容到 prompt(可多次指定)")
5959
] = None,
60+
format: Annotated[
61+
str,
62+
Option(
63+
"--format",
64+
help="输出格式: text(默认) 或 json(结构化: {content, thinking, usage, model, elapsed_ms})",
65+
),
66+
] = "text",
6067
dry_run: Annotated[bool, Option("--dry-run", help="预览操作内容,不实际执行")] = False,
6168
):
6269
"""LLM 快速问答(支持管道输入)
@@ -79,9 +86,21 @@ def ask(
7986
flexllm ask "用 Python 写个快排" -x
8087
flexllm ask "用 Python 写个快排" -x > sort.py
8188
89+
JSON 输出 (--format json): 给 agent/脚本解析
90+
flexllm ask "你好" --format json
91+
# {"content":"...","thinking":null,"usage":{...},"model":"...","elapsed_ms":123}
92+
8293
预览:
8394
flexllm ask "测试" --dry-run # 预览请求内容
8495
"""
96+
if format not in ("text", "json"):
97+
cli_error(
98+
ErrorType.INVALID_ARGS,
99+
"--format 参数值无效",
100+
context={"arg": "--format", "received": format, "expected": ["text", "json"]},
101+
suggestion="使用 --format text 或 --format json",
102+
doc="flexllm ask --help",
103+
)
85104
stdin_content = None
86105
if not sys.stdin.isatty():
87106
stdin_content = sys.stdin.read().strip()
@@ -147,8 +166,12 @@ async def _ask():
147166
async with LLMClient(model=model_id, base_url=base_url, api_key=api_key) as client:
148167
return await client.chat_completions(messages, **model_params)
149168

169+
import time
170+
150171
try:
172+
t0 = time.perf_counter()
151173
result = asyncio.run(_ask())
174+
elapsed_ms = int((time.perf_counter() - t0) * 1000)
152175
if result is None:
153176
cli_error(
154177
ErrorType.GENERAL,
@@ -173,6 +196,23 @@ async def _ask():
173196
retryable=True,
174197
)
175198
output = str(result) if not isinstance(result, str) else result
199+
200+
if format == "json":
201+
thinking_text = None
202+
usage = None
203+
if hasattr(result, "data") and isinstance(result.data, dict):
204+
thinking_text = result.data.get("thinking")
205+
usage = result.data.get("usage")
206+
payload = {
207+
"content": output,
208+
"thinking": thinking_text,
209+
"usage": usage,
210+
"model": model_id,
211+
"elapsed_ms": elapsed_ms,
212+
}
213+
print(json.dumps(payload, ensure_ascii=False))
214+
return
215+
176216
if extract:
177217
code = extract_code_block(output)
178218
if code is not None:
@@ -228,6 +268,13 @@ def chat(
228268
files: Annotated[
229269
list[str] | None, Option("-f", "--file", help="附加文件内容到 prompt(可多次指定)")
230270
] = None,
271+
format: Annotated[
272+
str,
273+
Option(
274+
"--format",
275+
help="输出格式: text(默认) 或 json(仅单条模式, 多轮模式会报错)",
276+
),
277+
] = "text",
231278
dry_run: Annotated[bool, Option("--dry-run", help="预览操作内容,不实际执行")] = False,
232279
):
233280
"""交互式对话
@@ -245,9 +292,28 @@ def chat(
245292
代码提取 (-x): 只输出回复中的代码块(仅单条模式)
246293
flexllm chat "写个 hello world" -x
247294
295+
JSON 输出 (--format json): 仅单条模式支持
296+
flexllm chat "你好" --format json
297+
248298
预览:
249299
flexllm chat "测试" --dry-run # 预览请求配置
250300
"""
301+
if format not in ("text", "json"):
302+
cli_error(
303+
ErrorType.INVALID_ARGS,
304+
"--format 参数值无效",
305+
context={"arg": "--format", "received": format, "expected": ["text", "json"]},
306+
suggestion="使用 --format text 或 --format json",
307+
doc="flexllm chat --help",
308+
)
309+
if format == "json" and not message:
310+
cli_error(
311+
ErrorType.INVALID_ARGS,
312+
"--format json 仅支持单条对话模式",
313+
context={"mode": "interactive", "message": None},
314+
suggestion='提供 message 切到单条模式: flexllm chat "你好" --format json',
315+
doc="flexllm chat --help",
316+
)
251317
model, base_url, api_key = resolve_model_config(model, base_url, api_key)
252318
config = get_config()
253319

@@ -319,6 +385,7 @@ def chat(
319385
user_template,
320386
thinking=resolved_thinking,
321387
extract=extract,
388+
output_format=format,
322389
)
323390
elif not sys.stdin.isatty():
324391
cli_error(
@@ -700,6 +767,13 @@ def batch(
700767
help="结构化输出 (json=JSON模式, @file.json=从文件读取, 或 JSON Schema 字符串)",
701768
),
702769
] = None,
770+
format: Annotated[
771+
str,
772+
Option(
773+
"--format",
774+
help="输出格式: text(默认) 或 json(stdout 输出聚合汇总 JSON)",
775+
),
776+
] = "text",
703777
dry_run: Annotated[bool, Option("--dry-run", help="预览操作内容,不实际执行")] = False,
704778
):
705779
"""批量处理 JSONL 文件(支持断点续传)
@@ -732,9 +806,20 @@ def batch(
732806
flexllm batch data.jsonl -o out.jsonl -uf text -sf sys_prompt
733807
flexllm batch input.jsonl -n 5 # 只处理前5条(试跑)
734808
809+
JSON 输出 (--format json): stdout 输出聚合汇总,方便 agent/脚本解析
810+
flexllm batch input.jsonl -o out.jsonl --format json
811+
735812
预览:
736813
flexllm batch input.jsonl --dry-run # 预览处理计划
737814
"""
815+
if format not in ("text", "json"):
816+
cli_error(
817+
ErrorType.INVALID_ARGS,
818+
"--format 参数值无效",
819+
context={"arg": "--format", "received": format, "expected": ["text", "json"]},
820+
suggestion="使用 --format text 或 --format json",
821+
doc="flexllm batch --help",
822+
)
738823
has_stdin = not sys.stdin.isatty()
739824
if not input and not has_stdin:
740825
cli_error(
@@ -1048,9 +1133,33 @@ async def _run_batch():
10481133

10491134
results, summary = asyncio.run(_run_batch())
10501135

1051-
if summary:
1052-
print(f"\n完成: {summary}", file=sys.stderr)
1053-
print(f"输出文件: {output}", file=sys.stderr)
1136+
if format == "json":
1137+
if isinstance(summary, dict):
1138+
summary_payload = summary
1139+
elif summary is None:
1140+
summary_payload = None
1141+
else:
1142+
summary_payload = {"raw": str(summary)}
1143+
success_count = sum(1 for r in results if r is not None)
1144+
print(
1145+
json.dumps(
1146+
{
1147+
"input_file": input,
1148+
"output_file": output,
1149+
"record_count": len(records),
1150+
"success_count": success_count,
1151+
"failure_count": len(records) - success_count,
1152+
"format_type": format_type,
1153+
"model": model_id,
1154+
"summary": summary_payload,
1155+
},
1156+
ensure_ascii=False,
1157+
)
1158+
)
1159+
else:
1160+
if summary:
1161+
print(f"\n完成: {summary}", file=sys.stderr)
1162+
print(f"输出文件: {output}", file=sys.stderr)
10541163

10551164
except json.JSONDecodeError as e:
10561165
cli_error(

0 commit comments

Comments
 (0)