chcode 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.
- chcode/__init__.py +0 -0
- chcode/__main__.py +5 -0
- chcode/agent_setup.py +395 -0
- chcode/agents/__init__.py +0 -0
- chcode/agents/definitions.py +158 -0
- chcode/agents/loader.py +104 -0
- chcode/agents/runner.py +159 -0
- chcode/chat.py +1630 -0
- chcode/cli.py +142 -0
- chcode/config.py +571 -0
- chcode/display.py +325 -0
- chcode/prompts.py +640 -0
- chcode/session.py +149 -0
- chcode/skill_manager.py +165 -0
- chcode/utils/__init__.py +3 -0
- chcode/utils/enhanced_chat_openai.py +368 -0
- chcode/utils/git_checker.py +38 -0
- chcode/utils/git_manager.py +261 -0
- chcode/utils/modelscope_ratelimit.py +65 -0
- chcode/utils/multimodal.py +268 -0
- chcode/utils/shell/__init__.py +17 -0
- chcode/utils/shell/output.py +63 -0
- chcode/utils/shell/provider.py +128 -0
- chcode/utils/shell/result.py +14 -0
- chcode/utils/shell/semantics.py +55 -0
- chcode/utils/shell/session.py +159 -0
- chcode/utils/skill_loader.py +565 -0
- chcode/utils/text_utils.py +14 -0
- chcode/utils/tool_result_pipeline.py +244 -0
- chcode/utils/tools.py +1724 -0
- chcode/vision_config.py +371 -0
- chcode-0.1.0.dist-info/METADATA +275 -0
- chcode-0.1.0.dist-info/RECORD +36 -0
- chcode-0.1.0.dist-info/WHEEL +4 -0
- chcode-0.1.0.dist-info/entry_points.txt +2 -0
- chcode-0.1.0.dist-info/licenses/LICENSE +21 -0
chcode/session.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""
|
|
2
|
+
会话管理 — thread_id, checkpointer DB, 历史会话列表/加载/删除/重命名
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from langgraph.graph.state import CompiledStateGraph
|
|
17
|
+
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
|
18
|
+
|
|
19
|
+
from langchain_core.messages import HumanMessage
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
_SUMMARY_MAX_LEN = 40
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class SessionManager:
|
|
27
|
+
def __init__(self, workplace_path: Path):
|
|
28
|
+
self.workplace_path = workplace_path
|
|
29
|
+
self.sessions_dir = workplace_path / ".chat" / "sessions"
|
|
30
|
+
self.sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
self.thread_id = self._new_thread_id()
|
|
32
|
+
self._names_path = self.sessions_dir / "names.json"
|
|
33
|
+
|
|
34
|
+
# ─── names.json 读写 ─────────────────────────────────
|
|
35
|
+
|
|
36
|
+
def _load_names(self) -> dict[str, str]:
|
|
37
|
+
if self._names_path.exists():
|
|
38
|
+
try:
|
|
39
|
+
return json.loads(self._names_path.read_text("utf-8"))
|
|
40
|
+
except Exception:
|
|
41
|
+
return {}
|
|
42
|
+
return {}
|
|
43
|
+
|
|
44
|
+
def _save_names(self, names: dict[str, str]) -> None:
|
|
45
|
+
self._names_path.write_text(
|
|
46
|
+
json.dumps(names, ensure_ascii=False, indent=2), "utf-8"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# ─── 基础 ────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
def _new_thread_id(self) -> str:
|
|
52
|
+
return f"thread_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}"
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def config(self) -> dict:
|
|
56
|
+
return {"configurable": {"thread_id": self.thread_id}}
|
|
57
|
+
|
|
58
|
+
def new_session(self) -> None:
|
|
59
|
+
self.thread_id = self._new_thread_id()
|
|
60
|
+
|
|
61
|
+
def set_thread(self, thread_id: str) -> None:
|
|
62
|
+
self.thread_id = thread_id
|
|
63
|
+
|
|
64
|
+
# ─── 重命名 ──────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
def rename_session(self, thread_id: str, new_name: str) -> None:
|
|
67
|
+
names = self._load_names()
|
|
68
|
+
if new_name:
|
|
69
|
+
names[thread_id] = new_name
|
|
70
|
+
else:
|
|
71
|
+
names.pop(thread_id, None)
|
|
72
|
+
self._save_names(names)
|
|
73
|
+
|
|
74
|
+
# ─── 显示名 ──────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
async def _get_summary(
|
|
77
|
+
self, agent: CompiledStateGraph, thread_id: str
|
|
78
|
+
) -> str | None:
|
|
79
|
+
cfg = {"configurable": {"thread_id": thread_id}}
|
|
80
|
+
try:
|
|
81
|
+
from chcode.utils import get_text_content
|
|
82
|
+
state = await agent.aget_state(cfg)
|
|
83
|
+
messages = state.values.get("messages", [])
|
|
84
|
+
for msg in messages:
|
|
85
|
+
if isinstance(msg, HumanMessage):
|
|
86
|
+
if not isinstance(msg.content, (str, list)):
|
|
87
|
+
continue
|
|
88
|
+
text = get_text_content(msg.content)
|
|
89
|
+
text = text.strip().replace("\n", " ")
|
|
90
|
+
if text:
|
|
91
|
+
return text[:_SUMMARY_MAX_LEN] + (
|
|
92
|
+
"…" if len(text) > _SUMMARY_MAX_LEN else ""
|
|
93
|
+
)
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
async def get_display_names(
|
|
99
|
+
self,
|
|
100
|
+
thread_ids: list[str],
|
|
101
|
+
agent: CompiledStateGraph,
|
|
102
|
+
) -> dict[str, str]:
|
|
103
|
+
names = self._load_names()
|
|
104
|
+
result: dict[str, str] = {}
|
|
105
|
+
need_summary: list[str] = []
|
|
106
|
+
|
|
107
|
+
for tid in thread_ids:
|
|
108
|
+
if tid in names:
|
|
109
|
+
result[tid] = names[tid]
|
|
110
|
+
else:
|
|
111
|
+
need_summary.append(tid)
|
|
112
|
+
|
|
113
|
+
if need_summary:
|
|
114
|
+
summaries = await asyncio.gather(
|
|
115
|
+
*[self._get_summary(agent, tid) for tid in need_summary]
|
|
116
|
+
)
|
|
117
|
+
for tid, summary in zip(need_summary, summaries):
|
|
118
|
+
result[tid] = summary or tid
|
|
119
|
+
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
# ─── 列表 / 删除 ─────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
async def list_sessions(self, checkpointer: AsyncSqliteSaver) -> list[str]:
|
|
125
|
+
"""从 checkpointer 获取所有历史 thread_id"""
|
|
126
|
+
try:
|
|
127
|
+
await checkpointer.setup()
|
|
128
|
+
async with checkpointer.lock:
|
|
129
|
+
rows = await checkpointer.conn.execute_fetchall(
|
|
130
|
+
"SELECT DISTINCT thread_id FROM checkpoints"
|
|
131
|
+
)
|
|
132
|
+
return [row[0] for row in rows if row[0]]
|
|
133
|
+
except Exception:
|
|
134
|
+
return []
|
|
135
|
+
|
|
136
|
+
async def delete_session(
|
|
137
|
+
self, thread_id: str, checkpointer: AsyncSqliteSaver
|
|
138
|
+
) -> bool:
|
|
139
|
+
"""删除指定会话的所有数据"""
|
|
140
|
+
try:
|
|
141
|
+
await checkpointer.adelete_thread(thread_id)
|
|
142
|
+
names = self._load_names()
|
|
143
|
+
if thread_id in names:
|
|
144
|
+
del names[thread_id]
|
|
145
|
+
self._save_names(names)
|
|
146
|
+
return True
|
|
147
|
+
except Exception as e:
|
|
148
|
+
console.print(f"[red]删除会话失败: {e}[/red]")
|
|
149
|
+
return False
|
chcode/skill_manager.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""
|
|
2
|
+
技能管理 — 扫描/列表/查看详情/安装/删除,全部用下拉列表交互
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.markdown import Markdown
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
from chcode.prompts import select, confirm, text
|
|
16
|
+
from chcode.utils.skill_loader import (
|
|
17
|
+
scan_all_skills,
|
|
18
|
+
validate_skill_package,
|
|
19
|
+
install_skill,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from chcode.session import SessionManager
|
|
24
|
+
|
|
25
|
+
console = Console()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def manage_skills(session: SessionManager) -> None:
|
|
29
|
+
"""技能管理主菜单"""
|
|
30
|
+
while True:
|
|
31
|
+
action = await select(
|
|
32
|
+
"技能管理:",
|
|
33
|
+
["查看已安装技能", "安装新技能", "返回"],
|
|
34
|
+
)
|
|
35
|
+
if action is None or action == "返回":
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
if action == "查看已安装技能":
|
|
39
|
+
await _list_skills(session)
|
|
40
|
+
elif action == "安装新技能":
|
|
41
|
+
await _install_skill(session)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def _list_skills(session: SessionManager) -> None:
|
|
45
|
+
"""列出所有已安装技能,支持下拉选择操作"""
|
|
46
|
+
skills = scan_all_skills(session.workplace_path)
|
|
47
|
+
if not skills:
|
|
48
|
+
console.print("[yellow]没有发现已安装的技能[/yellow]")
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
# 构建表格
|
|
52
|
+
table = Table(title="已安装技能")
|
|
53
|
+
table.add_column("名称", style="cyan")
|
|
54
|
+
table.add_column("类型", style="green")
|
|
55
|
+
table.add_column("描述", style="white")
|
|
56
|
+
table.add_column("路径", style="dim")
|
|
57
|
+
for s in skills:
|
|
58
|
+
desc = s["description"]
|
|
59
|
+
if len(desc) > 60:
|
|
60
|
+
desc = desc[:57] + "..."
|
|
61
|
+
table.add_row(s["name"], s["type"], desc, str(s["path"]))
|
|
62
|
+
console.print(table)
|
|
63
|
+
|
|
64
|
+
# 选择操作
|
|
65
|
+
names = [f"{s['name']} ({s['type']})" for s in skills]
|
|
66
|
+
action = await select(
|
|
67
|
+
"选择技能进行操作:",
|
|
68
|
+
names + ["返回"],
|
|
69
|
+
)
|
|
70
|
+
if action is None or action == "返回":
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
# 找到选中的技能
|
|
74
|
+
selected_name = action.split(" (")[0]
|
|
75
|
+
skill = next((s for s in skills if s["name"] == selected_name), None)
|
|
76
|
+
if not skill:
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
op = await select(
|
|
80
|
+
f"对技能 '{skill['name']}' 的操作:",
|
|
81
|
+
["查看详情", "删除技能", "返回"],
|
|
82
|
+
)
|
|
83
|
+
if op == "查看详情":
|
|
84
|
+
await _show_skill_detail(skill)
|
|
85
|
+
elif op == "删除技能":
|
|
86
|
+
await _delete_skill(skill, session)
|
|
87
|
+
elif op == "返回":
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def _show_skill_detail(skill: dict) -> None:
|
|
92
|
+
"""查看技能详情"""
|
|
93
|
+
skill_md = Path(skill["path"]) / "SKILL.md"
|
|
94
|
+
if not skill_md.exists():
|
|
95
|
+
console.print("[red]技能文件不存在[/red]")
|
|
96
|
+
return
|
|
97
|
+
|
|
98
|
+
content = skill_md.read_text(encoding="utf-8")
|
|
99
|
+
console.print(
|
|
100
|
+
Panel(
|
|
101
|
+
Markdown(content),
|
|
102
|
+
title=f"技能: {skill['name']}",
|
|
103
|
+
border_style="cyan",
|
|
104
|
+
padding=(1, 2),
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def _delete_skill(skill: dict, session: SessionManager) -> None:
|
|
110
|
+
"""删除技能"""
|
|
111
|
+
ok = await confirm(
|
|
112
|
+
f"确定删除技能 '{skill['name']}'?此操作不可撤销!", default=False
|
|
113
|
+
)
|
|
114
|
+
if not ok:
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
import shutil
|
|
118
|
+
|
|
119
|
+
skill_path = Path(skill["path"])
|
|
120
|
+
try:
|
|
121
|
+
shutil.rmtree(skill_path)
|
|
122
|
+
console.print(f"[green]技能 '{skill['name']}' 已删除[/green]")
|
|
123
|
+
except Exception as e:
|
|
124
|
+
console.print(f"[red]删除失败: {e}[/red]")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def _install_skill(session: SessionManager) -> None:
|
|
128
|
+
"""安装技能"""
|
|
129
|
+
file_path = await text("输入技能压缩包路径 (.zip/.tar.gz/.tgz):")
|
|
130
|
+
if not file_path:
|
|
131
|
+
return
|
|
132
|
+
|
|
133
|
+
path = Path(file_path)
|
|
134
|
+
if not path.exists():
|
|
135
|
+
console.print("[red]文件不存在[/red]")
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
# 验证
|
|
139
|
+
console.print("[yellow]验证技能包...[/yellow]")
|
|
140
|
+
skill_info = validate_skill_package(str(path))
|
|
141
|
+
if not skill_info:
|
|
142
|
+
console.print("[red]无效的技能包,必须包含 SKILL.md[/red]")
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
# 选择安装位置
|
|
146
|
+
location = await select(
|
|
147
|
+
"选择安装位置:",
|
|
148
|
+
["项目级 (当前工作目录)", "全局级 (用户目录)"],
|
|
149
|
+
)
|
|
150
|
+
if location is None:
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
if "项目级" in location:
|
|
154
|
+
install_path = session.workplace_path / ".chat" / "skills"
|
|
155
|
+
else:
|
|
156
|
+
install_path = Path.home() / ".chat" / "skills"
|
|
157
|
+
|
|
158
|
+
install_path.mkdir(parents=True, exist_ok=True)
|
|
159
|
+
|
|
160
|
+
console.print("[yellow]安装中...[/yellow]")
|
|
161
|
+
if install_skill(str(path), install_path):
|
|
162
|
+
name = skill_info["name"]
|
|
163
|
+
console.print(f"[green]技能 '{name}' 安装成功![/green]")
|
|
164
|
+
else:
|
|
165
|
+
console.print("[red]安装失败[/red]")
|
chcode/utils/__init__.py
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Enhanced ChatOpenAI with support for third-party model reasoning content.
|
|
3
|
+
|
|
4
|
+
This module extends langchain_openai's ChatOpenAI to support reasoning/thinking
|
|
5
|
+
content from third-party models like Qwen, GLM, DeepSeek, etc.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any, ClassVar
|
|
11
|
+
|
|
12
|
+
from langchain_core.messages import AIMessage
|
|
13
|
+
from langchain_core.outputs import ChatResult
|
|
14
|
+
from langchain_openai import ChatOpenAI
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EnhancedChatOpenAI(ChatOpenAI):
|
|
18
|
+
"""Enhanced ChatOpenAI with third-party model reasoning support.
|
|
19
|
+
|
|
20
|
+
This class extends ChatOpenAI to support reasoning/thinking content
|
|
21
|
+
from third-party models (Qwen, GLM, DeepSeek, etc.) that use different
|
|
22
|
+
field names for reasoning output.
|
|
23
|
+
|
|
24
|
+
Supported reasoning fields:
|
|
25
|
+
- reasoning_content (Qwen)
|
|
26
|
+
- thinking (Generic)
|
|
27
|
+
- reasoning (DeepSeek)
|
|
28
|
+
- thought (GLM)
|
|
29
|
+
- thought_process (Custom)
|
|
30
|
+
|
|
31
|
+
Example:
|
|
32
|
+
```python
|
|
33
|
+
from enhanced_chat_openai import EnhancedChatOpenAI
|
|
34
|
+
|
|
35
|
+
# For Qwen models
|
|
36
|
+
model = EnhancedChatOpenAI(
|
|
37
|
+
model="qwen-plus",
|
|
38
|
+
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
39
|
+
api_key="your-api-key",
|
|
40
|
+
reasoning_field="reasoning_content" # Qwen uses this field
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
response = model.invoke("What is 2+2?")
|
|
44
|
+
|
|
45
|
+
# Access reasoning content
|
|
46
|
+
reasoning = response.additional_kwargs.get("reasoning")
|
|
47
|
+
print(f"Reasoning: {reasoning}")
|
|
48
|
+
|
|
49
|
+
Note on streaming:
|
|
50
|
+
When using streaming mode, the reasoning content is accumulated across
|
|
51
|
+
all chunks and stored in the final message's additional_kwargs.
|
|
52
|
+
You can access it after the stream completes:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
reasoning_parts = []
|
|
56
|
+
for chunk in model.stream(messages):
|
|
57
|
+
# Access reasoning from each chunk if needed
|
|
58
|
+
if "reasoning" in chunk.additional_kwargs:
|
|
59
|
+
reasoning_parts.append(chunk.additional_kwargs["reasoning"])
|
|
60
|
+
|
|
61
|
+
full_reasoning = "".join(reasoning_parts)
|
|
62
|
+
```
|
|
63
|
+
print(f"Answer: {response.content}")
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
reasoning_field: Field name for reasoning content in API response.
|
|
68
|
+
Options: "reasoning_content", "thinking", "reasoning", "thought",
|
|
69
|
+
"thought_process", or "auto" (auto-detect, default)
|
|
70
|
+
include_reasoning_in_content: Whether to prepend reasoning to content.
|
|
71
|
+
Default: False (reasoning stored only in additional_kwargs)
|
|
72
|
+
reasoning_separator: Separator between reasoning and content when included.
|
|
73
|
+
Default: "\n\n---\n\n"
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
reasoning_field: str = "auto"
|
|
77
|
+
include_reasoning_in_content: bool = False
|
|
78
|
+
reasoning_separator: str = "\n\n---\n\n"
|
|
79
|
+
|
|
80
|
+
# Known reasoning field mappings for different providers
|
|
81
|
+
REASONING_FIELDS: ClassVar[list[str]] = [
|
|
82
|
+
"reasoning_content", # Qwen / Alibaba
|
|
83
|
+
"thinking", # Generic / Anthropic-style
|
|
84
|
+
"reasoning", # DeepSeek / OpenAI o1
|
|
85
|
+
"thought", # GLM / Zhipu
|
|
86
|
+
"thought_process", # Custom
|
|
87
|
+
"reasoning_text", # Alternative
|
|
88
|
+
"thought_content", # Alternative
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
def _extract_reasoning(self, data: dict[str, Any]) -> str | None:
|
|
92
|
+
"""Extract reasoning content from API response data.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
data: Message dictionary from API response
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Reasoning content string or None if not found
|
|
99
|
+
"""
|
|
100
|
+
# If specific field configured, try that first
|
|
101
|
+
if self.reasoning_field != "auto":
|
|
102
|
+
if self.reasoning_field in data:
|
|
103
|
+
return data[self.reasoning_field]
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
# Auto-detect: try all known fields
|
|
107
|
+
for field in self.REASONING_FIELDS:
|
|
108
|
+
if field in data and data[field]:
|
|
109
|
+
return data[field]
|
|
110
|
+
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
def _process_message_with_reasoning(
|
|
114
|
+
self, message: dict[str, Any]
|
|
115
|
+
) -> dict[str, Any]:
|
|
116
|
+
"""Process message to extract and format reasoning content.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
message: Raw message dict from API
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Processed message dict
|
|
123
|
+
"""
|
|
124
|
+
# Extract reasoning
|
|
125
|
+
reasoning = self._extract_reasoning(message)
|
|
126
|
+
|
|
127
|
+
if reasoning:
|
|
128
|
+
# Store in additional_kwargs
|
|
129
|
+
if "additional_kwargs" not in message:
|
|
130
|
+
message["additional_kwargs"] = {}
|
|
131
|
+
message["additional_kwargs"]["reasoning"] = reasoning
|
|
132
|
+
|
|
133
|
+
# Optionally include in content
|
|
134
|
+
if self.include_reasoning_in_content and message.get("content"):
|
|
135
|
+
message["content"] = (
|
|
136
|
+
f"{reasoning}{self.reasoning_separator}{message['content']}"
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Remove original reasoning field from message
|
|
140
|
+
for field in self.REASONING_FIELDS:
|
|
141
|
+
if field in message:
|
|
142
|
+
del message[field]
|
|
143
|
+
|
|
144
|
+
return message
|
|
145
|
+
|
|
146
|
+
def _extract_reasoning_from_message(self, message: Any) -> str | None:
|
|
147
|
+
"""Extract reasoning content from message object.
|
|
148
|
+
|
|
149
|
+
Checks multiple sources:
|
|
150
|
+
1. reasoning_content field (Qwen style)
|
|
151
|
+
2. content_blocks with type='thinking'
|
|
152
|
+
3. reasoning field (DeepSeek style)
|
|
153
|
+
"""
|
|
154
|
+
if message is None:
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
reasoning_parts = []
|
|
158
|
+
|
|
159
|
+
# Method 1: Direct reasoning_content field
|
|
160
|
+
if hasattr(message, "reasoning_content") and message.reasoning_content:
|
|
161
|
+
if isinstance(message.reasoning_content, str):
|
|
162
|
+
reasoning_parts.append(message.reasoning_content)
|
|
163
|
+
|
|
164
|
+
# Method 2: content_blocks with thinking type
|
|
165
|
+
if hasattr(message, "content_blocks") and message.content_blocks:
|
|
166
|
+
for block in message.content_blocks:
|
|
167
|
+
# Handle both object and dict formats
|
|
168
|
+
block_type = None
|
|
169
|
+
thinking_content = None
|
|
170
|
+
|
|
171
|
+
if hasattr(block, "type"):
|
|
172
|
+
block_type = block.type
|
|
173
|
+
elif isinstance(block, dict):
|
|
174
|
+
block_type = block.get("type")
|
|
175
|
+
|
|
176
|
+
if block_type == "thinking":
|
|
177
|
+
if hasattr(block, "thinking"):
|
|
178
|
+
thinking_content = block.thinking
|
|
179
|
+
elif isinstance(block, dict):
|
|
180
|
+
thinking_content = block.get("thinking")
|
|
181
|
+
|
|
182
|
+
if thinking_content and isinstance(thinking_content, str):
|
|
183
|
+
reasoning_parts.append(thinking_content)
|
|
184
|
+
|
|
185
|
+
# Method 3: Check model_dump/dict for reasoning_content
|
|
186
|
+
if not reasoning_parts:
|
|
187
|
+
try:
|
|
188
|
+
if hasattr(message, "model_dump"):
|
|
189
|
+
data = message.model_dump()
|
|
190
|
+
if data.get("reasoning_content"):
|
|
191
|
+
reasoning_parts.append(data["reasoning_content"])
|
|
192
|
+
elif hasattr(message, "dict"):
|
|
193
|
+
data = message.dict()
|
|
194
|
+
if data.get("reasoning_content"):
|
|
195
|
+
reasoning_parts.append(data["reasoning_content"])
|
|
196
|
+
except Exception:
|
|
197
|
+
pass
|
|
198
|
+
|
|
199
|
+
# Combine all reasoning parts
|
|
200
|
+
if reasoning_parts:
|
|
201
|
+
return "\n".join(reasoning_parts)
|
|
202
|
+
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
def _create_chat_result(
|
|
206
|
+
self, response: Any, generation_info: dict[str, Any] | None = None
|
|
207
|
+
) -> ChatResult:
|
|
208
|
+
"""Override to process reasoning content in response."""
|
|
209
|
+
# Extract reasoning from raw response before parent processing
|
|
210
|
+
reasoning_content = None
|
|
211
|
+
if hasattr(response, "choices") and response.choices:
|
|
212
|
+
try:
|
|
213
|
+
message = response.choices[0].message
|
|
214
|
+
reasoning_content = self._extract_reasoning_from_message(message)
|
|
215
|
+
except (AttributeError, IndexError):
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
# Get result from parent
|
|
219
|
+
result = super()._create_chat_result(response, generation_info)
|
|
220
|
+
|
|
221
|
+
# Add reasoning to the message's additional_kwargs and content_blocks
|
|
222
|
+
if result.generations:
|
|
223
|
+
for generation in result.generations:
|
|
224
|
+
if isinstance(generation.message, AIMessage):
|
|
225
|
+
# Add to additional_kwargs
|
|
226
|
+
if reasoning_content:
|
|
227
|
+
if "reasoning" not in generation.message.additional_kwargs:
|
|
228
|
+
generation.message.additional_kwargs["reasoning"] = (
|
|
229
|
+
reasoning_content
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Add to content_blocks if reasoning exists
|
|
233
|
+
if reasoning_content:
|
|
234
|
+
content_blocks = []
|
|
235
|
+
|
|
236
|
+
# Add thinking block
|
|
237
|
+
thinking_block = {
|
|
238
|
+
"type": "thinking",
|
|
239
|
+
"thinking": reasoning_content,
|
|
240
|
+
}
|
|
241
|
+
content_blocks.append(thinking_block)
|
|
242
|
+
|
|
243
|
+
# Add text block with actual content
|
|
244
|
+
if generation.message.content:
|
|
245
|
+
text_block = {
|
|
246
|
+
"type": "text",
|
|
247
|
+
"text": generation.message.content,
|
|
248
|
+
}
|
|
249
|
+
content_blocks.append(text_block)
|
|
250
|
+
|
|
251
|
+
# Store content_blocks in additional_kwargs
|
|
252
|
+
generation.message.additional_kwargs["content_blocks"] = (
|
|
253
|
+
content_blocks
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
break
|
|
257
|
+
|
|
258
|
+
return result
|
|
259
|
+
|
|
260
|
+
def _make_status_error_from_response(
|
|
261
|
+
self,
|
|
262
|
+
response: Any,
|
|
263
|
+
message: str,
|
|
264
|
+
*,
|
|
265
|
+
body: Any = None,
|
|
266
|
+
) -> Exception:
|
|
267
|
+
"""Override to handle reasoning in error responses."""
|
|
268
|
+
# Some providers include reasoning even in errors
|
|
269
|
+
if body and isinstance(body, dict):
|
|
270
|
+
if "choices" in body and body["choices"]:
|
|
271
|
+
for choice in body["choices"]:
|
|
272
|
+
if "message" in choice:
|
|
273
|
+
choice["message"] = self._process_message_with_reasoning(
|
|
274
|
+
choice["message"]
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
return super()._make_status_error_from_response(response, message, body=body)
|
|
278
|
+
|
|
279
|
+
def _convert_dict_to_message(self, _dict: dict[str, Any]) -> Any:
|
|
280
|
+
"""Override to extract reasoning before conversion."""
|
|
281
|
+
# Process reasoning first
|
|
282
|
+
_dict = self._process_message_with_reasoning(_dict)
|
|
283
|
+
|
|
284
|
+
# Convert using parent method
|
|
285
|
+
return super()._convert_dict_to_message(_dict)
|
|
286
|
+
|
|
287
|
+
def _convert_chunk_to_generation_chunk(
|
|
288
|
+
self,
|
|
289
|
+
chunk: dict,
|
|
290
|
+
default_chunk_class: type,
|
|
291
|
+
base_generation_info: dict | None,
|
|
292
|
+
):
|
|
293
|
+
"""Override to extract reasoning_content from streaming chunks.
|
|
294
|
+
|
|
295
|
+
This is the correct method to override for streaming support.
|
|
296
|
+
The chunk here is already a dict from model_dump().
|
|
297
|
+
"""
|
|
298
|
+
# First, let parent process the chunk
|
|
299
|
+
generation_chunk = super()._convert_chunk_to_generation_chunk(
|
|
300
|
+
chunk, default_chunk_class, base_generation_info
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
# Now extract reasoning_content if present
|
|
304
|
+
if generation_chunk is not None:
|
|
305
|
+
choices = chunk.get("choices", [])
|
|
306
|
+
if choices:
|
|
307
|
+
delta = choices[0].get("delta", {})
|
|
308
|
+
if delta and isinstance(delta, dict):
|
|
309
|
+
reasoning_content = delta.get("reasoning_content")
|
|
310
|
+
content = delta.get("content")
|
|
311
|
+
|
|
312
|
+
# Ensure additional_kwargs exists
|
|
313
|
+
if not hasattr(generation_chunk.message, "additional_kwargs"):
|
|
314
|
+
generation_chunk.message.additional_kwargs = {}
|
|
315
|
+
if generation_chunk.message.additional_kwargs is None:
|
|
316
|
+
generation_chunk.message.additional_kwargs = {}
|
|
317
|
+
|
|
318
|
+
# Accumulate reasoning
|
|
319
|
+
if reasoning_content and isinstance(reasoning_content, str):
|
|
320
|
+
if (
|
|
321
|
+
"reasoning"
|
|
322
|
+
not in generation_chunk.message.additional_kwargs
|
|
323
|
+
):
|
|
324
|
+
generation_chunk.message.additional_kwargs["reasoning"] = ""
|
|
325
|
+
generation_chunk.message.additional_kwargs["reasoning"] += (
|
|
326
|
+
reasoning_content
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
# Build content_blocks for this chunk
|
|
330
|
+
content_blocks = []
|
|
331
|
+
|
|
332
|
+
# Add thinking block if we have reasoning in this chunk
|
|
333
|
+
if reasoning_content and isinstance(reasoning_content, str):
|
|
334
|
+
content_blocks.append(
|
|
335
|
+
{
|
|
336
|
+
"type": "thinking",
|
|
337
|
+
"thinking": reasoning_content,
|
|
338
|
+
}
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
# Add text block if we have content in this chunk
|
|
342
|
+
if content and isinstance(content, str):
|
|
343
|
+
content_blocks.append(
|
|
344
|
+
{
|
|
345
|
+
"type": "text",
|
|
346
|
+
"text": content,
|
|
347
|
+
}
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
# Store content_blocks if we have any
|
|
351
|
+
if content_blocks:
|
|
352
|
+
if (
|
|
353
|
+
"content_blocks"
|
|
354
|
+
not in generation_chunk.message.additional_kwargs
|
|
355
|
+
):
|
|
356
|
+
generation_chunk.message.additional_kwargs[
|
|
357
|
+
"content_blocks"
|
|
358
|
+
] = []
|
|
359
|
+
generation_chunk.message.additional_kwargs[
|
|
360
|
+
"content_blocks"
|
|
361
|
+
].extend(content_blocks)
|
|
362
|
+
|
|
363
|
+
return generation_chunk
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
__all__ = [
|
|
367
|
+
"EnhancedChatOpenAI",
|
|
368
|
+
]
|