lcode-agent 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lcode/protocol.py ADDED
@@ -0,0 +1,800 @@
1
+ """OpenAI 兼容协议。
2
+
3
+ 运行方式:
4
+ uv run python -m lcode.protocol
5
+
6
+ 读项目 config.json 里的 url / requestType / model 当默认值,
7
+ 用户配置统一在 ~/.lcode/config.json(url/model/apiKey 都在这一个文件)。
8
+ 密钥只来自环境变量 LCODE_API_KEY 或 ~/.lcode/config.json 的 apiKey。
9
+
10
+ TUI 用 ask_stream(messages):每来一块就 yield ("thinking", 文本)、("answer", 文本)
11
+ 或 ("usage", 用量 dict)。messages 是多轮 [{role, content}, ...]。
12
+ 本文件单独跑时还是用 ask(),打一句 ping 看通不通。
13
+ """
14
+
15
+ import asyncio
16
+ import json
17
+
18
+ import aiohttp
19
+
20
+ from lcode.settings import api_key, load_runtime
21
+
22
+ CONFIG = load_runtime()
23
+
24
+ BASE_URL = str(CONFIG.get("url") or "").rstrip("/")
25
+ REQUEST_TYPE = str(CONFIG.get("requestType") or "completions").strip().lower()
26
+ MODEL = str(CONFIG.get("model") or "deepseek-v4-flash")
27
+ API_KEY = api_key()
28
+
29
+ # url 已经是 .../v1,这里只加后半段,不要再写 /v1
30
+ COMPLETIONS_URL = BASE_URL + "/chat/completions"
31
+ RESPONSES_URL = BASE_URL + "/responses"
32
+ MODELS_URL = BASE_URL + "/models"
33
+
34
+ # 拉 /models 失败时的输入预算,宁可偏小也不要按 100 万硬灌
35
+ DEFAULT_INPUT_BUDGET = 128_000
36
+
37
+ _model_limits_cache: dict | None = None
38
+
39
+
40
+ def set_model(name: str) -> str:
41
+ """运行时切模型:改内存全局并写回 config.json,重启后仍生效。"""
42
+ global MODEL
43
+ name = str(name or "").strip()
44
+ if not name:
45
+ return MODEL
46
+ MODEL = name
47
+ from lcode.settings import save_project_config
48
+
49
+ save_project_config({"model": name})
50
+ reset_model_limits()
51
+ return MODEL
52
+
53
+
54
+ def reset_model_limits() -> None:
55
+ """切模型后把 /models 缓存清掉,下次重新拉窗口大小。"""
56
+ global _model_limits_cache
57
+ _model_limits_cache = None
58
+
59
+
60
+ _KNOWN_EFFORTS = ("low", "medium", "high", "minimal")
61
+
62
+
63
+ def _reasoning_levels_of(hit: dict) -> list[str]:
64
+ """从 /models 的模型元数据里抠支持的推理等级。
65
+
66
+ 没有标准字段;认两种:显式等级列表(reasoning_efforts / reasoning_levels),
67
+ 和 OpenRouter 风格的 supported_parameters 含 reasoning_effort(按 low/medium/high 算)。
68
+ 拿不到就返回空,调用方走启发式。
69
+ """
70
+ for key in ("reasoning_efforts", "reasoning_levels", "efforts"):
71
+ raw = hit.get(key)
72
+ if isinstance(raw, list):
73
+ levels = [str(x).strip().lower() for x in raw if str(x).strip()]
74
+ known = [x for x in _KNOWN_EFFORTS if x in levels]
75
+ if known:
76
+ return known
77
+ params = hit.get("supported_parameters")
78
+ if isinstance(params, list):
79
+ names = {str(x).strip().lower() for x in params}
80
+ if "reasoning_effort" in names or "reasoning" in names:
81
+ return ["low", "medium", "high"]
82
+ return []
83
+
84
+
85
+ def headers() -> dict:
86
+ return {
87
+ "Authorization": f"Bearer {api_key()}",
88
+ "Content-Type": "application/json",
89
+ }
90
+
91
+
92
+ def _positive_int(*values) -> int:
93
+ for value in values:
94
+ if value is None:
95
+ continue
96
+ try:
97
+ number = int(value)
98
+ except (TypeError, ValueError):
99
+ continue
100
+ if number > 0:
101
+ return number
102
+ return 0
103
+
104
+
105
+ # 单次回答的输出上限和请求超时,可在 config.json 里用 maxTokens / timeoutSeconds 覆盖
106
+ MAX_TOKENS = _positive_int(CONFIG.get("maxTokens"), CONFIG.get("max_tokens")) or 4096
107
+ REQUEST_TIMEOUT = _positive_int(
108
+ CONFIG.get("timeoutSeconds"), CONFIG.get("timeout")
109
+ ) or 180
110
+
111
+ # 推理等级:OpenAI o 系/gpt-5 的 reasoning_effort(low/medium/high)。
112
+ # 不是所有兼容网关都认;不配置就不发,被 400 拒了会自动去掉再试一次。
113
+ REASONING_EFFORT = str(CONFIG.get("reasoningEffort") or "").strip().lower()
114
+ if REASONING_EFFORT in ("default", "none", "无", "默认"):
115
+ REASONING_EFFORT = ""
116
+
117
+
118
+ def set_reasoning_effort(effort: str) -> str:
119
+ """设置推理等级并写回 ~/.lcode/config.json;空串表示不发这个参数。"""
120
+ global REASONING_EFFORT
121
+ effort = str(effort or "").strip().lower()
122
+ if effort in ("default", "none", "无", "默认"):
123
+ effort = ""
124
+ if effort and effort not in ("low", "medium", "high", "minimal"):
125
+ return REASONING_EFFORT
126
+ REASONING_EFFORT = effort
127
+ from lcode.settings import save_project_config
128
+
129
+ save_project_config({"reasoningEffort": effort})
130
+ return REASONING_EFFORT
131
+
132
+
133
+ def input_budget(context_length: int, max_output: int = 0) -> int:
134
+ """上下文窗口减去输出预留,至少留 1/4 给输入。"""
135
+ if context_length <= 0:
136
+ return DEFAULT_INPUT_BUDGET
137
+ reserve = max_output if max_output > 0 else context_length // 4
138
+ reserve = min(reserve, context_length * 3 // 4)
139
+ return max(1024, context_length - reserve)
140
+
141
+
142
+ async def fetch_model_limits(model: str | None = None) -> dict:
143
+ """GET /models,取出当前模型的 context_length / max_completion_tokens。"""
144
+ global _model_limits_cache
145
+ name = (model or MODEL).strip()
146
+ if _model_limits_cache and _model_limits_cache.get("id") == name:
147
+ return _model_limits_cache
148
+ info = {
149
+ "id": name,
150
+ "context_length": 0,
151
+ "max_output": 0,
152
+ "input_budget": DEFAULT_INPUT_BUDGET,
153
+ "reasoning_levels": [],
154
+ }
155
+ if not BASE_URL:
156
+ _model_limits_cache = info
157
+ return info
158
+ timeout = aiohttp.ClientTimeout(total=20)
159
+ try:
160
+ async with aiohttp.ClientSession(timeout=timeout, headers=headers()) as session:
161
+ async with session.get(MODELS_URL) as resp:
162
+ if resp.status != 200:
163
+ _model_limits_cache = info
164
+ return info
165
+ payload = await resp.json()
166
+ except Exception:
167
+ _model_limits_cache = info
168
+ return info
169
+ data = payload.get("data") if isinstance(payload, dict) else payload
170
+ if not isinstance(data, list):
171
+ data = []
172
+ hit = None
173
+ for item in data:
174
+ if isinstance(item, dict) and str(item.get("id") or "") == name:
175
+ hit = item
176
+ break
177
+ if hit:
178
+ info["context_length"] = _positive_int(
179
+ hit.get("context_length"),
180
+ hit.get("context_window"),
181
+ hit.get("max_model_len"),
182
+ hit.get("max_input_tokens"),
183
+ )
184
+ info["max_output"] = _positive_int(
185
+ hit.get("max_completion_tokens"),
186
+ hit.get("max_output_tokens"),
187
+ hit.get("max_tokens"),
188
+ )
189
+ info["input_budget"] = input_budget(info["context_length"], info["max_output"])
190
+ info["reasoning_levels"] = _reasoning_levels_of(hit)
191
+ _model_limits_cache = info
192
+ return info
193
+
194
+
195
+ def _extract_usage(obj: dict) -> dict | None:
196
+ """从一块 SSE JSON 里拿出 token 用量;没有就返回 None。"""
197
+ usage = obj.get("usage")
198
+ if isinstance(usage, dict) and usage:
199
+ return usage
200
+ response = obj.get("response")
201
+ if isinstance(response, dict):
202
+ usage = response.get("usage")
203
+ if isinstance(usage, dict) and usage:
204
+ return usage
205
+ return None
206
+
207
+
208
+ def _is_tool_arg_event(typ: str) -> bool:
209
+ """function_call_arguments.delta 这类是工具 JSON,不是给用户看的正文。"""
210
+ t = (typ or "").lower()
211
+ if "function_call" in t or "tool_call" in t:
212
+ return True
213
+ if "arguments" in t and "delta" in t:
214
+ return True
215
+ return False
216
+
217
+
218
+ def _pick_stream_pieces(obj: dict) -> list[tuple[str, object]]:
219
+ """从一块 SSE JSON 里挑出思考/正文/用量。认不出就返回空列表。"""
220
+ pieces: list[tuple[str, object]] = []
221
+ usage = _extract_usage(obj)
222
+
223
+ # 旧协议: choices[0].delta.reasoning_content / content
224
+ # 最后一块常常是 choices=[] 只带 usage
225
+ choices = obj.get("choices")
226
+ if isinstance(choices, list):
227
+ if choices:
228
+ delta = choices[0].get("delta") or {}
229
+ thinking = (
230
+ delta.get("reasoning_content")
231
+ or delta.get("reasoning")
232
+ or delta.get("thinking")
233
+ or ""
234
+ )
235
+ answer = delta.get("content") or ""
236
+ if thinking:
237
+ pieces.append(("thinking", thinking))
238
+ if answer:
239
+ pieces.append(("answer", answer))
240
+ if usage:
241
+ pieces.append(("usage", usage))
242
+ if pieces:
243
+ return pieces
244
+
245
+ # 新协议:只吃带 delta 的增量事件,完成事件会带全文,再拼会重复
246
+ # 用量多半在 response.completed 里,没有 delta
247
+ if usage:
248
+ pieces.append(("usage", usage))
249
+ typ = str(obj.get("type") or "")
250
+ if "delta" not in typ:
251
+ return pieces
252
+ # 工具参数走 tool_delta,这里再拼进正文会变成满屏 \n 字面量
253
+ if _is_tool_arg_event(typ):
254
+ return pieces
255
+ delta = obj.get("delta")
256
+ if isinstance(delta, str):
257
+ text = delta
258
+ elif isinstance(delta, dict):
259
+ text = delta.get("text") or delta.get("content") or ""
260
+ else:
261
+ text = ""
262
+ if not text:
263
+ return pieces
264
+ if "reasoning" in typ:
265
+ pieces.append(("thinking", text))
266
+ else:
267
+ pieces.append(("answer", text))
268
+ return pieces
269
+
270
+
271
+ async def _iter_sse(resp: aiohttp.ClientResponse):
272
+ """按行拆 SSE,吐出 data: 后面的 JSON。"""
273
+ buf = ""
274
+ async for raw in resp.content:
275
+ buf += raw.decode("utf-8", errors="ignore")
276
+ while "\n" in buf:
277
+ line, buf = buf.split("\n", 1)
278
+ line = line.strip()
279
+ if not line.startswith("data:"):
280
+ continue
281
+ payload = line[5:].strip()
282
+ if payload == "[DONE]":
283
+ return
284
+ try:
285
+ yield json.loads(payload)
286
+ except json.JSONDecodeError:
287
+ continue
288
+
289
+
290
+ def as_messages(prompt: str | list[dict]) -> list[dict]:
291
+ """单句或已有多轮,都收成 role+content 列表。"""
292
+ if isinstance(prompt, str):
293
+ text = prompt.strip()
294
+ return [{"role": "user", "content": text}] if text else []
295
+ out: list[dict] = []
296
+ for item in prompt:
297
+ role = str(item.get("role") or "")
298
+ content = str(item.get("content") or "").strip()
299
+ if role in ("user", "assistant", "system") and content:
300
+ out.append({"role": role, "content": content})
301
+ return out
302
+
303
+
304
+ def _fix_payload_400(payload: dict, body: str) -> dict | None:
305
+ """服务端 400 时的自动降级:去掉它不认的 reasoning 参数/换 token 字段名。"""
306
+ low = (body or "").lower()
307
+ fixed = dict(payload)
308
+ changed = False
309
+ if "reasoning" in low and ("reasoning_effort" in fixed or "reasoning" in fixed):
310
+ fixed.pop("reasoning_effort", None)
311
+ fixed.pop("reasoning", None)
312
+ changed = True
313
+ if "max_completion_tokens" in low and "max_tokens" in fixed:
314
+ fixed["max_completion_tokens"] = fixed.pop("max_tokens")
315
+ changed = True
316
+ return fixed if changed else None
317
+
318
+
319
+ async def _post_stream(
320
+ session: aiohttp.ClientSession, url: str, payload: dict, label: str
321
+ ):
322
+ """POST 流式请求;400 且响应体点名了可降级的参数时,改完自动再试一次。"""
323
+ resp = await session.post(url, json=payload)
324
+ if resp.status == 400:
325
+ body = await resp.text()
326
+ fixed = _fix_payload_400(payload, body)
327
+ if fixed is not None:
328
+ resp.release()
329
+ resp = await session.post(url, json=fixed)
330
+ if resp.status != 200:
331
+ body = await resp.text()
332
+ raise RuntimeError(f"{label} 失败 HTTP {resp.status}: {body[:500]}")
333
+ return resp
334
+
335
+
336
+ def _call_id_of(call: dict) -> str:
337
+ fn = call.get("function") if isinstance(call.get("function"), dict) else {}
338
+ return str(call.get("call_id") or call.get("id") or fn.get("id") or "").strip()
339
+
340
+
341
+ def _call_name_args(call: dict) -> tuple[str, str]:
342
+ fn = call.get("function") if isinstance(call.get("function"), dict) else {}
343
+ name = str(call.get("name") or fn.get("name") or "")
344
+ raw = call.get("arguments")
345
+ if raw is None:
346
+ raw = fn.get("arguments")
347
+ if isinstance(raw, (dict, list)):
348
+ try:
349
+ raw = json.dumps(raw, ensure_ascii=False)
350
+ except (TypeError, ValueError):
351
+ raw = "{}"
352
+ return name, str(raw or "{}")
353
+
354
+
355
+ def ensure_tool_outputs(messages: list[dict]) -> list[dict]:
356
+ """每个 tool_call 必须带一条 tool 结果,否则 completions/responses 会 400。"""
357
+ have: set[str] = set()
358
+ needed: list[str] = []
359
+ for msg in messages:
360
+ if not isinstance(msg, dict):
361
+ continue
362
+ if msg.get("role") == "tool":
363
+ cid = str(msg.get("tool_call_id") or msg.get("call_id") or "").strip()
364
+ if cid:
365
+ have.add(cid)
366
+ for call in msg.get("tool_calls") or []:
367
+ if isinstance(call, dict):
368
+ cid = _call_id_of(call)
369
+ if cid:
370
+ needed.append(cid)
371
+ if msg.get("type") == "function_call":
372
+ cid = str(msg.get("call_id") or msg.get("id") or "").strip()
373
+ if cid:
374
+ needed.append(cid)
375
+ if msg.get("type") == "function_call_output":
376
+ cid = str(msg.get("call_id") or "").strip()
377
+ if cid:
378
+ have.add(cid)
379
+ missing = [cid for cid in needed if cid not in have]
380
+ if not missing:
381
+ return messages
382
+ out = list(messages)
383
+ for cid in missing:
384
+ out.append(
385
+ {
386
+ "role": "tool",
387
+ "tool_call_id": cid,
388
+ "content": "(无结果)",
389
+ }
390
+ )
391
+ return out
392
+
393
+
394
+ def _responses_parts(parts: object) -> object:
395
+ """responses 协议的 content 类型名和 completions 不同,逐个转换。"""
396
+ if not isinstance(parts, list):
397
+ return parts
398
+ out: list = []
399
+ for part in parts:
400
+ if not isinstance(part, dict):
401
+ out.append(part)
402
+ continue
403
+ typ = str(part.get("type") or "")
404
+ if typ == "text":
405
+ out.append({"type": "input_text", "text": str(part.get("text") or "")})
406
+ elif typ == "image_url":
407
+ url = part.get("image_url")
408
+ if isinstance(url, dict):
409
+ url = url.get("url")
410
+ out.append({"type": "input_image", "image_url": url})
411
+ else:
412
+ out.append(part)
413
+ return out
414
+
415
+
416
+ def _responses_input(messages: list[dict]):
417
+ """responses:每个 function_call 后面紧跟对应 output,否则会报 No tool output found。"""
418
+ if (
419
+ len(messages) == 1
420
+ and messages[0].get("role") == "user"
421
+ and not messages[0].get("tool_calls")
422
+ ):
423
+ content = messages[0].get("content")
424
+ if isinstance(content, list):
425
+ # 带图片的多模态消息:整个消息对象作为 input item
426
+ return [{"role": "user", "content": _responses_parts(content)}]
427
+ return content or ""
428
+ outputs: dict[str, str] = {}
429
+ for msg in messages:
430
+ if not isinstance(msg, dict):
431
+ continue
432
+ if msg.get("role") == "tool":
433
+ cid = str(msg.get("tool_call_id") or msg.get("call_id") or "").strip()
434
+ if cid:
435
+ outputs[cid] = str(msg.get("content") or "")
436
+ elif msg.get("type") == "function_call_output":
437
+ cid = str(msg.get("call_id") or "").strip()
438
+ if cid:
439
+ outputs[cid] = str(msg.get("output") or msg.get("content") or "")
440
+ out: list[dict] = []
441
+ emitted: set[str] = set()
442
+
443
+ def emit_call(call: dict) -> None:
444
+ cid = _call_id_of(call)
445
+ if not cid or cid in emitted:
446
+ return
447
+ name, arguments = _call_name_args(call)
448
+ item: dict = {
449
+ "type": "function_call",
450
+ "call_id": cid,
451
+ "name": name,
452
+ "arguments": arguments,
453
+ }
454
+ if call.get("id") and str(call.get("id")) != cid:
455
+ item["id"] = str(call["id"])
456
+ out.append(item)
457
+ out.append(
458
+ {
459
+ "type": "function_call_output",
460
+ "call_id": cid,
461
+ "output": outputs.get(cid, "(无结果)"),
462
+ }
463
+ )
464
+ emitted.add(cid)
465
+
466
+ for msg in messages:
467
+ if not isinstance(msg, dict):
468
+ continue
469
+ if msg.get("role") == "tool" or msg.get("type") == "function_call_output":
470
+ continue
471
+ if msg.get("type") == "function_call":
472
+ emit_call(msg)
473
+ continue
474
+ if msg.get("type"):
475
+ out.append(msg)
476
+ continue
477
+ calls = msg.get("tool_calls") or []
478
+ text = msg.get("content")
479
+ if calls:
480
+ if text:
481
+ out.append({"role": "assistant", "content": text})
482
+ for call in calls:
483
+ if isinstance(call, dict):
484
+ emit_call(call)
485
+ continue
486
+ out.append(
487
+ {
488
+ "role": msg.get("role") or "user",
489
+ "content": _responses_parts(text) if isinstance(text, list) else (text or ""),
490
+ }
491
+ )
492
+ return out
493
+
494
+
495
+ class _ToolAcc:
496
+ def __init__(self) -> None:
497
+ self.by_index: dict[int, dict[str, str]] = {}
498
+ self.by_id: dict[str, dict[str, str]] = {}
499
+ self._item_to_call: dict[str, str] = {}
500
+
501
+ def ingest_completions(self, obj: dict) -> None:
502
+ choices = obj.get("choices")
503
+ if not isinstance(choices, list) or not choices:
504
+ return
505
+ delta = choices[0].get("delta") or {}
506
+ for item in delta.get("tool_calls") or []:
507
+ if not isinstance(item, dict):
508
+ continue
509
+ idx = int(item.get("index") or 0)
510
+ slot = self.by_index.setdefault(idx, {"id": "", "name": "", "arguments": ""})
511
+ if item.get("id"):
512
+ slot["id"] = str(item["id"])
513
+ fn = item.get("function") or {}
514
+ if fn.get("name"):
515
+ slot["name"] += str(fn["name"])
516
+ if fn.get("arguments"):
517
+ slot["arguments"] += str(fn["arguments"])
518
+
519
+ def snapshot(self) -> list[dict[str, str]]:
520
+ """流式中途的工具调用,名字还没到也先给界面。"""
521
+ rows = list(self.by_index.values()) + list(self.by_id.values())
522
+ out: list[dict[str, str]] = []
523
+ seen: set[int] = set()
524
+ for row in rows:
525
+ ident = id(row)
526
+ if ident in seen:
527
+ continue
528
+ seen.add(ident)
529
+ if not (row.get("name") or row.get("arguments")):
530
+ continue
531
+ out.append(
532
+ {
533
+ "id": row.get("id") or "",
534
+ "name": row.get("name") or "",
535
+ "arguments": row.get("arguments") or "",
536
+ }
537
+ )
538
+ return out
539
+
540
+ def signature(self) -> tuple:
541
+ return tuple(
542
+ (row.get("id"), row.get("name"), len(row.get("arguments") or ""))
543
+ for row in self.snapshot()
544
+ )
545
+
546
+ def _slot(self, call_id: str) -> dict[str, str] | None:
547
+ call_id = (call_id or "").strip()
548
+ if not call_id:
549
+ return None
550
+ return self.by_id.setdefault(call_id, {"id": call_id, "name": "", "arguments": ""})
551
+
552
+ def ingest_responses(self, obj: dict) -> None:
553
+ typ = str(obj.get("type") or "")
554
+ item = obj.get("item")
555
+ if isinstance(item, dict) and (
556
+ "function_call" in str(item.get("type") or "") or item.get("name")
557
+ ):
558
+ call_id = str(item.get("call_id") or "").strip()
559
+ item_id = str(item.get("id") or "").strip()
560
+ if item_id and call_id:
561
+ self._item_to_call[item_id] = call_id
562
+ if not call_id:
563
+ call_id = self._item_to_call.get(item_id, "")
564
+ if not call_id:
565
+ call_id = item_id
566
+ pending = None
567
+ if item_id and call_id and item_id != call_id:
568
+ pending = self.by_id.pop(item_id, None)
569
+ slot = self._slot(call_id)
570
+ if slot is not None:
571
+ if pending:
572
+ if pending.get("name") and not slot.get("name"):
573
+ slot["name"] = pending["name"]
574
+ if len(pending.get("arguments") or "") > len(slot.get("arguments") or ""):
575
+ slot["arguments"] = pending["arguments"]
576
+ if item.get("name"):
577
+ slot["name"] = str(item["name"])
578
+ if item.get("arguments"):
579
+ slot["arguments"] = str(item["arguments"])
580
+ if "function_call_arguments" in typ:
581
+ call_id = str(obj.get("call_id") or "").strip()
582
+ item_id = str(obj.get("item_id") or "").strip()
583
+ if not call_id:
584
+ call_id = self._item_to_call.get(item_id, "")
585
+ if not call_id:
586
+ call_id = item_id
587
+ if item_id and call_id and item_id != call_id:
588
+ self._item_to_call[item_id] = call_id
589
+ pending = self.by_id.pop(item_id, None)
590
+ else:
591
+ pending = None
592
+ slot = self._slot(call_id)
593
+ if slot is None:
594
+ return
595
+ if pending:
596
+ if pending.get("name") and not slot.get("name"):
597
+ slot["name"] = pending["name"]
598
+ if len(pending.get("arguments") or "") > len(slot.get("arguments") or ""):
599
+ slot["arguments"] = pending["arguments"]
600
+ delta = obj.get("delta")
601
+ if isinstance(delta, str):
602
+ slot["arguments"] += delta
603
+ elif isinstance(delta, dict):
604
+ piece = delta.get("arguments") or delta.get("text") or delta.get("content") or ""
605
+ if piece:
606
+ slot["arguments"] += str(piece)
607
+ if obj.get("name"):
608
+ slot["name"] = str(obj["name"])
609
+ if obj.get("arguments") and "delta" not in typ:
610
+ slot["arguments"] = str(obj["arguments"])
611
+
612
+ def finished(self) -> list[dict[str, str]]:
613
+ rows = list(self.by_index.values()) + list(self.by_id.values())
614
+ out = []
615
+ seen = set()
616
+ for row in rows:
617
+ name = (row.get("name") or "").strip()
618
+ if not name:
619
+ continue
620
+ key = row.get("id") or name
621
+ if key in seen:
622
+ continue
623
+ seen.add(key)
624
+ out.append(
625
+ {
626
+ "id": row.get("id") or key,
627
+ "name": name,
628
+ "arguments": row.get("arguments") or "{}",
629
+ }
630
+ )
631
+ return out
632
+
633
+
634
+ async def stream_completions(
635
+ session: aiohttp.ClientSession,
636
+ messages: list[dict],
637
+ tools: list[dict] | None = None,
638
+ ):
639
+ """旧协议流式:POST /chat/completions, stream=true。"""
640
+ payload = {
641
+ "model": MODEL,
642
+ "messages": ensure_tool_outputs(messages),
643
+ "max_tokens": MAX_TOKENS,
644
+ "stream": True,
645
+ "stream_options": {"include_usage": True},
646
+ }
647
+ if tools:
648
+ payload["tools"] = tools
649
+ payload["tool_choice"] = "auto"
650
+ if REASONING_EFFORT:
651
+ payload["reasoning_effort"] = REASONING_EFFORT
652
+ acc = _ToolAcc()
653
+ last_sig = None
654
+ resp = await _post_stream(session, COMPLETIONS_URL, payload, "completions")
655
+ async with resp:
656
+ async for obj in _iter_sse(resp):
657
+ acc.ingest_completions(obj)
658
+ for kind, text in _pick_stream_pieces(obj):
659
+ yield kind, text
660
+ sig = acc.signature()
661
+ if sig != last_sig:
662
+ last_sig = sig
663
+ snap = acc.snapshot()
664
+ if snap:
665
+ yield "tool_delta", snap
666
+ calls = acc.finished()
667
+ if calls:
668
+ yield "tool_calls", calls
669
+
670
+
671
+ async def stream_responses(
672
+ session: aiohttp.ClientSession,
673
+ messages: list[dict],
674
+ tools: list[dict] | None = None,
675
+ ):
676
+ """新协议流式:POST /responses, stream=true。"""
677
+ payload = {
678
+ "model": MODEL,
679
+ "input": _responses_input(ensure_tool_outputs(messages)),
680
+ "max_output_tokens": MAX_TOKENS,
681
+ "stream": True,
682
+ }
683
+ if tools:
684
+ payload["tools"] = tools
685
+ if REASONING_EFFORT:
686
+ payload["reasoning"] = {"effort": REASONING_EFFORT}
687
+ acc = _ToolAcc()
688
+ last_sig = None
689
+ resp = await _post_stream(session, RESPONSES_URL, payload, "responses")
690
+ async with resp:
691
+ async for obj in _iter_sse(resp):
692
+ acc.ingest_responses(obj)
693
+ for kind, text in _pick_stream_pieces(obj):
694
+ yield kind, text
695
+ sig = acc.signature()
696
+ if sig != last_sig:
697
+ last_sig = sig
698
+ snap = acc.snapshot()
699
+ if snap:
700
+ yield "tool_delta", snap
701
+ calls = acc.finished()
702
+ if calls:
703
+ yield "tool_calls", calls
704
+
705
+
706
+ async def ask_stream(prompt: str | list[dict], tools: list[dict] | None = None):
707
+ """给 TUI 用:一块一块 yield thinking / answer / usage / tool_calls。"""
708
+ if isinstance(prompt, str):
709
+ messages = as_messages(prompt)
710
+ else:
711
+ messages = list(prompt)
712
+ if not messages:
713
+ return
714
+ timeout = aiohttp.ClientTimeout(total=REQUEST_TIMEOUT)
715
+ async with aiohttp.ClientSession(timeout=timeout, headers=headers()) as session:
716
+ if tools:
717
+ if REQUEST_TYPE == "responses":
718
+ try:
719
+ async for piece in stream_responses(
720
+ session, messages, responses_tools_from(tools)
721
+ ):
722
+ yield piece
723
+ return
724
+ except RuntimeError:
725
+ pass
726
+ async for piece in stream_completions(session, messages, tools):
727
+ yield piece
728
+ return
729
+ try:
730
+ async for piece in stream_completions(session, messages, tools):
731
+ yield piece
732
+ return
733
+ except RuntimeError:
734
+ pass
735
+ async for piece in stream_responses(session, messages, responses_tools_from(tools)):
736
+ yield piece
737
+ return
738
+ if REQUEST_TYPE == "responses":
739
+ try:
740
+ async for piece in stream_responses(session, messages):
741
+ yield piece
742
+ return
743
+ except RuntimeError:
744
+ pass
745
+ async for piece in stream_completions(session, messages):
746
+ yield piece
747
+
748
+
749
+ def responses_tools_from(openai_tools: list[dict]) -> list[dict]:
750
+ out = []
751
+ for item in openai_tools:
752
+ fn = item.get("function") if item.get("type") == "function" else item
753
+ if not isinstance(fn, dict):
754
+ continue
755
+ name = fn.get("name") or item.get("name")
756
+ if not name:
757
+ continue
758
+ out.append(
759
+ {
760
+ "type": "function",
761
+ "name": name,
762
+ "description": fn.get("description") or item.get("description") or "",
763
+ "parameters": fn.get("parameters") or item.get("parameters") or {},
764
+ }
765
+ )
766
+ return out
767
+
768
+
769
+ async def ask(prompt: str | list[dict]) -> str:
770
+ """非流式,给本文件单独测试用。思考和正文拼在一起。"""
771
+ thinking = []
772
+ answer = []
773
+ async for kind, text in ask_stream(prompt):
774
+ if kind == "thinking":
775
+ thinking.append(str(text))
776
+ elif kind == "answer":
777
+ answer.append(str(text))
778
+ text = "".join(answer).strip()
779
+ if not text and thinking:
780
+ text = "".join(thinking).strip()
781
+ return text
782
+
783
+
784
+ async def main() -> None:
785
+ if not BASE_URL or not api_key():
786
+ raise SystemExit("缺少 url,或未在 ~/.lcode/config.json 配置 apiKey(或设 LCODE_API_KEY)")
787
+
788
+ print("url =", BASE_URL)
789
+ print("协议 =", REQUEST_TYPE)
790
+ print("模型 =", MODEL)
791
+ print()
792
+
793
+ print("流式:")
794
+ async for kind, text in ask_stream("只回复一个词: pong"):
795
+ print(f" [{kind}] {text!r}")
796
+ print("通了")
797
+
798
+
799
+ if __name__ == "__main__":
800
+ asyncio.run(main())