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.
Files changed (126) hide show
  1. voidx/__init__.py +3 -0
  2. voidx/agent/__init__.py +0 -0
  3. voidx/agent/agents.py +439 -0
  4. voidx/agent/attachments.py +235 -0
  5. voidx/agent/graph.py +463 -0
  6. voidx/agent/graph_components/__init__.py +1 -0
  7. voidx/agent/graph_components/compaction.py +268 -0
  8. voidx/agent/graph_components/permissions.py +139 -0
  9. voidx/agent/graph_components/run_loop.py +532 -0
  10. voidx/agent/graph_components/runtime.py +14 -0
  11. voidx/agent/graph_components/streaming.py +351 -0
  12. voidx/agent/graph_components/subagent.py +278 -0
  13. voidx/agent/graph_components/tool_execution.py +208 -0
  14. voidx/agent/runtime_context.py +368 -0
  15. voidx/agent/slash.py +466 -0
  16. voidx/agent/slash_components/__init__.py +1 -0
  17. voidx/agent/slash_components/code_ide.py +68 -0
  18. voidx/agent/slash_components/lsp.py +105 -0
  19. voidx/agent/slash_components/mcp.py +332 -0
  20. voidx/agent/slash_components/model.py +419 -0
  21. voidx/agent/slash_components/runtime.py +55 -0
  22. voidx/agent/slash_components/skills.py +94 -0
  23. voidx/agent/state.py +32 -0
  24. voidx/agent/task_state.py +278 -0
  25. voidx/agent/tool_filters.py +27 -0
  26. voidx/config.py +707 -0
  27. voidx/llm/__init__.py +0 -0
  28. voidx/llm/catalog.py +188 -0
  29. voidx/llm/compaction.py +267 -0
  30. voidx/llm/context.py +43 -0
  31. voidx/llm/instruction.py +220 -0
  32. voidx/llm/provider.py +312 -0
  33. voidx/llm/usage.py +341 -0
  34. voidx/lsp/__init__.py +30 -0
  35. voidx/lsp/client.py +259 -0
  36. voidx/lsp/config.py +172 -0
  37. voidx/lsp/detector.py +512 -0
  38. voidx/lsp/errors.py +19 -0
  39. voidx/lsp/manager.py +280 -0
  40. voidx/lsp/schema.py +179 -0
  41. voidx/lsp/service.py +103 -0
  42. voidx/main.py +154 -0
  43. voidx/mcp/__init__.py +33 -0
  44. voidx/mcp/client.py +458 -0
  45. voidx/mcp/manager.py +267 -0
  46. voidx/mcp/schema.py +112 -0
  47. voidx/mcp/tool.py +122 -0
  48. voidx/mcp_servers/__init__.py +1 -0
  49. voidx/mcp_servers/web.py +104 -0
  50. voidx/memory/__init__.py +0 -0
  51. voidx/memory/context_frames.py +188 -0
  52. voidx/memory/model_profiles.py +98 -0
  53. voidx/memory/runtime_state.py +240 -0
  54. voidx/memory/session.py +272 -0
  55. voidx/memory/store.py +245 -0
  56. voidx/memory/transcript.py +137 -0
  57. voidx/permission/__init__.py +28 -0
  58. voidx/permission/engine.py +430 -0
  59. voidx/permission/evaluate.py +114 -0
  60. voidx/permission/sandbox.py +280 -0
  61. voidx/permission/schema.py +24 -0
  62. voidx/permission/service.py +314 -0
  63. voidx/permission/wildcard.py +34 -0
  64. voidx/skills/__init__.py +18 -0
  65. voidx/skills/bundled/superpowers/receiving-code-review/SKILL.md +30 -0
  66. voidx/skills/bundled/superpowers/requesting-code-review/SKILL.md +27 -0
  67. voidx/skills/bundled/superpowers/systematic-debugging/SKILL.md +36 -0
  68. voidx/skills/bundled/superpowers/test-driven-development/SKILL.md +33 -0
  69. voidx/skills/bundled/superpowers/verification-before-completion/SKILL.md +31 -0
  70. voidx/skills/bundled/superpowers/writing-plans/SKILL.md +27 -0
  71. voidx/skills/policy.py +97 -0
  72. voidx/skills/registry.py +162 -0
  73. voidx/skills/schema.py +47 -0
  74. voidx/skills/service.py +199 -0
  75. voidx/tools/__init__.py +0 -0
  76. voidx/tools/agent.py +81 -0
  77. voidx/tools/base.py +86 -0
  78. voidx/tools/bash.py +105 -0
  79. voidx/tools/file_ops.py +193 -0
  80. voidx/tools/lsp.py +155 -0
  81. voidx/tools/registry.py +104 -0
  82. voidx/tools/repomap.py +238 -0
  83. voidx/tools/search.py +162 -0
  84. voidx/tools/task_status.py +57 -0
  85. voidx/tools/task_tracker.py +81 -0
  86. voidx/tools/todo.py +82 -0
  87. voidx/tools/web_content.py +357 -0
  88. voidx/tools/web_mcp.py +107 -0
  89. voidx/tools/webfetch.py +155 -0
  90. voidx/tools/websearch.py +276 -0
  91. voidx/ui/__init__.py +0 -0
  92. voidx/ui/app.py +1033 -0
  93. voidx/ui/app_components/__init__.py +1 -0
  94. voidx/ui/app_components/clipboard_image.py +245 -0
  95. voidx/ui/app_components/commands.py +18 -0
  96. voidx/ui/app_components/controls.py +29 -0
  97. voidx/ui/app_components/file_picker.py +115 -0
  98. voidx/ui/app_components/formatting.py +187 -0
  99. voidx/ui/app_components/git_changes.py +51 -0
  100. voidx/ui/app_components/rendering.py +1169 -0
  101. voidx/ui/browse.py +160 -0
  102. voidx/ui/capture.py +169 -0
  103. voidx/ui/code_ide.py +251 -0
  104. voidx/ui/commands.py +83 -0
  105. voidx/ui/console.py +381 -0
  106. voidx/ui/console_components/__init__.py +1 -0
  107. voidx/ui/console_components/formatting.py +96 -0
  108. voidx/ui/console_components/streaming.py +253 -0
  109. voidx/ui/diff.py +331 -0
  110. voidx/ui/dock.py +372 -0
  111. voidx/ui/dock_components/__init__.py +1 -0
  112. voidx/ui/dock_components/formatting.py +123 -0
  113. voidx/ui/dock_components/nodes.py +401 -0
  114. voidx/ui/dock_components/state.py +51 -0
  115. voidx/ui/event_components/__init__.py +1 -0
  116. voidx/ui/event_components/schema.py +249 -0
  117. voidx/ui/events.py +341 -0
  118. voidx/ui/session_changes.py +163 -0
  119. voidx/ui/startup.py +161 -0
  120. voidx/ui/transcript.py +148 -0
  121. voidx/ui/tree.py +316 -0
  122. voidx-1.0.0.dist-info/METADATA +59 -0
  123. voidx-1.0.0.dist-info/RECORD +126 -0
  124. voidx-1.0.0.dist-info/WHEEL +5 -0
  125. voidx-1.0.0.dist-info/entry_points.txt +2 -0
  126. voidx-1.0.0.dist-info/top_level.txt +1 -0
@@ -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