abyss-cli 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.
abyss/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ # -*- coding: utf-8 -*-
2
+ """Abyss - 终端AI开发助手"""
3
+ __version__ = "0.1.0"
abyss/ansi_menu.py ADDED
@@ -0,0 +1,559 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ 自定义 ANSI 补全菜单 — 完全绕过 prompt_toolkit 的灰色背景问题
4
+ 使用纯 ANSI 转义码渲染,保留 prompt_toolkit 的行编辑能力。
5
+
6
+ 用法:
7
+ from .ansi_menu import ansi_prompt
8
+ user_input = ansi_prompt("> ")
9
+ """
10
+ import os
11
+ import sys
12
+ import msvcrt
13
+ import time
14
+ from typing import List, Tuple, Optional
15
+
16
+
17
+ # ─── 模糊匹配 ──────────────────────────────────────────────
18
+ def _fuzzy_match(search: str, target: str) -> bool:
19
+ if not search:
20
+ return True
21
+ si = 0
22
+ for ch in target:
23
+ if si < len(search) and ch.lower() == search[si].lower():
24
+ si += 1
25
+ return si == len(search)
26
+
27
+
28
+ # ─── 命令列表 ──────────────────────────────────────────────
29
+ COMMANDS = [
30
+ ("/help", "显示帮助"),
31
+ ("/clear", "清空上下文"),
32
+ ("/exit", "退出"),
33
+ ("/quit", "退出"),
34
+ ("/config", "查看当前配置"),
35
+ ("/config set api-key ", "设置 API Key"),
36
+ ("/config set model ", "设置模型"),
37
+ ("/config set thinking ", "开关思考模式 (on/off)"),
38
+ ("/config set max-tokens ", "设置最大 token 数"),
39
+ ("/model", "查看/切换模型"),
40
+ ("/thinking", "查看/开关思考模式"),
41
+ ("/show-reasoning", "开关思考过程显示"),
42
+ ]
43
+
44
+
45
+ # ─── 输入历史(↑↓ 翻看用)────────────────────────────────
46
+ # 模块级变量,跨多次 ansi_prompt 调用共享。
47
+ HISTORY: list = []
48
+ HISTORY_MAX = 100
49
+
50
+
51
+ # ─── 补全菜单最大可见行数(根据终端高度动态计算)────────────
52
+ # 旧版本写死 8,COMMANDS 列表有 12 个 → 后 4 个被截断看不见。
53
+ # 现在根据 os.get_terminal_size().lines 算:留 3 行给 prompt / spinner / status。
54
+ _MIN_VISIBLE = 3
55
+ _RESERVED_LINES = 3
56
+
57
+
58
+ def compute_max_visible() -> int:
59
+ """根据终端高度算补全菜单最大可见行数。
60
+ - 大屏:尽量显示所有项(避免截断)
61
+ - 小屏:至少留 3 行供 prompt 等使用
62
+ - 非 TTY 环境:fallback 到 20(够所有命令 + 文件)
63
+ """
64
+ try:
65
+ term_h = os.get_terminal_size().lines
66
+ except (OSError, AttributeError):
67
+ return 20
68
+ return max(_MIN_VISIBLE, term_h - _RESERVED_LINES)
69
+
70
+
71
+ # ── 补全数据源 ───────────────────────────────────────
72
+ def get_file_completions(partial: str) -> List[Tuple[str, str]]:
73
+ cwd = os.getcwd()
74
+ try:
75
+ entries = sorted(os.listdir(cwd))
76
+ except OSError:
77
+ return []
78
+ results = []
79
+ search = partial.lower()
80
+ for name in entries:
81
+ full = os.path.join(cwd, name)
82
+ if not os.path.isfile(full):
83
+ continue
84
+ if name.startswith("."):
85
+ continue
86
+ if _fuzzy_match(search, name):
87
+ try:
88
+ size = os.path.getsize(full)
89
+ if size < 1024:
90
+ meta = f"{size}B"
91
+ elif size < 1024 * 1024:
92
+ meta = f"{size // 1024}KB"
93
+ else:
94
+ meta = f"{size // (1024 * 1024)}MB"
95
+ except OSError:
96
+ meta = ""
97
+ results.append((name, meta))
98
+ return results
99
+
100
+
101
+ def get_command_completions(partial: str) -> List[Tuple[str, str]]:
102
+ results = []
103
+ for cmd_name, cmd_desc in COMMANDS:
104
+ if _fuzzy_match(partial, cmd_name):
105
+ results.append((cmd_name, cmd_desc))
106
+ return results
107
+
108
+
109
+ # ─── @ 上下文检测 ─────────────────────────────────────────
110
+ def find_at_context(text_before_cursor: str) -> Optional[Tuple[int, str]]:
111
+ at_positions = []
112
+ for i, ch in enumerate(text_before_cursor):
113
+ if ch == "@":
114
+ if i > 0 and text_before_cursor[i - 1] not in (" ", "\n", "\t"):
115
+ continue
116
+ at_positions.append(i)
117
+ if not at_positions:
118
+ return None
119
+ last_at = at_positions[-1]
120
+ partial = text_before_cursor[last_at + 1:]
121
+ if " " in partial:
122
+ return None
123
+ return last_at, partial
124
+
125
+
126
+ # ─── ANSI 颜色工具 ─────────────────────────────────────
127
+ def ansi_white(s: str) -> str:
128
+ return f"\x1b[97m{s}\x1b[0m"
129
+
130
+
131
+ def ansi_selected(s: str) -> str:
132
+ return f"\x1b[7;97m{s}\x1b[0m"
133
+
134
+
135
+ def ansi_dim(s: str) -> str:
136
+ return f"\x1b[2m{s}\x1b[0m"
137
+
138
+
139
+ def ansi_move_up(n: int) -> str:
140
+ return f"\x1b[{n}A" if n > 0 else ""
141
+
142
+
143
+ def ansi_move_down(n: int) -> str:
144
+ return f"\x1b[{n}B" if n > 0 else ""
145
+
146
+
147
+ def ansi_move_left(n: int) -> str:
148
+ return f"\x1b[{n}D" if n > 0 else ""
149
+
150
+
151
+ def ansi_move_right(n: int) -> str:
152
+ return f"\x1b[{n}C" if n > 0 else ""
153
+
154
+
155
+ def ansi_clear_line() -> str:
156
+ return "\r\x1b[K"
157
+
158
+
159
+ def ansi_erase_lines_below(count: int) -> str:
160
+ out = ""
161
+ for _ in range(count):
162
+ out += "\r\x1b[K\n"
163
+ out += ansi_move_up(count)
164
+ return out
165
+
166
+
167
+ # ─── 交互式选择器 ──────────────────────────────────────
168
+ def ansi_select(prompt: str, options: list, default_idx: int = 0) -> "str | None":
169
+ """上下键选择 + Enter 确认 + Esc 取消。
170
+
171
+ Args:
172
+ prompt: 提示语(显示在选项上方)
173
+ options: 选项列表(字符串)
174
+ default_idx: 初始高亮项索引(默认 0)
175
+
176
+ Returns:
177
+ 选中的字符串;按 Esc 取消返回 None。
178
+ """
179
+ if not options:
180
+ return None
181
+
182
+ selected = max(0, min(default_idx, len(options) - 1))
183
+
184
+ def render():
185
+ """重绘整个选择区域:prompt + 选项列表 + 高亮当前"""
186
+ # 第一行先清掉
187
+ sys.stdout.write('\r\x1b[K')
188
+ sys.stdout.write(f" \033[1m{prompt}\033[0m\n")
189
+ for i, opt in enumerate(options):
190
+ sys.stdout.write('\r\x1b[K')
191
+ if i == selected:
192
+ # 选中行:反白 + 箭头
193
+ sys.stdout.write(f" \x1b[7;97m> {opt}\x1b[0m\n")
194
+ else:
195
+ sys.stdout.write(f" {opt}\n")
196
+ # 把光标移回 prompt 行(len(options) + 1:1 行 prompt + N 行选项)。
197
+ # 旧代码用 len(options) 会落到第一选项行,下次 render() 在选项行写新 prompt,
198
+ # 旧 prompt 永远不被清掉 → 屏幕上堆积多个 prompt。
199
+ sys.stdout.write(ansi_move_up(len(options) + 1))
200
+ sys.stdout.flush()
201
+
202
+ render()
203
+
204
+ while True:
205
+ try:
206
+ key = msvcrt.getwch()
207
+ except (OSError, ValueError):
208
+ time.sleep(0.01)
209
+ continue
210
+
211
+ # Ctrl+C
212
+ if key == '\x03':
213
+ sys.stdout.write(ansi_erase_lines_below(len(options) + 1))
214
+ raise KeyboardInterrupt
215
+
216
+ # 特殊键
217
+ if key == '\x00' or key == '\xe0':
218
+ special = msvcrt.getwch()
219
+ if special == 'H': # 上箭头
220
+ selected = max(0, selected - 1)
221
+ render()
222
+ elif special == 'P': # 下箭头
223
+ selected = min(len(options) - 1, selected + 1)
224
+ render()
225
+ continue
226
+
227
+ # Esc 取消
228
+ if key == '\x1b':
229
+ sys.stdout.write(ansi_erase_lines_below(len(options) + 1))
230
+ sys.stdout.write('\r\x1b[K')
231
+ sys.stdout.flush()
232
+ return None
233
+
234
+ # Enter 确认
235
+ if key == '\r' or key == '\n':
236
+ # 渲染最终状态(保持高亮在选中行)
237
+ sys.stdout.write(ansi_erase_lines_below(len(options) + 1))
238
+ sys.stdout.write('\r\x1b[K')
239
+ sys.stdout.write(f" \033[1m{prompt}\033[0m \033[32m> {options[selected]}\033[0m\n")
240
+ sys.stdout.flush()
241
+ return options[selected]
242
+
243
+ # 其他键忽略
244
+
245
+
246
+ # ── 主输入函数 ────────────────────────────────────────────
247
+ def ansi_prompt(prompt_str: str = "> ") -> str:
248
+ """
249
+ 自定义输入循环,支持 / 和 @ 补全菜单(ANSI 渲染)。
250
+ 返回用户输入的字符串。
251
+ Ctrl+C 抛出 KeyboardInterrupt。
252
+ """
253
+ # Windows: 清空控制台输入缓冲,避免上次输出污染导致首次按键被吞
254
+ if sys.platform == "win32":
255
+ try:
256
+ import ctypes
257
+ STD_INPUT_HANDLE = -10
258
+ handle = ctypes.windll.kernel32.GetStdHandle(STD_INPUT_HANDLE)
259
+ ctypes.windll.kernel32.FlushConsoleInputBuffer(handle)
260
+ except Exception:
261
+ pass
262
+
263
+ buf: list = list(prompt_str)
264
+ cursor_pos: int = len(buf)
265
+ prev_line_len: int = len(buf)
266
+ completions: List[Tuple[str, str]] = []
267
+ selected_idx: int = 0
268
+ showing_menu: bool = False
269
+ max_visible: int = compute_max_visible() # 根据终端高度动态算,避免截断
270
+ menu_row_count: int = 0
271
+ max_rows_ever: int = 0 # 追踪渲染过的最大行数,确保清除干净
272
+ # 历史翻看:history_idx = -1 表示不在导航态(停在草稿)
273
+ history_idx: int = -1
274
+ history_draft: str = "" # 进入导航前保存的当前 buffer,便于 ↓ 回到
275
+
276
+ def redraw_line():
277
+ """重绘当前输入行。
278
+ 关键:必须 \r\x1b[K(回行首 + 清行尾),不能只 \r。
279
+ 否则本行原本有内容(如上一轮的 ',请重试)' 残留)会被新输入部分覆盖,
280
+ 用户看到 '> /show-reasoning,请重试)' 这种新旧混杂的乱象。
281
+ """
282
+ nonlocal cursor_pos, buf, prev_line_len
283
+ # 回行首 + 清行尾:本行原内容(残留的 ',请重试)' 之类)彻底抹掉
284
+ sys.stdout.write('\r\x1b[K')
285
+ line = ''.join(buf)
286
+ sys.stdout.write(line)
287
+ prev_line_len = len(buf)
288
+ target_col = cursor_pos
289
+ current_col = len(buf)
290
+ if target_col < current_col:
291
+ sys.stdout.write(ansi_move_left(current_col - target_col))
292
+ sys.stdout.flush()
293
+
294
+ def clear_menu_area():
295
+ """清除菜单区域的所有行,光标回到输入行 cursor_pos 位置。
296
+ 只在已有菜单空间内操作(假定 show_menu 已用 \\n 创建了空间),使用 \\x1b[B 避免额外滚动。
297
+ """
298
+ nonlocal menu_row_count
299
+ if menu_row_count > 0:
300
+ # \x1b[B 到菜单首行(空间已由 show_menu 首次激活时创建)
301
+ sys.stdout.write('\x1b[B')
302
+ for i in range(menu_row_count):
303
+ sys.stdout.write('\r\x1b[K')
304
+ if i < menu_row_count - 1:
305
+ sys.stdout.write('\x1b[B')
306
+ # 上移回输入行
307
+ sys.stdout.write(ansi_move_up(menu_row_count))
308
+ sys.stdout.write('\r')
309
+ sys.stdout.write(ansi_move_right(cursor_pos))
310
+ sys.stdout.flush()
311
+ menu_row_count = 0
312
+
313
+ def hide_menu():
314
+ nonlocal showing_menu, menu_row_count, max_rows_ever, completions, selected_idx
315
+ # 用 menu_row_count 判断是否有菜单需要清除(showing_menu 可能已被 update_completions 置 False)
316
+ if menu_row_count > 0:
317
+ clear_menu_area()
318
+ showing_menu = False
319
+ completions = []
320
+ selected_idx = 0
321
+ max_rows_ever = 0 # 重置,下次激活重新滚动创建空间
322
+
323
+ def show_menu():
324
+ nonlocal showing_menu, menu_row_count, completions, selected_idx, max_rows_ever
325
+ if not completions:
326
+ hide_menu()
327
+ return
328
+
329
+ showing_menu = True
330
+ visible_count = min(len(completions), max_visible)
331
+ start_idx = 0
332
+ if selected_idx >= max_visible:
333
+ start_idx = selected_idx - max_visible + 1
334
+
335
+ # 仅在需要更多空间时滚动(首次激活或匹配项增多)
336
+ if visible_count > max_rows_ever:
337
+ extra = visible_count - max_rows_ever
338
+ for _ in range(extra):
339
+ sys.stdout.write('\n')
340
+ sys.stdout.write(ansi_move_up(extra))
341
+ max_rows_ever = visible_count
342
+ # 菜单已存在:清除旧内容(在已有空间内,用 \x1b[B 避免额外滚动)
343
+ elif menu_row_count > 0:
344
+ sys.stdout.write('\x1b[B')
345
+ for i in range(menu_row_count):
346
+ sys.stdout.write('\r\x1b[K')
347
+ if i < menu_row_count - 1:
348
+ sys.stdout.write('\x1b[B')
349
+ sys.stdout.write(ansi_move_up(menu_row_count))
350
+ sys.stdout.write('\r')
351
+ sys.stdout.write(ansi_move_right(cursor_pos))
352
+
353
+ # 重绘输入行
354
+ sys.stdout.write('\r\x1b[K' + ''.join(buf))
355
+
356
+ # 绘制菜单项(在已有空间内,用 \x1b[B)
357
+ sys.stdout.write('\x1b[B')
358
+ for i in range(visible_count):
359
+ idx = start_idx + i
360
+ if idx >= len(completions):
361
+ break
362
+ name, meta = completions[idx]
363
+ is_selected = (idx == selected_idx)
364
+ if is_selected:
365
+ line = f" {ansi_selected(name)} {ansi_dim(meta)}"
366
+ else:
367
+ line = f" {ansi_white(name)} {ansi_dim(meta)}"
368
+ sys.stdout.write('\r\x1b[K' + line)
369
+ if i < visible_count - 1:
370
+ sys.stdout.write('\x1b[B')
371
+
372
+ # 上移回输入行
373
+ sys.stdout.write(ansi_move_up(visible_count))
374
+ sys.stdout.write('\r')
375
+ sys.stdout.write(ansi_move_right(cursor_pos))
376
+ sys.stdout.flush()
377
+
378
+ menu_row_count = visible_count
379
+
380
+ def update_completions():
381
+ nonlocal completions, showing_menu, selected_idx
382
+ text_before = ''.join(buf[len(prompt_str):cursor_pos])
383
+
384
+ at_ctx = find_at_context(text_before)
385
+ if at_ctx:
386
+ _, partial = at_ctx
387
+ new_comps = get_file_completions(partial)
388
+ if new_comps:
389
+ completions = new_comps
390
+ selected_idx = 0
391
+ showing_menu = True
392
+ return
393
+ else:
394
+ completions = []
395
+ showing_menu = False
396
+ return
397
+
398
+ if text_before.startswith('/'):
399
+ new_comps = get_command_completions(text_before)
400
+ if new_comps:
401
+ completions = new_comps
402
+ selected_idx = 0
403
+ showing_menu = True
404
+ return
405
+ else:
406
+ completions = []
407
+ showing_menu = False
408
+ return
409
+
410
+ completions = []
411
+ showing_menu = False
412
+
413
+ # 打印初始提示符
414
+ sys.stdout.write(prompt_str)
415
+ sys.stdout.flush()
416
+
417
+ while True:
418
+ # 直接阻塞读取 msvcrt.getwch(),不要用 kbhit() + 紧密轮询。
419
+ # 5ms 紧密轮询会和 Windows 中文 IME 冲突,导致 IME 提交字符后
420
+ # 程序仍然读不到 Enter,从而"按回车无反应、卡死"。
421
+ # getwch() 在 Windows 上 Ctrl+C 不会自动抛异常,需靠 \x03 字符判断(已处理)。
422
+ try:
423
+ key = msvcrt.getwch()
424
+ except (OSError, ValueError):
425
+ time.sleep(0.01)
426
+ continue
427
+
428
+ # 特殊键(两字节序列)
429
+ if key == '\x00' or key == '\xe0':
430
+ special = msvcrt.getwch()
431
+ if special == 'H': # 上箭头
432
+ if showing_menu and completions:
433
+ selected_idx = max(0, selected_idx - 1)
434
+ show_menu()
435
+ elif HISTORY:
436
+ # 历史翻看:↑ 调到更早一条
437
+ if history_idx == -1:
438
+ # 第一次按 ↑:保存当前 buffer 为草稿
439
+ history_draft = ''.join(buf[len(prompt_str):])
440
+ history_idx = max(0, history_idx - 1) if history_idx != -1 else len(HISTORY) - 1
441
+ recall = HISTORY[history_idx]
442
+ buf = list(prompt_str) + list(recall)
443
+ cursor_pos = len(buf)
444
+ redraw_line()
445
+ continue
446
+ elif special == 'P': # 下箭头
447
+ if showing_menu and completions:
448
+ vis = min(len(completions), max_visible)
449
+ selected_idx = min(vis - 1, selected_idx + 1)
450
+ show_menu()
451
+ elif history_idx != -1:
452
+ # 历史翻看:↓ 调到更新一条(越界则回草稿)
453
+ history_idx += 1
454
+ if history_idx >= len(HISTORY):
455
+ # 越界:恢复草稿
456
+ buf = list(prompt_str) + list(history_draft)
457
+ cursor_pos = len(buf)
458
+ history_idx = -1
459
+ else:
460
+ recall = HISTORY[history_idx]
461
+ buf = list(prompt_str) + list(recall)
462
+ cursor_pos = len(buf)
463
+ redraw_line()
464
+ continue
465
+ elif special == 'K': # 左箭头
466
+ if cursor_pos > len(prompt_str):
467
+ cursor_pos -= 1
468
+ redraw_line()
469
+ continue
470
+ elif special == 'M': # 右箭头
471
+ if cursor_pos < len(buf):
472
+ cursor_pos += 1
473
+ redraw_line()
474
+ continue
475
+ continue
476
+
477
+ # Ctrl+C
478
+ if key == '\x03':
479
+ sys.stdout.write('\r\x1b[K\n')
480
+ raise KeyboardInterrupt
481
+
482
+ # Enter
483
+ if key == '\r' or key == '\n':
484
+ if showing_menu and completions:
485
+ sel_text = completions[selected_idx][0]
486
+ text_before = ''.join(buf[len(prompt_str):cursor_pos])
487
+ at_ctx = find_at_context(text_before)
488
+ if at_ctx:
489
+ start = len(prompt_str) + at_ctx[0]
490
+ elif text_before.startswith('/'):
491
+ start = len(prompt_str)
492
+ else:
493
+ start = len(prompt_str)
494
+ buf[start:cursor_pos] = list(sel_text)
495
+ cursor_pos = start + len(sel_text)
496
+ hide_menu()
497
+ redraw_line()
498
+ else:
499
+ result = ''.join(buf[len(prompt_str):])
500
+ # 入历史(非空 + 不与最后一条重复)
501
+ if result and (not HISTORY or HISTORY[-1] != result):
502
+ HISTORY.append(result)
503
+ if len(HISTORY) > HISTORY_MAX:
504
+ HISTORY.pop(0)
505
+ # 重置历史导航状态
506
+ history_idx = -1
507
+ history_draft = ""
508
+ sys.stdout.write('\n')
509
+ sys.stdout.flush()
510
+ return result
511
+ continue
512
+
513
+ # Backspace
514
+ if key == '\x08' or key == '\x7f':
515
+ if cursor_pos > len(prompt_str):
516
+ cursor_pos -= 1
517
+ buf.pop(cursor_pos)
518
+ redraw_line()
519
+ update_completions()
520
+ if showing_menu:
521
+ show_menu()
522
+ else:
523
+ hide_menu()
524
+ continue
525
+
526
+ # Tab - 确认补全
527
+ if key == '\t':
528
+ if showing_menu and completions:
529
+ sel_text = completions[selected_idx][0]
530
+ text_before = ''.join(buf[len(prompt_str):cursor_pos])
531
+ at_ctx = find_at_context(text_before)
532
+ if at_ctx:
533
+ start = len(prompt_str) + at_ctx[0]
534
+ elif text_before.startswith('/'):
535
+ start = len(prompt_str)
536
+ else:
537
+ start = len(prompt_str)
538
+ buf[start:cursor_pos] = list(sel_text)
539
+ cursor_pos = start + len(sel_text)
540
+ hide_menu()
541
+ redraw_line()
542
+ continue
543
+
544
+ # Escape - 关闭菜单
545
+ if key == '\x1b':
546
+ if showing_menu:
547
+ hide_menu()
548
+ redraw_line()
549
+ continue
550
+
551
+ # 普通可打印字符
552
+ if len(key) == 1 and key.isprintable():
553
+ buf.insert(cursor_pos, key)
554
+ cursor_pos += 1
555
+ redraw_line()
556
+ update_completions()
557
+ if showing_menu:
558
+ show_menu()
559
+ continue
abyss/api_client.py ADDED
@@ -0,0 +1,123 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ DeepSeek API 客户端封装
4
+ 支持流式输出、思考模式、工具调用、错误重试
5
+ """
6
+ import os
7
+ import time
8
+ from typing import List, Dict, Any, Iterator
9
+ from openai import OpenAI, APIError, RateLimitError, APIConnectionError, InternalServerError
10
+ from . import logger
11
+
12
+
13
+ class DeepSeekClient:
14
+ """DeepSeek API 客户端封装,支持流式输出、工具调用和错误重试。"""
15
+
16
+ # 可重试的错误码
17
+ RETRYABLE_CODES = {429, 500, 503}
18
+
19
+ def __init__(self, api_key: str = None, base_url: str = None):
20
+ self.api_key = api_key or os.environ.get("DEEPSEEK_API_KEY")
21
+ self.base_url = base_url or "https://api.deepseek.com"
22
+ self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)
23
+ self.max_retries = 3
24
+ self.retry_delay = 1.0
25
+
26
+ def chat(
27
+ self,
28
+ messages: List[Dict[str, Any]],
29
+ model: str = "deepseek-v4-pro",
30
+ tools: List[Dict] = None,
31
+ stream: bool = True,
32
+ thinking: bool = True,
33
+ reasoning_effort: str = "high",
34
+ max_tokens: int = None,
35
+ user_id: str = None
36
+ ) -> Iterator[Dict]:
37
+ """
38
+ 发送对话请求,返回迭代器。
39
+
40
+ Args:
41
+ messages: 对话消息列表
42
+ model: 模型名称
43
+ tools: 工具 schema 列表
44
+ stream: 是否流式输出
45
+ thinking: 是否启用思考模式
46
+ reasoning_effort: 推理强度 high/max
47
+ max_tokens: 最大生成 token 数
48
+ user_id: 自定义用户ID,用于隔离
49
+ """
50
+ extra_body = {"thinking": {"type": "enabled" if thinking else "disabled"}}
51
+ if user_id:
52
+ extra_body["user_id"] = user_id
53
+
54
+ kwargs = {
55
+ "model": model,
56
+ "messages": messages,
57
+ "stream": stream,
58
+ "reasoning_effort": reasoning_effort,
59
+ "extra_body": extra_body,
60
+ }
61
+ if tools:
62
+ kwargs["tools"] = tools
63
+ if max_tokens:
64
+ kwargs["max_tokens"] = max_tokens
65
+
66
+ logger.api_request(model=model, msgs=len(messages),
67
+ tools=len(tools) if tools else 0,
68
+ thinking=thinking)
69
+
70
+ for attempt in range(self.max_retries):
71
+ try:
72
+ response = self.client.chat.completions.create(**kwargs)
73
+ if stream:
74
+ for chunk in response:
75
+ yield self._parse_chunk(chunk)
76
+ else:
77
+ yield self._parse_response(response)
78
+ return
79
+ except RateLimitError as e:
80
+ if attempt < self.max_retries - 1:
81
+ wait = self.retry_delay * (2 ** attempt)
82
+ logger.api_retry(attempt + 1, wait, "rate_limit")
83
+ time.sleep(wait)
84
+ continue
85
+ raise e
86
+ except (APIConnectionError, InternalServerError) as e:
87
+ if attempt < self.max_retries - 1:
88
+ wait = self.retry_delay * (2 ** attempt)
89
+ logger.api_retry(attempt + 1, wait, type(e).__name__)
90
+ time.sleep(wait)
91
+ continue
92
+ raise e
93
+ except APIError as e:
94
+ if e.status_code in self.RETRYABLE_CODES and attempt < self.max_retries - 1:
95
+ wait = self.retry_delay * (2 ** attempt)
96
+ logger.api_retry(attempt + 1, wait, f"http_{e.status_code}")
97
+ time.sleep(wait)
98
+ continue
99
+ raise e
100
+
101
+ def _parse_chunk(self, chunk) -> Dict[str, Any]:
102
+ """解析流式 chunk"""
103
+ delta = chunk.choices[0].delta if chunk.choices else None
104
+ return {
105
+ "type": "chunk",
106
+ "content": getattr(delta, "content", None),
107
+ "reasoning_content": getattr(delta, "reasoning_content", None),
108
+ "tool_calls": getattr(delta, "tool_calls", None),
109
+ "finish_reason": chunk.choices[0].finish_reason if chunk.choices else None,
110
+ "usage": getattr(chunk, "usage", None)
111
+ }
112
+
113
+ def _parse_response(self, response) -> Dict[str, Any]:
114
+ """解析非流式响应"""
115
+ msg = response.choices[0].message
116
+ return {
117
+ "type": "complete",
118
+ "content": getattr(msg, "content", None),
119
+ "reasoning_content": getattr(msg, "reasoning_content", None),
120
+ "tool_calls": getattr(msg, "tool_calls", None),
121
+ "finish_reason": response.choices[0].finish_reason,
122
+ "usage": getattr(response, "usage", None)
123
+ }
@@ -0,0 +1,12 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ 命令模块
4
+ 提供 Slash Commands 等用户快捷指令机制
5
+ """
6
+ from .slash import SlashCommand, SlashCommandRegistry, create_default_registry
7
+
8
+ __all__ = [
9
+ "SlashCommand",
10
+ "SlashCommandRegistry",
11
+ "create_default_registry",
12
+ ]