voidx 1.0.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.
- voidx/__init__.py +3 -0
- voidx/agent/__init__.py +0 -0
- voidx/agent/agents.py +439 -0
- voidx/agent/attachments.py +235 -0
- voidx/agent/graph.py +463 -0
- voidx/agent/graph_components/__init__.py +1 -0
- voidx/agent/graph_components/compaction.py +268 -0
- voidx/agent/graph_components/permissions.py +139 -0
- voidx/agent/graph_components/run_loop.py +532 -0
- voidx/agent/graph_components/runtime.py +14 -0
- voidx/agent/graph_components/streaming.py +351 -0
- voidx/agent/graph_components/subagent.py +278 -0
- voidx/agent/graph_components/tool_execution.py +208 -0
- voidx/agent/runtime_context.py +368 -0
- voidx/agent/slash.py +466 -0
- voidx/agent/slash_components/__init__.py +1 -0
- voidx/agent/slash_components/code_ide.py +68 -0
- voidx/agent/slash_components/lsp.py +105 -0
- voidx/agent/slash_components/mcp.py +332 -0
- voidx/agent/slash_components/model.py +419 -0
- voidx/agent/slash_components/runtime.py +55 -0
- voidx/agent/slash_components/skills.py +94 -0
- voidx/agent/state.py +32 -0
- voidx/agent/task_state.py +278 -0
- voidx/agent/tool_filters.py +27 -0
- voidx/config.py +707 -0
- voidx/llm/__init__.py +0 -0
- voidx/llm/catalog.py +188 -0
- voidx/llm/compaction.py +267 -0
- voidx/llm/context.py +43 -0
- voidx/llm/instruction.py +220 -0
- voidx/llm/provider.py +312 -0
- voidx/llm/usage.py +341 -0
- voidx/lsp/__init__.py +30 -0
- voidx/lsp/client.py +259 -0
- voidx/lsp/config.py +172 -0
- voidx/lsp/detector.py +512 -0
- voidx/lsp/errors.py +19 -0
- voidx/lsp/manager.py +280 -0
- voidx/lsp/schema.py +179 -0
- voidx/lsp/service.py +103 -0
- voidx/main.py +154 -0
- voidx/mcp/__init__.py +33 -0
- voidx/mcp/client.py +458 -0
- voidx/mcp/manager.py +267 -0
- voidx/mcp/schema.py +112 -0
- voidx/mcp/tool.py +122 -0
- voidx/mcp_servers/__init__.py +1 -0
- voidx/mcp_servers/web.py +104 -0
- voidx/memory/__init__.py +0 -0
- voidx/memory/context_frames.py +188 -0
- voidx/memory/model_profiles.py +98 -0
- voidx/memory/runtime_state.py +240 -0
- voidx/memory/session.py +272 -0
- voidx/memory/store.py +245 -0
- voidx/memory/transcript.py +137 -0
- voidx/permission/__init__.py +28 -0
- voidx/permission/engine.py +430 -0
- voidx/permission/evaluate.py +114 -0
- voidx/permission/sandbox.py +280 -0
- voidx/permission/schema.py +24 -0
- voidx/permission/service.py +314 -0
- voidx/permission/wildcard.py +34 -0
- voidx/skills/__init__.py +18 -0
- voidx/skills/bundled/superpowers/receiving-code-review/SKILL.md +30 -0
- voidx/skills/bundled/superpowers/requesting-code-review/SKILL.md +27 -0
- voidx/skills/bundled/superpowers/systematic-debugging/SKILL.md +36 -0
- voidx/skills/bundled/superpowers/test-driven-development/SKILL.md +33 -0
- voidx/skills/bundled/superpowers/verification-before-completion/SKILL.md +31 -0
- voidx/skills/bundled/superpowers/writing-plans/SKILL.md +27 -0
- voidx/skills/policy.py +97 -0
- voidx/skills/registry.py +162 -0
- voidx/skills/schema.py +47 -0
- voidx/skills/service.py +199 -0
- voidx/tools/__init__.py +0 -0
- voidx/tools/agent.py +81 -0
- voidx/tools/base.py +86 -0
- voidx/tools/bash.py +105 -0
- voidx/tools/file_ops.py +193 -0
- voidx/tools/lsp.py +155 -0
- voidx/tools/registry.py +104 -0
- voidx/tools/repomap.py +238 -0
- voidx/tools/search.py +162 -0
- voidx/tools/task_status.py +57 -0
- voidx/tools/task_tracker.py +81 -0
- voidx/tools/todo.py +82 -0
- voidx/tools/web_content.py +357 -0
- voidx/tools/web_mcp.py +107 -0
- voidx/tools/webfetch.py +155 -0
- voidx/tools/websearch.py +276 -0
- voidx/ui/__init__.py +0 -0
- voidx/ui/app.py +1033 -0
- voidx/ui/app_components/__init__.py +1 -0
- voidx/ui/app_components/clipboard_image.py +245 -0
- voidx/ui/app_components/commands.py +18 -0
- voidx/ui/app_components/controls.py +29 -0
- voidx/ui/app_components/file_picker.py +115 -0
- voidx/ui/app_components/formatting.py +187 -0
- voidx/ui/app_components/git_changes.py +51 -0
- voidx/ui/app_components/rendering.py +1169 -0
- voidx/ui/browse.py +160 -0
- voidx/ui/capture.py +169 -0
- voidx/ui/code_ide.py +251 -0
- voidx/ui/commands.py +83 -0
- voidx/ui/console.py +381 -0
- voidx/ui/console_components/__init__.py +1 -0
- voidx/ui/console_components/formatting.py +96 -0
- voidx/ui/console_components/streaming.py +253 -0
- voidx/ui/diff.py +331 -0
- voidx/ui/dock.py +372 -0
- voidx/ui/dock_components/__init__.py +1 -0
- voidx/ui/dock_components/formatting.py +123 -0
- voidx/ui/dock_components/nodes.py +401 -0
- voidx/ui/dock_components/state.py +51 -0
- voidx/ui/event_components/__init__.py +1 -0
- voidx/ui/event_components/schema.py +249 -0
- voidx/ui/events.py +341 -0
- voidx/ui/session_changes.py +163 -0
- voidx/ui/startup.py +161 -0
- voidx/ui/transcript.py +148 -0
- voidx/ui/tree.py +316 -0
- voidx-1.0.0.dist-info/METADATA +59 -0
- voidx-1.0.0.dist-info/RECORD +126 -0
- voidx-1.0.0.dist-info/WHEEL +5 -0
- voidx-1.0.0.dist-info/entry_points.txt +2 -0
- voidx-1.0.0.dist-info/top_level.txt +1 -0
voidx/llm/instruction.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Instruction service — AGENTS.md / CLAUDE.md project memory.
|
|
2
|
+
|
|
3
|
+
Aligns with opencode's instruction.ts: hierarchical loading, dedup,
|
|
4
|
+
per-message claims tracking, live re-read on every turn.
|
|
5
|
+
|
|
6
|
+
Resolution order:
|
|
7
|
+
1. ~/.voidx/AGENTS.md (global)
|
|
8
|
+
2. ~/.claude/CLAUDE.md (compat, only if ~/.voidx/ not found)
|
|
9
|
+
3. Workspace walk-up AGENTS.md (first match wins, like opencode)
|
|
10
|
+
4. Config URL instructions
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
INSTRUCTION_FILES = ["AGENTS.md", "CLAUDE.md"] # CLAUDE.md for compat
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class SkillRuntimeContext:
|
|
26
|
+
instructions: list[str]
|
|
27
|
+
active: list[str]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class InstructionService:
|
|
31
|
+
"""Manages project instructions injection into system prompt."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, workspace: str, settings=None) -> None:
|
|
34
|
+
self._workspace = Path(workspace).resolve()
|
|
35
|
+
self._settings = settings
|
|
36
|
+
self._global_dir = Path.home() / ".voidx"
|
|
37
|
+
self._claude_dir = Path.home() / ".claude"
|
|
38
|
+
|
|
39
|
+
# Per-message claims: track which instruction files have been
|
|
40
|
+
# attached to which assistant message to avoid duplicates.
|
|
41
|
+
self._claims: dict[str, set[str]] = {}
|
|
42
|
+
# Cached system paths (refreshed each turn)
|
|
43
|
+
self._system_paths: list[str] = []
|
|
44
|
+
|
|
45
|
+
# ── public API ──────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
def clear(self, message_id: str) -> None:
|
|
48
|
+
"""Clear claims for a message (called when message is removed)."""
|
|
49
|
+
self._claims.pop(message_id, None)
|
|
50
|
+
|
|
51
|
+
async def system_paths(self) -> list[str]:
|
|
52
|
+
"""Discover all instruction file paths. Refreshed each call."""
|
|
53
|
+
paths: list[str] = []
|
|
54
|
+
|
|
55
|
+
# 1. Global: ~/.voidx/AGENTS.md first, then ~/.claude/CLAUDE.md
|
|
56
|
+
global_voidx = self._global_dir / "AGENTS.md"
|
|
57
|
+
if global_voidx.exists():
|
|
58
|
+
paths.append(str(global_voidx.resolve()))
|
|
59
|
+
else:
|
|
60
|
+
claude_global = self._claude_dir / "CLAUDE.md"
|
|
61
|
+
if claude_global.exists():
|
|
62
|
+
paths.append(str(claude_global.resolve()))
|
|
63
|
+
|
|
64
|
+
# 2. Project: walk-up from workspace, first match wins
|
|
65
|
+
current = self._workspace
|
|
66
|
+
root = Path(current.anchor)
|
|
67
|
+
while current != root:
|
|
68
|
+
for filename in INSTRUCTION_FILES:
|
|
69
|
+
candidate = current / filename
|
|
70
|
+
if candidate.exists() and str(candidate.resolve()) not in paths:
|
|
71
|
+
paths.append(str(candidate.resolve()))
|
|
72
|
+
break
|
|
73
|
+
else:
|
|
74
|
+
current = current.parent
|
|
75
|
+
continue
|
|
76
|
+
break # first match wins (one level only, opencode semantics)
|
|
77
|
+
# Actually opencode tries each filename at each level
|
|
78
|
+
# and breaks out on the FIRST filename that has any match
|
|
79
|
+
|
|
80
|
+
self._system_paths = paths
|
|
81
|
+
return paths
|
|
82
|
+
|
|
83
|
+
async def system(self) -> list[str]:
|
|
84
|
+
"""Read all system instruction files. Returns list of
|
|
85
|
+
'Instructions from: <path>\n<content>' strings."""
|
|
86
|
+
paths = await self.system_paths()
|
|
87
|
+
return await self._read_all(paths)
|
|
88
|
+
|
|
89
|
+
async def skill_context_for(
|
|
90
|
+
self,
|
|
91
|
+
user_text: str,
|
|
92
|
+
*,
|
|
93
|
+
agent: str = "",
|
|
94
|
+
task_intent: str | None = None,
|
|
95
|
+
interaction_mode: str | None = None,
|
|
96
|
+
) -> SkillRuntimeContext:
|
|
97
|
+
from voidx.skills.registry import SkillRegistry
|
|
98
|
+
from voidx.skills.service import SkillService
|
|
99
|
+
|
|
100
|
+
selection = self._settings.get_skill_selection() if self._settings is not None else None
|
|
101
|
+
service = SkillService(
|
|
102
|
+
SkillRegistry(str(self._workspace)),
|
|
103
|
+
selection=selection,
|
|
104
|
+
)
|
|
105
|
+
matches = await asyncio.to_thread(
|
|
106
|
+
service.select,
|
|
107
|
+
user_text,
|
|
108
|
+
agent=agent,
|
|
109
|
+
task_intent=task_intent,
|
|
110
|
+
interaction_mode=interaction_mode,
|
|
111
|
+
)
|
|
112
|
+
return SkillRuntimeContext(
|
|
113
|
+
instructions=[service.render_instruction(match.skill) for match in matches],
|
|
114
|
+
active=[f"{match.name} ({match.reason})" for match in matches],
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
async def skills_for(
|
|
118
|
+
self,
|
|
119
|
+
user_text: str,
|
|
120
|
+
*,
|
|
121
|
+
agent: str = "",
|
|
122
|
+
task_intent: str | None = None,
|
|
123
|
+
interaction_mode: str | None = None,
|
|
124
|
+
) -> list[str]:
|
|
125
|
+
context = await self.skill_context_for(
|
|
126
|
+
user_text,
|
|
127
|
+
agent=agent,
|
|
128
|
+
task_intent=task_intent,
|
|
129
|
+
interaction_mode=interaction_mode,
|
|
130
|
+
)
|
|
131
|
+
return context.instructions
|
|
132
|
+
|
|
133
|
+
async def resolve(
|
|
134
|
+
self,
|
|
135
|
+
filepath: str,
|
|
136
|
+
message_id: str,
|
|
137
|
+
already_loaded: set[str] | None = None,
|
|
138
|
+
) -> list[str]:
|
|
139
|
+
"""When a file is read by the read tool, check if there are
|
|
140
|
+
nearby instruction files that should be injected. Returns list
|
|
141
|
+
of 'Instructions from: <path>\n<content>' strings.
|
|
142
|
+
|
|
143
|
+
Walks up from the file's directory looking for instruction files
|
|
144
|
+
that haven't been loaded yet (not in system_paths, not already loaded).
|
|
145
|
+
Each file is attached at most once per message_id.
|
|
146
|
+
"""
|
|
147
|
+
sys_paths = set(self._system_paths) if self._system_paths else set(await self.system_paths())
|
|
148
|
+
already = already_loaded or set()
|
|
149
|
+
|
|
150
|
+
target = Path(filepath).resolve()
|
|
151
|
+
current = target.parent if target.is_file() else target
|
|
152
|
+
root = self._workspace
|
|
153
|
+
|
|
154
|
+
results: list[str] = []
|
|
155
|
+
|
|
156
|
+
while current != root and str(current) != str(current.anchor):
|
|
157
|
+
for filename in INSTRUCTION_FILES:
|
|
158
|
+
candidate = current / filename
|
|
159
|
+
candidate_str = str(candidate.resolve())
|
|
160
|
+
|
|
161
|
+
if not candidate.exists():
|
|
162
|
+
continue
|
|
163
|
+
if candidate_str == str(target):
|
|
164
|
+
continue
|
|
165
|
+
if candidate_str in sys_paths or candidate_str in already:
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
# Check claims for this message_id
|
|
169
|
+
if message_id not in self._claims:
|
|
170
|
+
self._claims[message_id] = set()
|
|
171
|
+
if candidate_str in self._claims[message_id]:
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
self._claims[message_id].add(candidate_str)
|
|
175
|
+
content = await self._read_file(str(candidate))
|
|
176
|
+
if content:
|
|
177
|
+
results.append(f"Instructions from: {candidate_str}\n{content}")
|
|
178
|
+
|
|
179
|
+
current = current.parent
|
|
180
|
+
|
|
181
|
+
return results
|
|
182
|
+
|
|
183
|
+
# ── helpers ─────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
async def _read_file(self, path: str) -> str:
|
|
186
|
+
try:
|
|
187
|
+
return await asyncio.to_thread(lambda: Path(path).read_text(encoding="utf-8", errors="replace"))
|
|
188
|
+
except Exception:
|
|
189
|
+
return ""
|
|
190
|
+
|
|
191
|
+
async def _read_all(self, paths: list[str]) -> list[str]:
|
|
192
|
+
"""Parallel read all paths. Returns formatted instruction strings."""
|
|
193
|
+
|
|
194
|
+
async def read_one(p: str) -> str | None:
|
|
195
|
+
content = await self._read_file(p)
|
|
196
|
+
if content:
|
|
197
|
+
return f"Instructions from: {p}\n{content}"
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
results = await asyncio.gather(*[read_one(p) for p in paths])
|
|
201
|
+
return [r for r in results if r is not None]
|
|
202
|
+
|
|
203
|
+
@staticmethod
|
|
204
|
+
def loaded_from_history(messages: list) -> set[str]:
|
|
205
|
+
"""Extract already-loaded instruction paths from conversation history.
|
|
206
|
+
Mimics opencode's Instruction.loaded() — scans tool parts for
|
|
207
|
+
metadata.loaded paths, skipping compacted parts."""
|
|
208
|
+
paths: set[str] = set()
|
|
209
|
+
for msg in messages:
|
|
210
|
+
if not hasattr(msg, "tool_calls") and not hasattr(msg, "content"):
|
|
211
|
+
continue
|
|
212
|
+
# Scan tool parts from message
|
|
213
|
+
content = getattr(msg, "content", "")
|
|
214
|
+
if isinstance(content, str) and "Instructions from:" in content:
|
|
215
|
+
for line in content.split("\n"):
|
|
216
|
+
if line.startswith("Instructions from:"):
|
|
217
|
+
p = line.replace("Instructions from:", "").strip()
|
|
218
|
+
if p:
|
|
219
|
+
paths.add(p)
|
|
220
|
+
return paths
|
voidx/llm/provider.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""LLM Provider layer — typed abstraction over LangChain ChatModels."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from langchain_anthropic import ChatAnthropic
|
|
6
|
+
from langchain_openai import ChatOpenAI
|
|
7
|
+
from langchain_core.language_models import BaseChatModel
|
|
8
|
+
from langchain_core.messages import AIMessageChunk
|
|
9
|
+
|
|
10
|
+
from voidx.config import ModelConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ── protocol resolution ───────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
_PROVIDER_PROTOCOLS: dict[str, str] = {
|
|
16
|
+
"anthropic": "anthropic",
|
|
17
|
+
"deepseek": "anthropic",
|
|
18
|
+
"openai": "openai",
|
|
19
|
+
"openrouter": "openai",
|
|
20
|
+
"mimo": "anthropic",
|
|
21
|
+
"mimo-token-plan": "anthropic",
|
|
22
|
+
"qwen": "anthropic",
|
|
23
|
+
"zhipu": "anthropic",
|
|
24
|
+
"kimi": "anthropic",
|
|
25
|
+
"doubao": "openai",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_DEFAULT_BASE_URLS: dict[tuple[str, str], str] = {
|
|
29
|
+
("anthropic", "anthropic"): "https://api.anthropic.com",
|
|
30
|
+
("openai", "openai"): "https://api.openai.com/v1",
|
|
31
|
+
("deepseek", "anthropic"): "https://api.deepseek.com/anthropic",
|
|
32
|
+
("deepseek", "openai"): "https://api.deepseek.com/v1",
|
|
33
|
+
("openrouter", "openai"): "https://openrouter.ai/api/v1",
|
|
34
|
+
("mimo", "openai"): "https://api.xiaomimimo.com/v1",
|
|
35
|
+
("mimo", "anthropic"): "https://api.xiaomimimo.com/anthropic",
|
|
36
|
+
("mimo-token-plan", "openai"): "https://token-plan-cn.xiaomimimo.com/v1",
|
|
37
|
+
("mimo-token-plan", "anthropic"): "https://token-plan-cn.xiaomimimo.com/anthropic",
|
|
38
|
+
("qwen", "openai"): "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
39
|
+
("qwen", "anthropic"): "https://dashscope.aliyuncs.com/apps/anthropic",
|
|
40
|
+
("zhipu", "openai"): "https://open.bigmodel.cn/api/paas/v4",
|
|
41
|
+
("zhipu", "anthropic"): "https://open.bigmodel.cn/api/anthropic",
|
|
42
|
+
("kimi", "openai"): "https://api.moonshot.cn/v1",
|
|
43
|
+
("kimi", "anthropic"): "https://api.moonshot.cn/anthropic",
|
|
44
|
+
("doubao", "openai"): "https://ark.cn-beijing.volces.com/api/v3",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_protocol(config: ModelConfig) -> str:
|
|
49
|
+
if config.protocol:
|
|
50
|
+
return config.protocol
|
|
51
|
+
return _PROVIDER_PROTOCOLS.get(config.provider, "openai")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# ── model factory ─────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
# ── reasoning effort mapping ─────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
_ANTHROPIC_BUDGETS = {
|
|
59
|
+
"low": 1_024,
|
|
60
|
+
"medium": 4_096,
|
|
61
|
+
"high": 8_192,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
_REASONING_PREFIXES = (
|
|
65
|
+
"gpt-5",
|
|
66
|
+
"o1",
|
|
67
|
+
"o3",
|
|
68
|
+
"o4",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _normalized_effort(effort: str | None) -> str | None:
|
|
73
|
+
if effort is None:
|
|
74
|
+
return None
|
|
75
|
+
value = effort.strip().lower()
|
|
76
|
+
if value in {"", "off", "none"}:
|
|
77
|
+
return "none"
|
|
78
|
+
if value in {"minimal", "low", "medium", "high", "xhigh", "max"}:
|
|
79
|
+
return value
|
|
80
|
+
return "medium"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _supports_openai_reasoning(model: str) -> bool:
|
|
84
|
+
name = model.lower()
|
|
85
|
+
return name.startswith(_REASONING_PREFIXES)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _supports_anthropic_effort(model: str) -> bool:
|
|
89
|
+
name = model.lower()
|
|
90
|
+
return "claude-opus-4-" in name
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _anthropic_reasoning_kwargs(config: ModelConfig) -> dict:
|
|
94
|
+
"""Return Anthropic-compatible reasoning kwargs for first-party Claude models."""
|
|
95
|
+
effort = _normalized_effort(config.reasoning_effort)
|
|
96
|
+
if effort in (None, "none"):
|
|
97
|
+
return {}
|
|
98
|
+
if _supports_anthropic_effort(config.model):
|
|
99
|
+
level = {"minimal": "low"}.get(effort, effort)
|
|
100
|
+
return {"thinking": {"type": "adaptive"}, "effort": level}
|
|
101
|
+
budget = _ANTHROPIC_BUDGETS.get(effort, _ANTHROPIC_BUDGETS["high"])
|
|
102
|
+
budget = min(budget, max(config.max_tokens - 1, 1))
|
|
103
|
+
if budget < 1_024:
|
|
104
|
+
return {}
|
|
105
|
+
return {"thinking": {"type": "enabled", "budget_tokens": budget}}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _mimo_reasoning_kwargs(effort: str | None) -> dict:
|
|
109
|
+
value = _normalized_effort(effort)
|
|
110
|
+
if value == "none":
|
|
111
|
+
return {"thinking": {"type": "disabled"}}
|
|
112
|
+
if value is None:
|
|
113
|
+
return {}
|
|
114
|
+
return {"thinking": {"type": "enabled"}}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _openai_reasoning_effort(effort: str | None) -> str | None:
|
|
118
|
+
"""Map unified reasoning_effort to OpenAI reasoning_effort string."""
|
|
119
|
+
value = _normalized_effort(effort)
|
|
120
|
+
if value is None:
|
|
121
|
+
return None
|
|
122
|
+
return {"none": "none", "minimal": "minimal", "low": "low", "medium": "medium", "high": "high", "xhigh": "xhigh", "max": "high"}.get(value)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _openai_reasoning_kwargs(config: ModelConfig) -> dict:
|
|
126
|
+
effort = _openai_reasoning_effort(config.reasoning_effort)
|
|
127
|
+
|
|
128
|
+
if config.provider == "openai":
|
|
129
|
+
if effort is None:
|
|
130
|
+
return {}
|
|
131
|
+
if not _supports_openai_reasoning(config.model):
|
|
132
|
+
return {}
|
|
133
|
+
if effort == "none":
|
|
134
|
+
if config.model.lower().startswith("gpt-5"):
|
|
135
|
+
return {"reasoning_effort": "none"}
|
|
136
|
+
return {"reasoning_effort": "low"}
|
|
137
|
+
return {"reasoning_effort": effort}
|
|
138
|
+
|
|
139
|
+
if config.provider == "openrouter":
|
|
140
|
+
if effort is None:
|
|
141
|
+
return {}
|
|
142
|
+
return {"extra_body": {"reasoning": {"effort": effort}}}
|
|
143
|
+
|
|
144
|
+
if config.provider == "doubao":
|
|
145
|
+
return _doubao_reasoning_kwargs(config)
|
|
146
|
+
|
|
147
|
+
return {}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _doubao_reasoning_kwargs(config: ModelConfig) -> dict:
|
|
151
|
+
if "thinking" not in config.model.lower() and "seed-1.6" not in config.model.lower():
|
|
152
|
+
return {}
|
|
153
|
+
effort = (config.reasoning_effort or "").strip().lower()
|
|
154
|
+
if effort in {"", "off", "none"}:
|
|
155
|
+
thinking_type = "disabled"
|
|
156
|
+
elif effort == "auto":
|
|
157
|
+
thinking_type = "auto"
|
|
158
|
+
else:
|
|
159
|
+
thinking_type = "enabled"
|
|
160
|
+
return {"extra_body": {"thinking": {"type": thinking_type}}}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _reasoning_kwargs(config: ModelConfig, protocol: str) -> dict:
|
|
164
|
+
if protocol == "anthropic":
|
|
165
|
+
if config.provider == "anthropic":
|
|
166
|
+
return _anthropic_reasoning_kwargs(config)
|
|
167
|
+
if config.provider in ("mimo", "deepseek", "mimo-token-plan"):
|
|
168
|
+
return _mimo_reasoning_kwargs(config.reasoning_effort)
|
|
169
|
+
return {}
|
|
170
|
+
if protocol == "openai":
|
|
171
|
+
return _openai_reasoning_kwargs(config)
|
|
172
|
+
return {}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def create_chat_model(api_key: str, config: ModelConfig) -> BaseChatModel:
|
|
176
|
+
protocol = resolve_protocol(config)
|
|
177
|
+
base_url = config.base_url or _DEFAULT_BASE_URLS.get(
|
|
178
|
+
(config.provider, protocol), ""
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if protocol == "anthropic":
|
|
182
|
+
kwargs = dict(
|
|
183
|
+
api_key=api_key,
|
|
184
|
+
model=config.model,
|
|
185
|
+
temperature=config.temperature,
|
|
186
|
+
max_tokens=config.max_tokens,
|
|
187
|
+
)
|
|
188
|
+
if base_url:
|
|
189
|
+
kwargs["base_url"] = base_url
|
|
190
|
+
kwargs.update(_reasoning_kwargs(config, protocol))
|
|
191
|
+
return ChatAnthropic(**kwargs)
|
|
192
|
+
|
|
193
|
+
if protocol == "openai":
|
|
194
|
+
kwargs = dict(
|
|
195
|
+
api_key=api_key,
|
|
196
|
+
model=config.model,
|
|
197
|
+
temperature=config.temperature,
|
|
198
|
+
max_tokens=config.max_tokens,
|
|
199
|
+
)
|
|
200
|
+
if base_url:
|
|
201
|
+
kwargs["base_url"] = base_url
|
|
202
|
+
kwargs.update(_reasoning_kwargs(config, protocol))
|
|
203
|
+
return ChatOpenAI(**kwargs)
|
|
204
|
+
|
|
205
|
+
if protocol == "gemini":
|
|
206
|
+
raise NotImplementedError(
|
|
207
|
+
"Gemini protocol not yet supported. Use 'openai' or 'anthropic'."
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
raise ValueError(f"Unknown protocol: {protocol}")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# ── thinking extraction ───────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
_THINKING_BLOCK_TYPES = {
|
|
216
|
+
"thinking",
|
|
217
|
+
"redacted_thinking",
|
|
218
|
+
"reasoning",
|
|
219
|
+
"reasoning_content",
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _extract_reasoning_text(value: object) -> str:
|
|
224
|
+
if isinstance(value, str):
|
|
225
|
+
return value
|
|
226
|
+
if isinstance(value, list):
|
|
227
|
+
return "".join(_extract_reasoning_text(item) for item in value)
|
|
228
|
+
if not isinstance(value, dict):
|
|
229
|
+
return ""
|
|
230
|
+
|
|
231
|
+
parts: list[str] = []
|
|
232
|
+
for key in ("thinking", "reasoning_content", "reasoning", "text", "data"):
|
|
233
|
+
field = value.get(key)
|
|
234
|
+
if isinstance(field, str):
|
|
235
|
+
parts.append(field)
|
|
236
|
+
|
|
237
|
+
summary = value.get("summary")
|
|
238
|
+
if isinstance(summary, (dict, list)):
|
|
239
|
+
parts.append(_extract_reasoning_text(summary))
|
|
240
|
+
|
|
241
|
+
return "".join(parts)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _extract_reasoning_blocks(content: object) -> str:
|
|
245
|
+
if not isinstance(content, list):
|
|
246
|
+
return ""
|
|
247
|
+
parts: list[str] = []
|
|
248
|
+
for item in content:
|
|
249
|
+
if isinstance(item, dict) and item.get("type") in _THINKING_BLOCK_TYPES:
|
|
250
|
+
parts.append(_extract_reasoning_text(item))
|
|
251
|
+
return "".join(parts)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _extract_thinking_anthropic(chunk: AIMessageChunk) -> str:
|
|
255
|
+
parts: list[str] = []
|
|
256
|
+
content_text = _extract_reasoning_blocks(chunk.content)
|
|
257
|
+
if content_text:
|
|
258
|
+
parts.append(content_text)
|
|
259
|
+
|
|
260
|
+
meta = chunk.response_metadata
|
|
261
|
+
if isinstance(meta, dict):
|
|
262
|
+
for key in ("thinking", "reasoning"):
|
|
263
|
+
text = _extract_reasoning_text(meta.get(key))
|
|
264
|
+
if text:
|
|
265
|
+
parts.append(text)
|
|
266
|
+
return "".join(parts)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _extract_thinking_openai(chunk: AIMessageChunk) -> str:
|
|
270
|
+
parts: list[str] = []
|
|
271
|
+
content_text = _extract_reasoning_blocks(chunk.content)
|
|
272
|
+
if content_text:
|
|
273
|
+
parts.append(content_text)
|
|
274
|
+
|
|
275
|
+
extra = chunk.additional_kwargs
|
|
276
|
+
if isinstance(extra, dict):
|
|
277
|
+
for key in ("reasoning_content", "reasoning", "reasoning_details"):
|
|
278
|
+
text = _extract_reasoning_text(extra.get(key))
|
|
279
|
+
if text:
|
|
280
|
+
parts.append(text)
|
|
281
|
+
return "".join(parts)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def extract_thinking(chunk: AIMessageChunk, protocol: str) -> str:
|
|
285
|
+
if protocol == "anthropic":
|
|
286
|
+
return _extract_thinking_anthropic(chunk)
|
|
287
|
+
if protocol == "openai":
|
|
288
|
+
return _extract_thinking_openai(chunk)
|
|
289
|
+
return _extract_thinking_anthropic(chunk) or _extract_thinking_openai(chunk)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ── context limits ────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
def get_context_limit(provider: str, protocol: str = "") -> int:
|
|
295
|
+
"""Return context-window limit for *provider*. Falls back to *protocol* for unknown providers."""
|
|
296
|
+
limits: dict[str, int] = {
|
|
297
|
+
"deepseek": 1_000_000,
|
|
298
|
+
"anthropic": 200_000,
|
|
299
|
+
"openai": 1_050_000,
|
|
300
|
+
"openrouter": 128_000,
|
|
301
|
+
"mimo": 1_000_000,
|
|
302
|
+
"mimo-token-plan": 1_000_000,
|
|
303
|
+
"qwen": 1_000_000,
|
|
304
|
+
"zhipu": 200_000,
|
|
305
|
+
"kimi": 262_144,
|
|
306
|
+
"doubao": 256_000,
|
|
307
|
+
}
|
|
308
|
+
if provider in limits:
|
|
309
|
+
return limits[provider]
|
|
310
|
+
if protocol == "anthropic":
|
|
311
|
+
return 200_000
|
|
312
|
+
return 128_000
|