algo-cli-runtime 0.14.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 (237) hide show
  1. algo_cli/__init__.py +3 -0
  2. algo_cli/__main__.py +7 -0
  3. algo_cli/_internal/__init__.py +12 -0
  4. algo_cli/_internal/policy_chain.py +259 -0
  5. algo_cli/action_registry.py +1047 -0
  6. algo_cli/agent_blocks.py +550 -0
  7. algo_cli/agent_pipeline.py +1457 -0
  8. algo_cli/agent_threads.py +308 -0
  9. algo_cli/animations.py +316 -0
  10. algo_cli/cache_admission.py +209 -0
  11. algo_cli/capability_mask.py +66 -0
  12. algo_cli/chat_protocol.py +116 -0
  13. algo_cli/chatgpt_auth.py +510 -0
  14. algo_cli/chatgpt_client.py +657 -0
  15. algo_cli/code_rag.py +479 -0
  16. algo_cli/config.py +651 -0
  17. algo_cli/context_budget.py +679 -0
  18. algo_cli/credential_helpers.py +315 -0
  19. algo_cli/deliberation.py +29 -0
  20. algo_cli/display.py +1470 -0
  21. algo_cli/evals/__init__.py +21 -0
  22. algo_cli/evals/algorithm_effectiveness.py +560 -0
  23. algo_cli/evals/competitive_harness_rating.py +702 -0
  24. algo_cli/evals/cot_quality.py +220 -0
  25. algo_cli/evals/harness_retrieval_benchmark.py +401 -0
  26. algo_cli/evals/performance_regression.py +136 -0
  27. algo_cli/evals/scorecard_grading.py +308 -0
  28. algo_cli/evals/session_distribution.py +84 -0
  29. algo_cli/execution_guardrails.py +806 -0
  30. algo_cli/extensions_manifest.py +84 -0
  31. algo_cli/git_evidence.py +227 -0
  32. algo_cli/google_workspace.py +407 -0
  33. algo_cli/google_workspace_auth.py +523 -0
  34. algo_cli/harness.py +2587 -0
  35. algo_cli/identity.py +557 -0
  36. algo_cli/index_compute_lab.py +228 -0
  37. algo_cli/inference_harness.py +70 -0
  38. algo_cli/intelligence/__init__.py +1103 -0
  39. algo_cli/intelligence/acrobat_config.py +307 -0
  40. algo_cli/intelligence/acrobat_manifests.py +338 -0
  41. algo_cli/intelligence/acrobat_models.py +195 -0
  42. algo_cli/intelligence/acrobat_pipeline.py +295 -0
  43. algo_cli/intelligence/acrobat_runtime.py +302 -0
  44. algo_cli/intelligence/acrobat_security.py +261 -0
  45. algo_cli/intelligence/acrobat_workflows.py +226 -0
  46. algo_cli/intelligence/actionability.py +165 -0
  47. algo_cli/intelligence/adversarial_audit.py +136 -0
  48. algo_cli/intelligence/agent_arena.py +92 -0
  49. algo_cli/intelligence/agent_benchmark.py +236 -0
  50. algo_cli/intelligence/agent_runtime.py +171 -0
  51. algo_cli/intelligence/agents_as_tools.py +70 -0
  52. algo_cli/intelligence/artifact_binding.py +80 -0
  53. algo_cli/intelligence/autonomous_engineer.py +1976 -0
  54. algo_cli/intelligence/backpressure.py +99 -0
  55. algo_cli/intelligence/bloom_filter.py +186 -0
  56. algo_cli/intelligence/bonferroni.py +66 -0
  57. algo_cli/intelligence/boundary_compaction.py +98 -0
  58. algo_cli/intelligence/catalog_verifier.py +172 -0
  59. algo_cli/intelligence/cavecrew.py +118 -0
  60. algo_cli/intelligence/changelog.py +176 -0
  61. algo_cli/intelligence/checkpoint_resume.py +92 -0
  62. algo_cli/intelligence/circuit_breaker.py +88 -0
  63. algo_cli/intelligence/clarification_gate.py +101 -0
  64. algo_cli/intelligence/code_graph.py +180 -0
  65. algo_cli/intelligence/coderank.py +97 -0
  66. algo_cli/intelligence/consistent_hash.py +150 -0
  67. algo_cli/intelligence/consortium_synthesis.py +139 -0
  68. algo_cli/intelligence/construction/__init__.py +241 -0
  69. algo_cli/intelligence/construction/common.py +273 -0
  70. algo_cli/intelligence/construction/documents.py +496 -0
  71. algo_cli/intelligence/construction/labor_units.py +1395 -0
  72. algo_cli/intelligence/construction/payments.py +470 -0
  73. algo_cli/intelligence/construction/risk.py +784 -0
  74. algo_cli/intelligence/content_extractor.py +132 -0
  75. algo_cli/intelligence/context_adaptive.py +102 -0
  76. algo_cli/intelligence/context_ops.py +95 -0
  77. algo_cli/intelligence/count_min.py +145 -0
  78. algo_cli/intelligence/cow_state.py +103 -0
  79. algo_cli/intelligence/critic_loop.py +119 -0
  80. algo_cli/intelligence/cross_source.py +113 -0
  81. algo_cli/intelligence/daemon_mode.py +99 -0
  82. algo_cli/intelligence/dag_orchestration.py +151 -0
  83. algo_cli/intelligence/deep_research.py +155 -0
  84. algo_cli/intelligence/degenerate_detector.py +78 -0
  85. algo_cli/intelligence/delta_report.py +92 -0
  86. algo_cli/intelligence/discovery_event_log.py +92 -0
  87. algo_cli/intelligence/document_ingest.py +298 -0
  88. algo_cli/intelligence/dual_layer_validate.py +151 -0
  89. algo_cli/intelligence/echo_fidelity.py +73 -0
  90. algo_cli/intelligence/ema_tuning.py +104 -0
  91. algo_cli/intelligence/event_log.py +92 -0
  92. algo_cli/intelligence/evidence_graph.py +114 -0
  93. algo_cli/intelligence/extension_host.py +162 -0
  94. algo_cli/intelligence/extension_manifest.py +115 -0
  95. algo_cli/intelligence/falsification_suite.py +178 -0
  96. algo_cli/intelligence/finance/__init__.py +169 -0
  97. algo_cli/intelligence/finance/anomalies.py +135 -0
  98. algo_cli/intelligence/finance/ap_ar.py +351 -0
  99. algo_cli/intelligence/finance/cash.py +162 -0
  100. algo_cli/intelligence/finance/close.py +332 -0
  101. algo_cli/intelligence/finance/common.py +244 -0
  102. algo_cli/intelligence/finance/construction.py +135 -0
  103. algo_cli/intelligence/finance/controls.py +172 -0
  104. algo_cli/intelligence/finance/evidence.py +119 -0
  105. algo_cli/intelligence/finance/exceptions.py +157 -0
  106. algo_cli/intelligence/finance/reconciliations.py +254 -0
  107. algo_cli/intelligence/finance/revenue.py +109 -0
  108. algo_cli/intelligence/finance/tax.py +74 -0
  109. algo_cli/intelligence/finance/workpapers.py +111 -0
  110. algo_cli/intelligence/finding_record.py +120 -0
  111. algo_cli/intelligence/flow_dag.py +267 -0
  112. algo_cli/intelligence/gatherer.py +223 -0
  113. algo_cli/intelligence/golden_master.py +98 -0
  114. algo_cli/intelligence/graph_rag.py +195 -0
  115. algo_cli/intelligence/group_chat.py +143 -0
  116. algo_cli/intelligence/hash_dedup.py +145 -0
  117. algo_cli/intelligence/hyperloglog.py +128 -0
  118. algo_cli/intelligence/incremental_index.py +316 -0
  119. algo_cli/intelligence/index_store.py +16 -0
  120. algo_cli/intelligence/iteration_plan.py +133 -0
  121. algo_cli/intelligence/kernel_plugins.py +167 -0
  122. algo_cli/intelligence/lesson_catalog.py +135 -0
  123. algo_cli/intelligence/llm_fallback.py +169 -0
  124. algo_cli/intelligence/log2_histogram.py +267 -0
  125. algo_cli/intelligence/lsp_integration.py +147 -0
  126. algo_cli/intelligence/memory_evolution.py +117 -0
  127. algo_cli/intelligence/minhash_lsh.py +182 -0
  128. algo_cli/intelligence/multi_model_score.py +174 -0
  129. algo_cli/intelligence/multi_tier_grade.py +211 -0
  130. algo_cli/intelligence/negative_controls.py +113 -0
  131. algo_cli/intelligence/numeric_clamp.py +63 -0
  132. algo_cli/intelligence/occ_editor.py +66 -0
  133. algo_cli/intelligence/output_normalize.py +112 -0
  134. algo_cli/intelligence/parallel_delegation.py +98 -0
  135. algo_cli/intelligence/parallel_fanout.py +104 -0
  136. algo_cli/intelligence/permission_modes.py +105 -0
  137. algo_cli/intelligence/pre_push_gate.py +68 -0
  138. algo_cli/intelligence/prefetch.py +171 -0
  139. algo_cli/intelligence/process_framework.py +217 -0
  140. algo_cli/intelligence/project_graph.py +387 -0
  141. algo_cli/intelligence/query_expansion.py +146 -0
  142. algo_cli/intelligence/ralph_loop.py +117 -0
  143. algo_cli/intelligence/rate_limiter.py +153 -0
  144. algo_cli/intelligence/refactor_transaction.py +94 -0
  145. algo_cli/intelligence/research_workspace.py +108 -0
  146. algo_cli/intelligence/retraction_ledger.py +72 -0
  147. algo_cli/intelligence/saga_pattern.py +88 -0
  148. algo_cli/intelligence/session_fork.py +100 -0
  149. algo_cli/intelligence/shadow_editor.py +67 -0
  150. algo_cli/intelligence/shell_session.py +213 -0
  151. algo_cli/intelligence/source_registry.py +143 -0
  152. algo_cli/intelligence/spawn_scales.py +99 -0
  153. algo_cli/intelligence/stat_stability.py +104 -0
  154. algo_cli/intelligence/structural_validator.py +148 -0
  155. algo_cli/intelligence/subagent_spawner.py +111 -0
  156. algo_cli/intelligence/symmetric_verify.py +70 -0
  157. algo_cli/intelligence/task_classifier.py +129 -0
  158. algo_cli/intelligence/team_execution.py +122 -0
  159. algo_cli/intelligence/tiered_access.py +121 -0
  160. algo_cli/intelligence/utility_registry.py +159 -0
  161. algo_cli/intuition_engine.py +560 -0
  162. algo_cli/intuition_injector.py +82 -0
  163. algo_cli/kernels/__init__.py +5 -0
  164. algo_cli/kernels/manifest.py +763 -0
  165. algo_cli/main.py +3903 -0
  166. algo_cli/memory_candidates.py +541 -0
  167. algo_cli/memory_echo_veil.py +394 -0
  168. algo_cli/memory_runtime.py +112 -0
  169. algo_cli/model_info.py +548 -0
  170. algo_cli/model_profile.py +160 -0
  171. algo_cli/model_routing.py +74 -0
  172. algo_cli/oneshot.py +331 -0
  173. algo_cli/perf_telemetry.py +389 -0
  174. algo_cli/plugins.py +245 -0
  175. algo_cli/private_event_store.py +654 -0
  176. algo_cli/quantization/__init__.py +24 -0
  177. algo_cli/quantization/lloyd_max.py +98 -0
  178. algo_cli/quantization/turbo_quant.py +308 -0
  179. algo_cli/reasoning/__init__.py +46 -0
  180. algo_cli/reasoning/combinatorial.py +356 -0
  181. algo_cli/reasoning/graph_of_thought.py +297 -0
  182. algo_cli/reasoning/mcts.py +220 -0
  183. algo_cli/reasoning/neuro_symbolic.py +250 -0
  184. algo_cli/reasoning/react.py +246 -0
  185. algo_cli/reasoning/reflexion.py +225 -0
  186. algo_cli/reasoning/tree_of_thought.py +241 -0
  187. algo_cli/reasoning_bridge.py +150 -0
  188. algo_cli/reconciliation.py +284 -0
  189. algo_cli/reflex.py +385 -0
  190. algo_cli/resources/docs/ALGO.md +13958 -0
  191. algo_cli/resources/docs/algo-cli-algorithm-evidence-contract.md +60 -0
  192. algo_cli/resources/docs/algo-cli-execution-verification-contract.md +59 -0
  193. algo_cli/resources/docs/algo-cli-memory-lifecycle-contract.md +72 -0
  194. algo_cli/resources/docs/harness-extension-cleanup-recommendation.md +41 -0
  195. algo_cli/resources/docs/index-compute-lab-integration.md +32 -0
  196. algo_cli/resources/docs/inference-harness-loop-blueprint-2026-06.md +55 -0
  197. algo_cli/resources/docs/main-split-map.md +35 -0
  198. algo_cli/resources/docs/privacy-and-context.md +48 -0
  199. algo_cli/resources/docs/reflex-loop-v0.2.md +354 -0
  200. algo_cli/resources/skills/README.md +26 -0
  201. algo_cli/resources/skills/algo-cli.md +59 -0
  202. algo_cli/resources/skills/edit-file-precision.md +49 -0
  203. algo_cli/resources/skills/harness-search-first.md +47 -0
  204. algo_cli/resources/skills/memory-recall-ritual.md +51 -0
  205. algo_cli/resources/skills/qol-algorithms.md +224 -0
  206. algo_cli/resources/skills/smart-error-recovery.md +56 -0
  207. algo_cli/resources/skills/tool-selection-cheatsheet.md +65 -0
  208. algo_cli/retrieval_algorithms.py +127 -0
  209. algo_cli/runtime_qos.py +236 -0
  210. algo_cli/runtime_services.py +320 -0
  211. algo_cli/session_commands.py +95 -0
  212. algo_cli/session_mode.py +113 -0
  213. algo_cli/skills.py +430 -0
  214. algo_cli/slash_dispatch.py +1265 -0
  215. algo_cli/small_context.py +206 -0
  216. algo_cli/spawn_budget.py +89 -0
  217. algo_cli/task_ledger.py +84 -0
  218. algo_cli/task_router.py +197 -0
  219. algo_cli/tool_context.py +94 -0
  220. algo_cli/tool_contract.py +99 -0
  221. algo_cli/tool_policy.py +357 -0
  222. algo_cli/tool_runtime.py +647 -0
  223. algo_cli/tools.py +3056 -0
  224. algo_cli/url_scheme.py +174 -0
  225. algo_cli/verify.py +154 -0
  226. algo_cli/version_manifest.py +178 -0
  227. algo_cli/vision_screenshot_verify.py +76 -0
  228. algo_cli/workspace_resolver.py +68 -0
  229. algo_cli/x_account.py +209 -0
  230. algo_cli/xai_auth.py +374 -0
  231. algo_cli/xai_client.py +600 -0
  232. algo_cli_runtime-0.14.0.dist-info/METADATA +369 -0
  233. algo_cli_runtime-0.14.0.dist-info/RECORD +237 -0
  234. algo_cli_runtime-0.14.0.dist-info/WHEEL +4 -0
  235. algo_cli_runtime-0.14.0.dist-info/entry_points.txt +3 -0
  236. algo_cli_runtime-0.14.0.dist-info/licenses/LICENSE +21 -0
  237. ollama_cli/__init__.py +67 -0
algo_cli/xai_client.py ADDED
@@ -0,0 +1,600 @@
1
+ """xAI chat client (OpenAI-compatible API → ollama-shaped responses).
2
+
3
+ Wraps api.x.ai/v1 with an interface compatible with ollama.Client.chat()
4
+ so the agent_loop does not need a separate code path for Grok. Auth is
5
+ provided by xai_auth.get_valid_token() (silent refresh).
6
+
7
+ Streaming: parses OpenAI Server-Sent Events and emits ollama-shaped chunks
8
+ of the form {"message": {"content": ..., "tool_calls": [...], "thinking": ...}}.
9
+
10
+ Tool-call deltas are accumulated by index and emitted as one complete chunk
11
+ when finish_reason="tool_calls" arrives, matching agent_loop's expectation
12
+ that tool_calls in a chunk are complete (not partial).
13
+
14
+ Multi-agent models and search use the xAI Responses API. Multi-agent does
15
+ not support Chat Completions or client-side custom tools, so that route
16
+ preserves text context but deliberately omits the local Python tool schema.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import urllib.error
22
+ import urllib.request
23
+ from typing import Any, Callable, Iterator
24
+
25
+ from . import xai_auth
26
+
27
+ try:
28
+ from ollama._utils import convert_function_to_tool
29
+ except Exception: # pragma: no cover
30
+ convert_function_to_tool = None # type: ignore[assignment]
31
+
32
+
33
+ XAI_OAUTH_PROVIDER_LABEL = "optional xAI Grok subscription OAuth"
34
+ _BILLING_OR_API_KEY_MARKERS = (
35
+ "api key",
36
+ "api_key",
37
+ "apikey",
38
+ "billing",
39
+ "credits",
40
+ "credit balance",
41
+ "invoice",
42
+ "payment",
43
+ "quota",
44
+ "spend",
45
+ )
46
+
47
+
48
+ class XaiOAuthAccessError(RuntimeError):
49
+ """Raised when OAuth access is unavailable without falling back to API spend."""
50
+
51
+
52
+ def _oauth_only_error(status: int | None, endpoint: str, detail: str) -> RuntimeError:
53
+ lower = detail.lower()
54
+ safe_detail = xai_auth.safe_error_message(detail)
55
+ gated = status in {402, 403} or any(marker in lower for marker in _BILLING_OR_API_KEY_MARKERS)
56
+ if gated:
57
+ return XaiOAuthAccessError(
58
+ f"xAI OAuth access was rejected for {endpoint}. "
59
+ "This CLI is configured for subscription OAuth only and will not use "
60
+ "XAI_API_KEY or any pay-per-token API-key fallback. "
61
+ f"Upstream response: {safe_detail or '(no body)'}"
62
+ )
63
+ prefix = f"xAI OAuth request failed for {endpoint}"
64
+ if status is not None:
65
+ prefix += f" ({status})"
66
+ return RuntimeError(f"{prefix} :: {safe_detail or '(no body)'}")
67
+
68
+
69
+ def _build_openai_tools(tools: list[Callable[..., Any]] | None) -> list[dict[str, Any]] | None:
70
+ if not tools or convert_function_to_tool is None:
71
+ return None
72
+ out: list[dict[str, Any]] = []
73
+ for fn in tools:
74
+ try:
75
+ spec = convert_function_to_tool(fn).model_dump(exclude_none=True)
76
+ except Exception:
77
+ continue
78
+ out.append(spec)
79
+ return out
80
+
81
+
82
+ def _build_openai_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
83
+ """Convert ollama-shaped messages to OpenAI chat completion format.
84
+
85
+ Ollama tool-result messages may omit `tool_call_id`; OpenAI requires it.
86
+ We consume assistant-emitted call IDs in order so duplicate tool names are
87
+ still associated one-to-one.
88
+ """
89
+ out: list[dict[str, Any]] = []
90
+ pending_call_ids: list[str] = []
91
+ counter = 0
92
+ for msg in messages:
93
+ role = msg.get("role")
94
+ if role == "assistant" and msg.get("tool_calls"):
95
+ calls_out: list[dict[str, Any]] = []
96
+ for call in msg["tool_calls"]:
97
+ if isinstance(call, dict):
98
+ fn = call.get("function") or {}
99
+ call_id = call.get("id")
100
+ else:
101
+ fn = getattr(call, "function", {}) or {}
102
+ call_id = getattr(call, "id", None)
103
+ if isinstance(fn, dict):
104
+ name = fn.get("name", "")
105
+ args = fn.get("arguments", "")
106
+ else:
107
+ name = getattr(fn, "name", "")
108
+ args = getattr(fn, "arguments", "")
109
+ if not isinstance(args, str):
110
+ args = json.dumps(args, ensure_ascii=False)
111
+ if not call_id:
112
+ counter += 1
113
+ call_id = f"call_{counter}"
114
+ call_id = str(call_id)
115
+ pending_call_ids.append(call_id)
116
+ calls_out.append(
117
+ {
118
+ "id": call_id,
119
+ "type": "function",
120
+ "function": {"name": name, "arguments": args or "{}"},
121
+ }
122
+ )
123
+ translated: dict[str, Any] = {"role": "assistant", "tool_calls": calls_out}
124
+ if msg.get("content"):
125
+ translated["content"] = msg["content"]
126
+ out.append(translated)
127
+ elif role == "tool":
128
+ explicit_call_id = msg.get("tool_call_id")
129
+ if explicit_call_id:
130
+ call_id = str(explicit_call_id)
131
+ if call_id not in pending_call_ids:
132
+ continue
133
+ pending_call_ids.remove(call_id)
134
+ elif pending_call_ids:
135
+ call_id = pending_call_ids.pop(0)
136
+ else:
137
+ continue
138
+ out.append(
139
+ {
140
+ "role": "tool",
141
+ "tool_call_id": call_id,
142
+ "content": str(msg.get("content", "")),
143
+ }
144
+ )
145
+ else:
146
+ keep = {k: v for k, v in msg.items() if k in {"role", "content"}}
147
+ out.append(keep)
148
+ return out
149
+
150
+
151
+ def is_multi_agent_model(model: str) -> bool:
152
+ return isinstance(model, str) and "multi-agent" in model.lower()
153
+
154
+
155
+ def _compact_tool_call(call: Any) -> str:
156
+ if isinstance(call, dict):
157
+ fn = call.get("function") or {}
158
+ else:
159
+ fn = getattr(call, "function", {}) or {}
160
+ if isinstance(fn, dict):
161
+ name = fn.get("name", "?")
162
+ args = fn.get("arguments", "")
163
+ else:
164
+ name = getattr(fn, "name", "?")
165
+ args = getattr(fn, "arguments", "")
166
+ if not isinstance(args, str):
167
+ args = json.dumps(args, ensure_ascii=False)
168
+ return f"{name}({args})"
169
+
170
+
171
+ def _build_responses_input(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
172
+ """Convert ollama history to Responses input without custom tool roles.
173
+
174
+ The multi-agent model does not accept client-side tools. Prior tool calls
175
+ and results are folded into assistant text so the model still sees the
176
+ relevant history without receiving unsupported function-call structures.
177
+ """
178
+ out: list[dict[str, Any]] = []
179
+ for msg in messages:
180
+ role = msg.get("role")
181
+ content = str(msg.get("content") or msg.get("thinking") or "")
182
+ if role == "tool":
183
+ tool_name = msg.get("name") or "tool"
184
+ content = f"[{tool_name} result]\n{content}"
185
+ role = "assistant"
186
+ elif role == "assistant" and msg.get("tool_calls"):
187
+ calls = ", ".join(_compact_tool_call(call) for call in msg.get("tool_calls") or [])
188
+ content = "\n".join(part for part in [content, f"[Called tools: {calls}]"] if part)
189
+ if role not in {"system", "user", "assistant"}:
190
+ role = "user"
191
+ if content:
192
+ out.append({"role": role, "content": content})
193
+ return out
194
+
195
+
196
+ def _post_chat(payload: dict[str, Any], *, stream: bool, timeout: float = 120.0) -> Any:
197
+ token = xai_auth.get_valid_token()
198
+ if not token:
199
+ raise XaiOAuthAccessError("Not authenticated with xAI OAuth. Run /xai-login first.")
200
+ body = json.dumps(payload).encode("utf-8")
201
+ req = urllib.request.Request(
202
+ f"{xai_auth.XAI_API_BASE}/chat/completions",
203
+ data=body,
204
+ headers={
205
+ "Authorization": f"Bearer {token}",
206
+ "Content-Type": "application/json",
207
+ "Accept": "text/event-stream" if stream else "application/json",
208
+ },
209
+ method="POST",
210
+ )
211
+ try:
212
+ return urllib.request.urlopen(req, timeout=timeout)
213
+ except urllib.error.HTTPError as exc:
214
+ detail = ""
215
+ try:
216
+ detail = exc.read().decode("utf-8", errors="replace")[:1500].strip()
217
+ except Exception:
218
+ pass
219
+ raise _oauth_only_error(exc.code, req.full_url, detail) from exc
220
+
221
+
222
+ def _post_responses(payload: dict[str, Any], *, timeout: float = 60.0) -> dict[str, Any]:
223
+ """POST to the xAI Responses API (/v1/responses).
224
+
225
+ Used for the built-in x_search tool and other Agent Tools.
226
+ Returns the parsed JSON response body.
227
+ """
228
+ token = xai_auth.get_valid_token()
229
+ if not token:
230
+ raise XaiOAuthAccessError("Not authenticated with xAI OAuth. Run /xai-login first.")
231
+ body = json.dumps(payload).encode("utf-8")
232
+ url = f"{xai_auth.XAI_API_BASE}/responses"
233
+ req = urllib.request.Request(
234
+ url,
235
+ data=body,
236
+ headers={
237
+ "Authorization": f"Bearer {token}",
238
+ "Content-Type": "application/json",
239
+ "Accept": "application/json",
240
+ },
241
+ method="POST",
242
+ )
243
+ try:
244
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
245
+ return json.loads(resp.read().decode("utf-8"))
246
+ except urllib.error.HTTPError as exc:
247
+ detail = ""
248
+ try:
249
+ detail = exc.read().decode("utf-8", errors="replace")[:1500].strip()
250
+ except Exception:
251
+ pass
252
+ raise _oauth_only_error(exc.code, url, detail) from exc
253
+
254
+
255
+ def get_models() -> dict[str, Any]:
256
+ """GET /v1/models with the current OAuth token. Useful as a token sanity check."""
257
+ token = xai_auth.get_valid_token()
258
+ if not token:
259
+ raise XaiOAuthAccessError("Not authenticated with xAI OAuth. Run /xai-login first.")
260
+ req = urllib.request.Request(
261
+ f"{xai_auth.XAI_API_BASE}/models",
262
+ headers={
263
+ "Authorization": f"Bearer {token}",
264
+ "Accept": "application/json",
265
+ },
266
+ method="GET",
267
+ )
268
+ try:
269
+ with urllib.request.urlopen(req, timeout=30) as resp:
270
+ return json.loads(resp.read().decode("utf-8"))
271
+ except urllib.error.HTTPError as exc:
272
+ detail = ""
273
+ try:
274
+ detail = exc.read().decode("utf-8", errors="replace")[:1500].strip()
275
+ except Exception:
276
+ pass
277
+ raise _oauth_only_error(exc.code, f"{xai_auth.XAI_API_BASE}/models", detail) from exc
278
+
279
+
280
+ def _parse_sse_events(resp: Any) -> Iterator[dict[str, Any]]:
281
+ """Yield parsed JSON events from a text/event-stream response.
282
+
283
+ Supports both fully framed SSE (blank line separates events) and the common
284
+ test/HTTP-client shape where each ``data:`` line is yielded as its own
285
+ complete event. Malformed partial data is buffered until a blank line/end of
286
+ stream, then skipped if it still is not valid JSON.
287
+ """
288
+ data_lines: list[str] = []
289
+
290
+ def parse_event(data: str) -> dict[str, Any] | None:
291
+ data = data.strip()
292
+ if not data:
293
+ return None
294
+ if data == "[DONE]":
295
+ raise StopIteration
296
+ try:
297
+ return json.loads(data)
298
+ except json.JSONDecodeError:
299
+ return None
300
+
301
+ def flush_buffer() -> dict[str, Any] | None:
302
+ if not data_lines:
303
+ return None
304
+ data = "\n".join(data_lines)
305
+ data_lines.clear()
306
+ return parse_event(data)
307
+
308
+ try:
309
+ for raw in resp:
310
+ line = raw.decode("utf-8", errors="replace").rstrip("\r\n")
311
+ if not line:
312
+ event = flush_buffer()
313
+ if event is not None:
314
+ yield event
315
+ continue
316
+ if line.startswith(":") or not line.startswith("data:"):
317
+ continue
318
+ data = line[5:].lstrip()
319
+ # Fast path for OpenAI-style one-JSON-object-per-data-line streams
320
+ # and for tests/fakes that omit the blank SSE separator.
321
+ event = parse_event(data)
322
+ if event is not None:
323
+ if data_lines:
324
+ buffered = flush_buffer()
325
+ if buffered is not None:
326
+ yield buffered
327
+ yield event
328
+ else:
329
+ data_lines.append(data)
330
+ event = flush_buffer()
331
+ if event is not None:
332
+ yield event
333
+ except StopIteration:
334
+ return
335
+
336
+
337
+ def _stream_iter(resp: Any) -> Iterator[dict[str, Any]]:
338
+ """Translate OpenAI-style SSE events into ollama-shaped chunks."""
339
+ pending_calls: dict[int, dict[str, Any]] = {}
340
+
341
+ try:
342
+ for event in _parse_sse_events(resp):
343
+ choices = event.get("choices") or []
344
+ if not choices:
345
+ continue
346
+ choice = choices[0]
347
+ delta = choice.get("delta") or {}
348
+ finish_reason = choice.get("finish_reason")
349
+
350
+ if delta.get("content"):
351
+ yield {"message": {"content": delta["content"]}}
352
+
353
+ if delta.get("reasoning_content"):
354
+ yield {"message": {"thinking": delta["reasoning_content"]}}
355
+
356
+ for tc_delta in delta.get("tool_calls") or []:
357
+ idx = int(tc_delta.get("index", 0))
358
+ slot = pending_calls.setdefault(
359
+ idx, {"function": {"name": "", "arguments": ""}}
360
+ )
361
+ if tc_delta.get("id"):
362
+ slot["id"] = tc_delta["id"]
363
+ if tc_delta.get("type"):
364
+ slot["type"] = tc_delta["type"]
365
+ fn_delta = tc_delta.get("function") or {}
366
+ if fn_delta.get("name"):
367
+ slot["function"]["name"] = fn_delta["name"]
368
+ if fn_delta.get("arguments"):
369
+ slot["function"]["arguments"] += fn_delta["arguments"]
370
+
371
+ if finish_reason in {"tool_calls", "stop"} and pending_calls:
372
+ completed = [pending_calls[i] for i in sorted(pending_calls)]
373
+ pending_calls.clear()
374
+ yield {"message": {"tool_calls": completed}}
375
+ if pending_calls:
376
+ completed = [pending_calls[i] for i in sorted(pending_calls)]
377
+ pending_calls.clear()
378
+ yield {"message": {"tool_calls": completed}}
379
+ finally:
380
+ try:
381
+ resp.close()
382
+ except Exception:
383
+ pass
384
+
385
+
386
+ def _nonstream_to_chunk(body: dict[str, Any]) -> dict[str, Any]:
387
+ """Convert a non-streaming xAI response into one ollama-shaped chunk."""
388
+ choice = (body.get("choices") or [{}])[0]
389
+ msg = choice.get("message") or {}
390
+ out_msg: dict[str, Any] = {}
391
+ if msg.get("content"):
392
+ out_msg["content"] = msg["content"]
393
+ if msg.get("reasoning_content"):
394
+ out_msg["thinking"] = msg["reasoning_content"]
395
+ if msg.get("tool_calls"):
396
+ out_msg["tool_calls"] = msg["tool_calls"]
397
+ chunk: dict[str, Any] = {"message": out_msg}
398
+ if body.get("citations"):
399
+ chunk["citations"] = body["citations"]
400
+ if body.get("usage"):
401
+ chunk["usage"] = body["usage"]
402
+ return chunk
403
+
404
+
405
+ def _responses_to_chunk(body: dict[str, Any]) -> dict[str, Any]:
406
+ """Convert a non-streaming Responses API body into one ollama-shaped chunk."""
407
+ out_msg: dict[str, Any] = {}
408
+ content_parts: list[str] = []
409
+ thinking_parts: list[str] = []
410
+ tool_calls: list[Any] = []
411
+
412
+ if body.get("output_text"):
413
+ content_parts.append(str(body["output_text"]))
414
+
415
+ for item in body.get("output", []):
416
+ if not isinstance(item, dict):
417
+ continue
418
+ item_type = item.get("type", "")
419
+ if item_type == "message":
420
+ for block in item.get("content", []):
421
+ if not isinstance(block, dict):
422
+ continue
423
+ if block.get("type") == "output_text" and block.get("text"):
424
+ content_parts.append(str(block["text"]))
425
+ elif item_type in {"reasoning", "summary"}:
426
+ for block in item.get("summary", []) or item.get("content", []):
427
+ if isinstance(block, dict) and block.get("text"):
428
+ thinking_parts.append(str(block["text"]))
429
+ elif isinstance(block, str):
430
+ thinking_parts.append(block)
431
+ elif item_type in {"function_call", "tool_call"}:
432
+ tool_calls.append(item)
433
+
434
+ if content_parts:
435
+ out_msg["content"] = "\n\n".join(content_parts)
436
+ if thinking_parts:
437
+ out_msg["thinking"] = "\n".join(thinking_parts)
438
+ if tool_calls:
439
+ out_msg["tool_calls"] = tool_calls
440
+
441
+ chunk: dict[str, Any] = {"message": out_msg}
442
+ if body.get("citations"):
443
+ chunk["citations"] = body["citations"]
444
+ if body.get("usage"):
445
+ chunk["usage"] = body["usage"]
446
+ return chunk
447
+
448
+
449
+ class XaiClient:
450
+ """Ollama-shaped chat client routed to api.x.ai/v1."""
451
+
452
+ def chat(
453
+ self,
454
+ *,
455
+ model: str,
456
+ messages: list[dict[str, Any]],
457
+ tools: list[Callable[..., Any]] | list[dict[str, Any]] | None = None,
458
+ stream: bool = False,
459
+ options: dict[str, Any] | None = None,
460
+ search_parameters: dict[str, Any] | None = None,
461
+ **_ignored: Any,
462
+ ) -> Any:
463
+ if is_multi_agent_model(model):
464
+ payload: dict[str, Any] = {
465
+ "model": model,
466
+ "input": _build_responses_input(messages),
467
+ }
468
+ if options and "temperature" in options:
469
+ payload["temperature"] = options["temperature"]
470
+ body = _post_responses(payload, timeout=3600.0)
471
+ chunk = _responses_to_chunk(body)
472
+ if stream:
473
+ return iter([chunk])
474
+ return chunk
475
+
476
+ payload: dict[str, Any] = {
477
+ "model": model,
478
+ "messages": _build_openai_messages(messages),
479
+ "stream": bool(stream),
480
+ }
481
+ if tools:
482
+ built: list[dict[str, Any]] | None
483
+ if tools and isinstance(tools[0], dict):
484
+ built = list(tools) # already in OpenAI format
485
+ else:
486
+ built = _build_openai_tools(tools) # type: ignore[arg-type]
487
+ if built:
488
+ payload["tools"] = built
489
+ if options:
490
+ if "temperature" in options:
491
+ payload["temperature"] = options["temperature"]
492
+ if "top_p" in options:
493
+ payload["top_p"] = options["top_p"]
494
+ if search_parameters:
495
+ payload["search_parameters"] = search_parameters
496
+
497
+ if stream:
498
+ resp = _post_chat(payload, stream=True)
499
+ return _stream_iter(resp)
500
+ resp = _post_chat(payload, stream=False)
501
+ try:
502
+ body = json.loads(resp.read().decode("utf-8"))
503
+ return _nonstream_to_chunk(body)
504
+ finally:
505
+ try:
506
+ resp.close()
507
+ except Exception:
508
+ pass
509
+
510
+ def search(
511
+ self,
512
+ *,
513
+ query: str,
514
+ model: str = "grok-4-latest",
515
+ sources: list[dict[str, Any]] | None = None,
516
+ max_results: int = 10,
517
+ from_date: str | None = None,
518
+ to_date: str | None = None,
519
+ allowed_x_handles: list[str] | None = None,
520
+ excluded_x_handles: list[str] | None = None,
521
+ ) -> dict[str, Any]:
522
+ """Search X.com via the xAI Responses API with the built-in x_search tool.
523
+
524
+ Replaces the deprecated Live Search (/v1/chat/completions + search_parameters).
525
+ Uses the Agent Tools API: POST /v1/responses with tools=[{type: "x_search"}].
526
+
527
+ Returns {"content": str, "citations": list[str]}.
528
+ """
529
+ tool_config: dict[str, Any] = {
530
+ "type": "x_search",
531
+ "max_results": max(1, min(int(max_results), 30)),
532
+ }
533
+ if from_date:
534
+ tool_config["from_date"] = from_date
535
+ if to_date:
536
+ tool_config["to_date"] = to_date
537
+ if allowed_x_handles:
538
+ tool_config["allowed_x_handles"] = allowed_x_handles
539
+ if excluded_x_handles:
540
+ tool_config["excluded_x_handles"] = excluded_x_handles
541
+
542
+ payload: dict[str, Any] = {
543
+ "model": model,
544
+ "input": [
545
+ {"role": "user", "content": query},
546
+ ],
547
+ "tools": [tool_config],
548
+ }
549
+
550
+ body = _post_responses(payload)
551
+
552
+ # Extract content and citations from the Responses API output.
553
+ # The response shape is: {"output": [...items...], "usage": {...}}
554
+ # Content items have type "message", search results have type "x_search_call".
555
+ content_parts: list[str] = []
556
+ citations: list[str] = []
557
+
558
+ for item in body.get("output", []):
559
+ if not isinstance(item, dict):
560
+ continue
561
+ item_type = item.get("type", "")
562
+ if item_type == "message":
563
+ # Message item: {"type": "message", "content": [{"type": "output_text", "text": "..."}]}
564
+ for content_block in item.get("content", []):
565
+ if not isinstance(content_block, dict):
566
+ continue
567
+ if content_block.get("type") == "output_text" and content_block.get("text"):
568
+ content_parts.append(content_block["text"])
569
+ elif item_type == "x_search_call":
570
+ # Search call result may contain citations
571
+ result = item.get("result", {})
572
+ if isinstance(result, dict):
573
+ for url in result.get("cited_urls", []):
574
+ if isinstance(url, str):
575
+ citations.append(url)
576
+ elif isinstance(url, dict) and url.get("url"):
577
+ citations.append(url["url"])
578
+
579
+ # Also check top-level citations if present (some models return them there)
580
+ for url in body.get("citations", []):
581
+ if isinstance(url, str) and url not in citations:
582
+ citations.append(url)
583
+ elif isinstance(url, dict) and url.get("url") and url["url"] not in citations:
584
+ citations.append(url["url"])
585
+
586
+ return {
587
+ "content": "\n\n".join(content_parts) if content_parts else "(Grok returned no summary.)",
588
+ "citations": citations,
589
+ "usage": body.get("usage") or {},
590
+ }
591
+
592
+
593
+ _CLIENT: XaiClient | None = None
594
+
595
+
596
+ def active_xai_client() -> XaiClient:
597
+ global _CLIENT
598
+ if _CLIENT is None:
599
+ _CLIENT = XaiClient()
600
+ return _CLIENT