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.
- algo_cli/__init__.py +3 -0
- algo_cli/__main__.py +7 -0
- algo_cli/_internal/__init__.py +12 -0
- algo_cli/_internal/policy_chain.py +259 -0
- algo_cli/action_registry.py +1047 -0
- algo_cli/agent_blocks.py +550 -0
- algo_cli/agent_pipeline.py +1457 -0
- algo_cli/agent_threads.py +308 -0
- algo_cli/animations.py +316 -0
- algo_cli/cache_admission.py +209 -0
- algo_cli/capability_mask.py +66 -0
- algo_cli/chat_protocol.py +116 -0
- algo_cli/chatgpt_auth.py +510 -0
- algo_cli/chatgpt_client.py +657 -0
- algo_cli/code_rag.py +479 -0
- algo_cli/config.py +651 -0
- algo_cli/context_budget.py +679 -0
- algo_cli/credential_helpers.py +315 -0
- algo_cli/deliberation.py +29 -0
- algo_cli/display.py +1470 -0
- algo_cli/evals/__init__.py +21 -0
- algo_cli/evals/algorithm_effectiveness.py +560 -0
- algo_cli/evals/competitive_harness_rating.py +702 -0
- algo_cli/evals/cot_quality.py +220 -0
- algo_cli/evals/harness_retrieval_benchmark.py +401 -0
- algo_cli/evals/performance_regression.py +136 -0
- algo_cli/evals/scorecard_grading.py +308 -0
- algo_cli/evals/session_distribution.py +84 -0
- algo_cli/execution_guardrails.py +806 -0
- algo_cli/extensions_manifest.py +84 -0
- algo_cli/git_evidence.py +227 -0
- algo_cli/google_workspace.py +407 -0
- algo_cli/google_workspace_auth.py +523 -0
- algo_cli/harness.py +2587 -0
- algo_cli/identity.py +557 -0
- algo_cli/index_compute_lab.py +228 -0
- algo_cli/inference_harness.py +70 -0
- algo_cli/intelligence/__init__.py +1103 -0
- algo_cli/intelligence/acrobat_config.py +307 -0
- algo_cli/intelligence/acrobat_manifests.py +338 -0
- algo_cli/intelligence/acrobat_models.py +195 -0
- algo_cli/intelligence/acrobat_pipeline.py +295 -0
- algo_cli/intelligence/acrobat_runtime.py +302 -0
- algo_cli/intelligence/acrobat_security.py +261 -0
- algo_cli/intelligence/acrobat_workflows.py +226 -0
- algo_cli/intelligence/actionability.py +165 -0
- algo_cli/intelligence/adversarial_audit.py +136 -0
- algo_cli/intelligence/agent_arena.py +92 -0
- algo_cli/intelligence/agent_benchmark.py +236 -0
- algo_cli/intelligence/agent_runtime.py +171 -0
- algo_cli/intelligence/agents_as_tools.py +70 -0
- algo_cli/intelligence/artifact_binding.py +80 -0
- algo_cli/intelligence/autonomous_engineer.py +1976 -0
- algo_cli/intelligence/backpressure.py +99 -0
- algo_cli/intelligence/bloom_filter.py +186 -0
- algo_cli/intelligence/bonferroni.py +66 -0
- algo_cli/intelligence/boundary_compaction.py +98 -0
- algo_cli/intelligence/catalog_verifier.py +172 -0
- algo_cli/intelligence/cavecrew.py +118 -0
- algo_cli/intelligence/changelog.py +176 -0
- algo_cli/intelligence/checkpoint_resume.py +92 -0
- algo_cli/intelligence/circuit_breaker.py +88 -0
- algo_cli/intelligence/clarification_gate.py +101 -0
- algo_cli/intelligence/code_graph.py +180 -0
- algo_cli/intelligence/coderank.py +97 -0
- algo_cli/intelligence/consistent_hash.py +150 -0
- algo_cli/intelligence/consortium_synthesis.py +139 -0
- algo_cli/intelligence/construction/__init__.py +241 -0
- algo_cli/intelligence/construction/common.py +273 -0
- algo_cli/intelligence/construction/documents.py +496 -0
- algo_cli/intelligence/construction/labor_units.py +1395 -0
- algo_cli/intelligence/construction/payments.py +470 -0
- algo_cli/intelligence/construction/risk.py +784 -0
- algo_cli/intelligence/content_extractor.py +132 -0
- algo_cli/intelligence/context_adaptive.py +102 -0
- algo_cli/intelligence/context_ops.py +95 -0
- algo_cli/intelligence/count_min.py +145 -0
- algo_cli/intelligence/cow_state.py +103 -0
- algo_cli/intelligence/critic_loop.py +119 -0
- algo_cli/intelligence/cross_source.py +113 -0
- algo_cli/intelligence/daemon_mode.py +99 -0
- algo_cli/intelligence/dag_orchestration.py +151 -0
- algo_cli/intelligence/deep_research.py +155 -0
- algo_cli/intelligence/degenerate_detector.py +78 -0
- algo_cli/intelligence/delta_report.py +92 -0
- algo_cli/intelligence/discovery_event_log.py +92 -0
- algo_cli/intelligence/document_ingest.py +298 -0
- algo_cli/intelligence/dual_layer_validate.py +151 -0
- algo_cli/intelligence/echo_fidelity.py +73 -0
- algo_cli/intelligence/ema_tuning.py +104 -0
- algo_cli/intelligence/event_log.py +92 -0
- algo_cli/intelligence/evidence_graph.py +114 -0
- algo_cli/intelligence/extension_host.py +162 -0
- algo_cli/intelligence/extension_manifest.py +115 -0
- algo_cli/intelligence/falsification_suite.py +178 -0
- algo_cli/intelligence/finance/__init__.py +169 -0
- algo_cli/intelligence/finance/anomalies.py +135 -0
- algo_cli/intelligence/finance/ap_ar.py +351 -0
- algo_cli/intelligence/finance/cash.py +162 -0
- algo_cli/intelligence/finance/close.py +332 -0
- algo_cli/intelligence/finance/common.py +244 -0
- algo_cli/intelligence/finance/construction.py +135 -0
- algo_cli/intelligence/finance/controls.py +172 -0
- algo_cli/intelligence/finance/evidence.py +119 -0
- algo_cli/intelligence/finance/exceptions.py +157 -0
- algo_cli/intelligence/finance/reconciliations.py +254 -0
- algo_cli/intelligence/finance/revenue.py +109 -0
- algo_cli/intelligence/finance/tax.py +74 -0
- algo_cli/intelligence/finance/workpapers.py +111 -0
- algo_cli/intelligence/finding_record.py +120 -0
- algo_cli/intelligence/flow_dag.py +267 -0
- algo_cli/intelligence/gatherer.py +223 -0
- algo_cli/intelligence/golden_master.py +98 -0
- algo_cli/intelligence/graph_rag.py +195 -0
- algo_cli/intelligence/group_chat.py +143 -0
- algo_cli/intelligence/hash_dedup.py +145 -0
- algo_cli/intelligence/hyperloglog.py +128 -0
- algo_cli/intelligence/incremental_index.py +316 -0
- algo_cli/intelligence/index_store.py +16 -0
- algo_cli/intelligence/iteration_plan.py +133 -0
- algo_cli/intelligence/kernel_plugins.py +167 -0
- algo_cli/intelligence/lesson_catalog.py +135 -0
- algo_cli/intelligence/llm_fallback.py +169 -0
- algo_cli/intelligence/log2_histogram.py +267 -0
- algo_cli/intelligence/lsp_integration.py +147 -0
- algo_cli/intelligence/memory_evolution.py +117 -0
- algo_cli/intelligence/minhash_lsh.py +182 -0
- algo_cli/intelligence/multi_model_score.py +174 -0
- algo_cli/intelligence/multi_tier_grade.py +211 -0
- algo_cli/intelligence/negative_controls.py +113 -0
- algo_cli/intelligence/numeric_clamp.py +63 -0
- algo_cli/intelligence/occ_editor.py +66 -0
- algo_cli/intelligence/output_normalize.py +112 -0
- algo_cli/intelligence/parallel_delegation.py +98 -0
- algo_cli/intelligence/parallel_fanout.py +104 -0
- algo_cli/intelligence/permission_modes.py +105 -0
- algo_cli/intelligence/pre_push_gate.py +68 -0
- algo_cli/intelligence/prefetch.py +171 -0
- algo_cli/intelligence/process_framework.py +217 -0
- algo_cli/intelligence/project_graph.py +387 -0
- algo_cli/intelligence/query_expansion.py +146 -0
- algo_cli/intelligence/ralph_loop.py +117 -0
- algo_cli/intelligence/rate_limiter.py +153 -0
- algo_cli/intelligence/refactor_transaction.py +94 -0
- algo_cli/intelligence/research_workspace.py +108 -0
- algo_cli/intelligence/retraction_ledger.py +72 -0
- algo_cli/intelligence/saga_pattern.py +88 -0
- algo_cli/intelligence/session_fork.py +100 -0
- algo_cli/intelligence/shadow_editor.py +67 -0
- algo_cli/intelligence/shell_session.py +213 -0
- algo_cli/intelligence/source_registry.py +143 -0
- algo_cli/intelligence/spawn_scales.py +99 -0
- algo_cli/intelligence/stat_stability.py +104 -0
- algo_cli/intelligence/structural_validator.py +148 -0
- algo_cli/intelligence/subagent_spawner.py +111 -0
- algo_cli/intelligence/symmetric_verify.py +70 -0
- algo_cli/intelligence/task_classifier.py +129 -0
- algo_cli/intelligence/team_execution.py +122 -0
- algo_cli/intelligence/tiered_access.py +121 -0
- algo_cli/intelligence/utility_registry.py +159 -0
- algo_cli/intuition_engine.py +560 -0
- algo_cli/intuition_injector.py +82 -0
- algo_cli/kernels/__init__.py +5 -0
- algo_cli/kernels/manifest.py +763 -0
- algo_cli/main.py +3903 -0
- algo_cli/memory_candidates.py +541 -0
- algo_cli/memory_echo_veil.py +394 -0
- algo_cli/memory_runtime.py +112 -0
- algo_cli/model_info.py +548 -0
- algo_cli/model_profile.py +160 -0
- algo_cli/model_routing.py +74 -0
- algo_cli/oneshot.py +331 -0
- algo_cli/perf_telemetry.py +389 -0
- algo_cli/plugins.py +245 -0
- algo_cli/private_event_store.py +654 -0
- algo_cli/quantization/__init__.py +24 -0
- algo_cli/quantization/lloyd_max.py +98 -0
- algo_cli/quantization/turbo_quant.py +308 -0
- algo_cli/reasoning/__init__.py +46 -0
- algo_cli/reasoning/combinatorial.py +356 -0
- algo_cli/reasoning/graph_of_thought.py +297 -0
- algo_cli/reasoning/mcts.py +220 -0
- algo_cli/reasoning/neuro_symbolic.py +250 -0
- algo_cli/reasoning/react.py +246 -0
- algo_cli/reasoning/reflexion.py +225 -0
- algo_cli/reasoning/tree_of_thought.py +241 -0
- algo_cli/reasoning_bridge.py +150 -0
- algo_cli/reconciliation.py +284 -0
- algo_cli/reflex.py +385 -0
- algo_cli/resources/docs/ALGO.md +13958 -0
- algo_cli/resources/docs/algo-cli-algorithm-evidence-contract.md +60 -0
- algo_cli/resources/docs/algo-cli-execution-verification-contract.md +59 -0
- algo_cli/resources/docs/algo-cli-memory-lifecycle-contract.md +72 -0
- algo_cli/resources/docs/harness-extension-cleanup-recommendation.md +41 -0
- algo_cli/resources/docs/index-compute-lab-integration.md +32 -0
- algo_cli/resources/docs/inference-harness-loop-blueprint-2026-06.md +55 -0
- algo_cli/resources/docs/main-split-map.md +35 -0
- algo_cli/resources/docs/privacy-and-context.md +48 -0
- algo_cli/resources/docs/reflex-loop-v0.2.md +354 -0
- algo_cli/resources/skills/README.md +26 -0
- algo_cli/resources/skills/algo-cli.md +59 -0
- algo_cli/resources/skills/edit-file-precision.md +49 -0
- algo_cli/resources/skills/harness-search-first.md +47 -0
- algo_cli/resources/skills/memory-recall-ritual.md +51 -0
- algo_cli/resources/skills/qol-algorithms.md +224 -0
- algo_cli/resources/skills/smart-error-recovery.md +56 -0
- algo_cli/resources/skills/tool-selection-cheatsheet.md +65 -0
- algo_cli/retrieval_algorithms.py +127 -0
- algo_cli/runtime_qos.py +236 -0
- algo_cli/runtime_services.py +320 -0
- algo_cli/session_commands.py +95 -0
- algo_cli/session_mode.py +113 -0
- algo_cli/skills.py +430 -0
- algo_cli/slash_dispatch.py +1265 -0
- algo_cli/small_context.py +206 -0
- algo_cli/spawn_budget.py +89 -0
- algo_cli/task_ledger.py +84 -0
- algo_cli/task_router.py +197 -0
- algo_cli/tool_context.py +94 -0
- algo_cli/tool_contract.py +99 -0
- algo_cli/tool_policy.py +357 -0
- algo_cli/tool_runtime.py +647 -0
- algo_cli/tools.py +3056 -0
- algo_cli/url_scheme.py +174 -0
- algo_cli/verify.py +154 -0
- algo_cli/version_manifest.py +178 -0
- algo_cli/vision_screenshot_verify.py +76 -0
- algo_cli/workspace_resolver.py +68 -0
- algo_cli/x_account.py +209 -0
- algo_cli/xai_auth.py +374 -0
- algo_cli/xai_client.py +600 -0
- algo_cli_runtime-0.14.0.dist-info/METADATA +369 -0
- algo_cli_runtime-0.14.0.dist-info/RECORD +237 -0
- algo_cli_runtime-0.14.0.dist-info/WHEEL +4 -0
- algo_cli_runtime-0.14.0.dist-info/entry_points.txt +3 -0
- algo_cli_runtime-0.14.0.dist-info/licenses/LICENSE +21 -0
- 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
|