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/__init__.py +3 -0
- lcode/__main__.py +3 -0
- lcode/app.py +4140 -0
- lcode/compress.py +80 -0
- lcode/default_config.json +10 -0
- lcode/protocol.py +800 -0
- lcode/settings.py +229 -0
- lcode/store.py +889 -0
- lcode/tools.py +1147 -0
- lcode_agent-0.1.0.dist-info/METADATA +123 -0
- lcode_agent-0.1.0.dist-info/RECORD +13 -0
- lcode_agent-0.1.0.dist-info/WHEEL +4 -0
- lcode_agent-0.1.0.dist-info/entry_points.txt +2 -0
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())
|