althing 1.7.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 (156) hide show
  1. althing/__init__.py +46 -0
  2. althing/__main__.py +9 -0
  3. althing/__version__.py +1 -0
  4. althing/_compat_env.py +27 -0
  5. althing/_runners.py +953 -0
  6. althing/agent_assets/__init__.py +3 -0
  7. althing/agent_assets/commands/althing-poll.md +75 -0
  8. althing/agent_assets/skills/concept-test/SKILL.md +72 -0
  9. althing/agent_assets/skills/focus-group/SKILL.md +77 -0
  10. althing/agent_assets/skills/name-test/SKILL.md +62 -0
  11. althing/agent_assets/skills/pricing-probe/SKILL.md +75 -0
  12. althing/agent_assets/skills/survey-prescreen/SKILL.md +76 -0
  13. althing/aggregation.py +240 -0
  14. althing/analysis/__init__.py +46 -0
  15. althing/analysis/distribution.py +323 -0
  16. althing/analysis/inspect.py +586 -0
  17. althing/analysis/subgroup.py +168 -0
  18. althing/analysis/subgroup_cli.py +598 -0
  19. althing/analyze.py +839 -0
  20. althing/attachments/__init__.py +77 -0
  21. althing/attachments/filter.py +67 -0
  22. althing/attachments/models.py +50 -0
  23. althing/attachments/pdf.py +537 -0
  24. althing/attachments/store.py +111 -0
  25. althing/calibration.py +239 -0
  26. althing/checkpoint.py +763 -0
  27. althing/cli/__init__.py +1 -0
  28. althing/cli/commands.py +8080 -0
  29. althing/cli/mcp_install.py +487 -0
  30. althing/cli/output.py +106 -0
  31. althing/cli/parser.py +1706 -0
  32. althing/cli/progress.py +93 -0
  33. althing/cli/repl.py +122 -0
  34. althing/cli/slash.py +197 -0
  35. althing/conditions.py +261 -0
  36. althing/convergence.py +1091 -0
  37. althing/cost.py +950 -0
  38. althing/cost_summary.py +452 -0
  39. althing/credentials.py +311 -0
  40. althing/data/__init__.py +8 -0
  41. althing/data/synthbench-snapshot.json +120 -0
  42. althing/diff.py +331 -0
  43. althing/domains.py +284 -0
  44. althing/ensemble.py +922 -0
  45. althing/fetch/__init__.py +38 -0
  46. althing/fetch/cache.py +360 -0
  47. althing/fetch/ladder.py +437 -0
  48. althing/fetch/lower.py +199 -0
  49. althing/fetch/perimeter.py +494 -0
  50. althing/instrument.py +751 -0
  51. althing/integrations/__init__.py +21 -0
  52. althing/integrations/composio.py +345 -0
  53. althing/llm/__init__.py +32 -0
  54. althing/llm/aliases.py +138 -0
  55. althing/llm/capabilities.py +86 -0
  56. althing/llm/client.py +267 -0
  57. althing/llm/errors.py +162 -0
  58. althing/llm/fast_default.py +71 -0
  59. althing/llm/models.py +340 -0
  60. althing/llm/providers/__init__.py +18 -0
  61. althing/llm/providers/_anthropic_format.py +281 -0
  62. althing/llm/providers/_openai_format.py +381 -0
  63. althing/llm/providers/anthropic.py +172 -0
  64. althing/llm/providers/base.py +60 -0
  65. althing/llm/providers/gemini.py +107 -0
  66. althing/llm/providers/openai_compat.py +102 -0
  67. althing/llm/providers/openrouter.py +357 -0
  68. althing/llm/providers/xai.py +98 -0
  69. althing/llm/retry.py +113 -0
  70. althing/logging_config.py +80 -0
  71. althing/main.py +308 -0
  72. althing/mcp/__init__.py +7 -0
  73. althing/mcp/compat.py +110 -0
  74. althing/mcp/data.py +700 -0
  75. althing/mcp/sampling.py +380 -0
  76. althing/mcp/server.py +3371 -0
  77. althing/metadata.py +214 -0
  78. althing/metadata_migrations.py +70 -0
  79. althing/orchestrator.py +2119 -0
  80. althing/pack_diff.py +271 -0
  81. althing/packs/__init__.py +3 -0
  82. althing/packs/ai-eval-buyers.yaml +247 -0
  83. althing/packs/broad-professionals.yaml +251 -0
  84. althing/packs/developer.yaml +271 -0
  85. althing/packs/enterprise-ai-buyers.yaml +245 -0
  86. althing/packs/enterprise-buyer.yaml +252 -0
  87. althing/packs/general-consumer.yaml +271 -0
  88. althing/packs/healthcare-patient.yaml +271 -0
  89. althing/packs/instruments/__init__.py +1 -0
  90. althing/packs/instruments/churn-diagnosis.yaml +73 -0
  91. althing/packs/instruments/feature-prioritization.yaml +75 -0
  92. althing/packs/instruments/general-survey.yaml +46 -0
  93. althing/packs/instruments/happiness-probe.yaml +41 -0
  94. althing/packs/instruments/landing-page-comprehension.yaml +75 -0
  95. althing/packs/instruments/market-research.yaml +50 -0
  96. althing/packs/instruments/name-test.yaml +74 -0
  97. althing/packs/instruments/pricing-discovery.yaml +80 -0
  98. althing/packs/instruments/product-feedback.yaml +46 -0
  99. althing/packs/job-seekers.yaml +188 -0
  100. althing/packs/market-research-critics.yaml +217 -0
  101. althing/packs/product-research.yaml +248 -0
  102. althing/packs/recruiters-talent.yaml +264 -0
  103. althing/packs/skeptical-executives.yaml +227 -0
  104. althing/packs/startup-founder.yaml +242 -0
  105. althing/packs/students.yaml +352 -0
  106. althing/persistence.py +510 -0
  107. althing/perturbation.py +333 -0
  108. althing/plugins/__init__.py +34 -0
  109. althing/plugins/hooks.py +159 -0
  110. althing/plugins/lint.py +352 -0
  111. althing/plugins/manager.py +176 -0
  112. althing/plugins/manifest.py +100 -0
  113. althing/plugins/registry.py +66 -0
  114. althing/poll_summary.py +1195 -0
  115. althing/preflight.py +155 -0
  116. althing/profiles.py +264 -0
  117. althing/prompts.py +311 -0
  118. althing/py.typed +0 -0
  119. althing/question_budget.py +140 -0
  120. althing/registry/__init__.py +178 -0
  121. althing/registry/cache.py +223 -0
  122. althing/registry/fetch.py +104 -0
  123. althing/registry/github.py +212 -0
  124. althing/reporting/__init__.py +17 -0
  125. althing/reporting/loader.py +69 -0
  126. althing/reporting/markdown.py +450 -0
  127. althing/response_coercion.py +161 -0
  128. althing/routing.py +132 -0
  129. althing/runtime.py +552 -0
  130. althing/schemas/__init__.py +53 -0
  131. althing/schemas/v1.0.0.json +161 -0
  132. althing/sdk.py +1458 -0
  133. althing/stats.py +1522 -0
  134. althing/structured/__init__.py +5 -0
  135. althing/structured/models.py +149 -0
  136. althing/structured/output.py +376 -0
  137. althing/structured/retry.py +170 -0
  138. althing/structured/schemas.py +127 -0
  139. althing/structured/validate.py +303 -0
  140. althing/structured/verdict.py +146 -0
  141. althing/synthbench.py +768 -0
  142. althing/synthbench_submit.py +582 -0
  143. althing/synthesis.py +1529 -0
  144. althing/synthesis_recovery.py +467 -0
  145. althing/templates.py +179 -0
  146. althing/text_width.py +126 -0
  147. althing-1.7.0.dist-info/METADATA +1473 -0
  148. althing-1.7.0.dist-info/RECORD +156 -0
  149. althing-1.7.0.dist-info/WHEEL +5 -0
  150. althing-1.7.0.dist-info/entry_points.txt +3 -0
  151. althing-1.7.0.dist-info/licenses/LICENSE +21 -0
  152. althing-1.7.0.dist-info/top_level.txt +3 -0
  153. synth_panel/__init__.py +60 -0
  154. synth_panel/__main__.py +17 -0
  155. synthpanel/__init__.py +60 -0
  156. synthpanel/__main__.py +17 -0
@@ -0,0 +1,107 @@
1
+ """Google Gemini provider implementation (SPEC.md §2).
2
+
3
+ Google's Gemini API exposes an OpenAI-compatible chat completions endpoint,
4
+ so this provider translates our internal request/response models to that format.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from collections.abc import Iterator
11
+
12
+ import httpx
13
+
14
+ from althing.credentials import get_credential, missing_api_key_message
15
+ from althing.llm.errors import LLMError, LLMErrorCategory, llm_error_from_response
16
+ from althing.llm.models import (
17
+ CompletionRequest,
18
+ CompletionResponse,
19
+ StreamEvent,
20
+ )
21
+ from althing.llm.providers._openai_format import (
22
+ build_openai_body,
23
+ parse_openai_response,
24
+ parse_openai_sse_stream,
25
+ )
26
+ from althing.llm.providers.base import LLMProvider, ProviderConfig
27
+
28
+ GEMINI_CONFIG = ProviderConfig(
29
+ api_key_env="GEMINI_API_KEY",
30
+ base_url_env="GEMINI_BASE_URL",
31
+ default_base_url="https://generativelanguage.googleapis.com/v1beta/openai",
32
+ model_prefixes=("gemini-",),
33
+ name="Gemini",
34
+ supports_seed=True,
35
+ )
36
+
37
+
38
+ class GeminiProvider(LLMProvider):
39
+ """Google Gemini provider (OpenAI-compatible chat completions)."""
40
+
41
+ config = GEMINI_CONFIG
42
+
43
+ def __init__(self) -> None:
44
+ # Support both GEMINI_API_KEY and GOOGLE_API_KEY, resolved via env
45
+ # or the on-disk credential store.
46
+ key = get_credential("GEMINI_API_KEY") or get_credential("GOOGLE_API_KEY")
47
+ if not key:
48
+ raise LLMError(
49
+ missing_api_key_message("GEMINI_API_KEY", alt_env_vars=("GOOGLE_API_KEY",)),
50
+ LLMErrorCategory.MISSING_CREDENTIALS,
51
+ )
52
+ self._api_key = key
53
+ self._base_url = self.config.get_base_url()
54
+
55
+ def _headers(self) -> dict[str, str]:
56
+ return {
57
+ "Authorization": f"Bearer {self._api_key}",
58
+ "Content-Type": "application/json",
59
+ }
60
+
61
+ def send(self, request: CompletionRequest) -> CompletionResponse:
62
+ url = f"{self._base_url}/chat/completions"
63
+ body = build_openai_body(request)
64
+ try:
65
+ resp = httpx.post(url, headers=self._headers(), json=body, timeout=120.0)
66
+ except httpx.HTTPError as exc:
67
+ raise LLMError(
68
+ f"Transport error: {exc}",
69
+ LLMErrorCategory.TRANSPORT,
70
+ cause=exc,
71
+ ) from exc
72
+
73
+ if resp.status_code != 200:
74
+ raise llm_error_from_response(resp, "Gemini")
75
+
76
+ try:
77
+ data = resp.json()
78
+ except (json.JSONDecodeError, ValueError) as exc:
79
+ raise LLMError(
80
+ "Failed to parse Gemini response",
81
+ LLMErrorCategory.DESERIALIZATION,
82
+ cause=exc,
83
+ ) from exc
84
+
85
+ return parse_openai_response(data, request.model)
86
+
87
+ def stream(self, request: CompletionRequest) -> Iterator[StreamEvent]:
88
+ url = f"{self._base_url}/chat/completions"
89
+ body = build_openai_body(request, stream=True)
90
+ try:
91
+ with httpx.stream(
92
+ "POST",
93
+ url,
94
+ headers=self._headers(),
95
+ json=body,
96
+ timeout=120.0,
97
+ ) as resp:
98
+ if resp.status_code != 200:
99
+ resp.read()
100
+ raise llm_error_from_response(resp, "Gemini")
101
+ yield from parse_openai_sse_stream(resp.iter_lines())
102
+ except httpx.HTTPError as exc:
103
+ raise LLMError(
104
+ f"Transport error during stream: {exc}",
105
+ LLMErrorCategory.TRANSPORT,
106
+ cause=exc,
107
+ ) from exc
@@ -0,0 +1,102 @@
1
+ """Generic OpenAI-compatible provider (SPEC.md §2).
2
+
3
+ This is the fallback for models that don't match Anthropic or xAI prefixes.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from collections.abc import Iterator
10
+
11
+ import httpx
12
+
13
+ from althing.llm.errors import LLMError, LLMErrorCategory, llm_error_from_response
14
+ from althing.llm.models import (
15
+ CompletionRequest,
16
+ CompletionResponse,
17
+ StreamEvent,
18
+ )
19
+ from althing.llm.providers._openai_format import (
20
+ build_openai_body,
21
+ parse_openai_response,
22
+ parse_openai_sse_stream,
23
+ )
24
+ from althing.llm.providers.base import LLMProvider, ProviderConfig
25
+
26
+ OPENAI_COMPAT_CONFIG = ProviderConfig(
27
+ api_key_env="OPENAI_API_KEY",
28
+ base_url_env="OPENAI_BASE_URL",
29
+ default_base_url="https://api.openai.com",
30
+ model_prefixes=(), # No prefix — this is the fallback
31
+ name="OpenAI",
32
+ supports_seed=True,
33
+ )
34
+
35
+
36
+ class OpenAICompatibleProvider(LLMProvider):
37
+ """Generic OpenAI-compatible chat completions provider."""
38
+
39
+ config = OPENAI_COMPAT_CONFIG
40
+
41
+ def __init__(
42
+ self,
43
+ *,
44
+ base_url: str | None = None,
45
+ api_key: str | None = None,
46
+ ) -> None:
47
+ self._api_key = api_key if api_key is not None else self.config.get_api_key()
48
+ self._base_url = base_url if base_url is not None else self.config.get_base_url()
49
+
50
+ def _headers(self) -> dict[str, str]:
51
+ return {
52
+ "Authorization": f"Bearer {self._api_key}",
53
+ "Content-Type": "application/json",
54
+ }
55
+
56
+ def send(self, request: CompletionRequest) -> CompletionResponse:
57
+ url = f"{self._base_url}/v1/chat/completions"
58
+ body = build_openai_body(request)
59
+ try:
60
+ resp = httpx.post(url, headers=self._headers(), json=body, timeout=120.0)
61
+ except httpx.HTTPError as exc:
62
+ raise LLMError(
63
+ f"Transport error: {exc}",
64
+ LLMErrorCategory.TRANSPORT,
65
+ cause=exc,
66
+ ) from exc
67
+
68
+ if resp.status_code != 200:
69
+ raise llm_error_from_response(resp, "OpenAI")
70
+
71
+ try:
72
+ data = resp.json()
73
+ except (json.JSONDecodeError, ValueError) as exc:
74
+ raise LLMError(
75
+ "Failed to parse OpenAI response",
76
+ LLMErrorCategory.DESERIALIZATION,
77
+ cause=exc,
78
+ ) from exc
79
+
80
+ return parse_openai_response(data, request.model)
81
+
82
+ def stream(self, request: CompletionRequest) -> Iterator[StreamEvent]:
83
+ url = f"{self._base_url}/v1/chat/completions"
84
+ body = build_openai_body(request, stream=True)
85
+ try:
86
+ with httpx.stream(
87
+ "POST",
88
+ url,
89
+ headers=self._headers(),
90
+ json=body,
91
+ timeout=120.0,
92
+ ) as resp:
93
+ if resp.status_code != 200:
94
+ resp.read()
95
+ raise llm_error_from_response(resp, "OpenAI")
96
+ yield from parse_openai_sse_stream(resp.iter_lines())
97
+ except httpx.HTTPError as exc:
98
+ raise LLMError(
99
+ f"Transport error during stream: {exc}",
100
+ LLMErrorCategory.TRANSPORT,
101
+ cause=exc,
102
+ ) from exc
@@ -0,0 +1,357 @@
1
+ """OpenRouter provider.
2
+
3
+ Routes requests to any model via OpenRouter's unified API. Most upstream
4
+ providers are reached via OR's OpenAI-compatible chat-completions endpoint.
5
+
6
+ For ``openrouter/anthropic/*`` traffic, the chat-completions path is
7
+ **unsafe** for multimodal: OR's downstream conversion from OpenAI
8
+ ``image_url`` blocks to Anthropic image blocks silently drops the image
9
+ during normalization (hq-m333). To keep multimodal blocks intact, we
10
+ route Anthropic-bound traffic through OR's Anthropic-native
11
+ ``/v1/messages`` passthrough with an Anthropic-shape body.
12
+
13
+ Requires OPENROUTER_API_KEY.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from collections.abc import Iterator
20
+ from typing import Any
21
+
22
+ import httpx
23
+
24
+ from althing.llm.errors import LLMError, LLMErrorCategory, llm_error_from_response
25
+ from althing.llm.models import (
26
+ CompletionRequest,
27
+ CompletionResponse,
28
+ StreamEvent,
29
+ )
30
+ from althing.llm.providers._anthropic_format import (
31
+ ANTHROPIC_API_VERSION,
32
+ build_anthropic_body,
33
+ parse_anthropic_response,
34
+ )
35
+ from althing.llm.providers._anthropic_format import (
36
+ parse_sse_stream as parse_anthropic_sse_stream,
37
+ )
38
+ from althing.llm.providers._openai_format import (
39
+ build_openai_body,
40
+ parse_openai_response,
41
+ parse_openai_sse_stream,
42
+ )
43
+ from althing.llm.providers.base import LLMProvider, ProviderConfig
44
+
45
+ OPENROUTER_CONFIG = ProviderConfig(
46
+ api_key_env="OPENROUTER_API_KEY",
47
+ base_url_env="OPENROUTER_BASE_URL",
48
+ default_base_url="https://openrouter.ai/api",
49
+ model_prefixes=("openrouter/",),
50
+ name="OpenRouter",
51
+ supports_seed=True,
52
+ )
53
+
54
+
55
+ def _openrouter_error_from_response(resp: httpx.Response) -> LLMError:
56
+ """Build an LLMError from an OpenRouter non-2xx response, surfacing
57
+ upstream provider details when present.
58
+
59
+ OpenRouter forwards typed errors from the downstream provider in the
60
+ body:
61
+
62
+ {"error": {"code": 429, "message": "Rate limit exceeded ...",
63
+ "type": "rate_limit_error",
64
+ "metadata": {"provider_name": "anthropic", ...}}}
65
+
66
+ When ``error.type`` and/or ``error.metadata.provider_name`` are
67
+ present, include them in the message so callers can tell which
68
+ downstream provider rejected the request and pick a recovery
69
+ strategy (wait vs. switch model). Falls back to the generic
70
+ ``llm_error_from_response`` message when the body is missing or
71
+ unparseable.
72
+ """
73
+ base = llm_error_from_response(resp, "OpenRouter")
74
+
75
+ try:
76
+ body = resp.json()
77
+ except (json.JSONDecodeError, ValueError):
78
+ return base
79
+ if not isinstance(body, dict):
80
+ return base
81
+
82
+ err = body.get("error")
83
+ if not isinstance(err, dict):
84
+ return base
85
+
86
+ upstream: str | None = None
87
+ metadata = err.get("metadata")
88
+ if isinstance(metadata, dict):
89
+ provider_name = metadata.get("provider_name")
90
+ if isinstance(provider_name, str) and provider_name:
91
+ upstream = provider_name
92
+
93
+ error_type = err.get("type") if isinstance(err.get("type"), str) else None
94
+ upstream_msg = err.get("message") if isinstance(err.get("message"), str) else None
95
+
96
+ if upstream is None and error_type is None and upstream_msg is None:
97
+ return base
98
+
99
+ label = f"OpenRouter (downstream: {upstream})" if upstream else "OpenRouter"
100
+ head = f"{label} API error {resp.status_code}"
101
+ if error_type:
102
+ head += f" [{error_type}]"
103
+ message = f"{head}: {upstream_msg}" if upstream_msg else head
104
+
105
+ return LLMError(
106
+ message,
107
+ base.category,
108
+ status_code=base.status_code,
109
+ retry_after=base.retry_after,
110
+ downstream_provider=upstream,
111
+ )
112
+
113
+
114
+ def _strip_or_prefix(model: str) -> str:
115
+ """Strip the ``openrouter/`` routing prefix so the API sees the
116
+ upstream model ID (e.g. ``anthropic/claude-sonnet-4.5``)."""
117
+ for prefix in OPENROUTER_CONFIG.model_prefixes:
118
+ if model.startswith(prefix):
119
+ return model[len(prefix) :]
120
+ return model
121
+
122
+
123
+ def _is_anthropic_routed(stripped_model: str) -> bool:
124
+ """Whether this model routes through Anthropic upstream on OpenRouter.
125
+
126
+ The ``openrouter/anthropic/*`` namespace is OR's convention for
127
+ Anthropic-upstream models. We branch off this prefix to use OR's
128
+ Anthropic Messages passthrough rather than the OpenAI-shape
129
+ chat-completions endpoint (hq-olrk).
130
+ """
131
+ return stripped_model.startswith("anthropic/")
132
+
133
+
134
+ def _rebind_model(request: CompletionRequest, new_model: str) -> CompletionRequest:
135
+ """Return a copy of ``request`` with ``model`` replaced.
136
+
137
+ Other call sites already do this inline; centralised here so both
138
+ transport paths (chat-completions and messages-passthrough) remain
139
+ consistent in which fields they preserve.
140
+ """
141
+ return CompletionRequest(
142
+ model=new_model,
143
+ max_tokens=request.max_tokens,
144
+ messages=request.messages,
145
+ system=request.system,
146
+ tools=request.tools,
147
+ tool_choice=request.tool_choice,
148
+ stream=request.stream,
149
+ temperature=request.temperature,
150
+ top_p=request.top_p,
151
+ seed=request.seed,
152
+ cache_enabled=request.cache_enabled,
153
+ provider_routing=request.provider_routing,
154
+ )
155
+
156
+
157
+ def _apply_provider_routing(body: dict[str, Any], request: CompletionRequest) -> None:
158
+ """Attach OpenRouter provider-routing preferences to the request body.
159
+
160
+ OpenRouter accepts a ``provider`` object (``ignore`` / ``order`` /
161
+ ``only`` / ...) on its endpoints. Used by the synthesis recovery
162
+ ladder to exclude a downstream provider that just rejected the call
163
+ (e.g. ``{"ignore": ["azure"]}``). No-op when unset.
164
+ """
165
+ if request.provider_routing:
166
+ body["provider"] = request.provider_routing
167
+
168
+
169
+ class OpenRouterProvider(LLMProvider):
170
+ """OpenRouter provider with dual transport.
171
+
172
+ * ``openrouter/anthropic/*`` → OR's Anthropic Messages passthrough
173
+ (``/v1/messages``) with Anthropic-shape body. Preserves multimodal
174
+ image / document blocks that the OpenAI-shape conversion drops.
175
+ * Everything else → OR's OpenAI-compatible chat-completions endpoint
176
+ (``/v1/chat/completions``).
177
+ """
178
+
179
+ config = OPENROUTER_CONFIG
180
+
181
+ def __init__(self) -> None:
182
+ self._api_key = self.config.get_api_key()
183
+ self._base_url = self.config.get_base_url()
184
+
185
+ def _headers(self, *, anthropic_passthrough: bool = False) -> dict[str, str]:
186
+ headers = {
187
+ "Authorization": f"Bearer {self._api_key}",
188
+ "Content-Type": "application/json",
189
+ }
190
+ if anthropic_passthrough:
191
+ # OR's /v1/messages passthrough emits Anthropic-native shape;
192
+ # send the same anthropic-version the direct provider sends so
193
+ # downstream features (cache_control, tool_use) line up.
194
+ headers["anthropic-version"] = ANTHROPIC_API_VERSION
195
+ return headers
196
+
197
+ @staticmethod
198
+ def _strip_prefix(model: str) -> str:
199
+ return _strip_or_prefix(model)
200
+
201
+ # ------------------------------------------------------------------
202
+ # send()
203
+ # ------------------------------------------------------------------
204
+
205
+ def send(self, request: CompletionRequest) -> CompletionResponse:
206
+ stripped = self._strip_prefix(request.model)
207
+ if request.model != stripped:
208
+ request = _rebind_model(request, stripped)
209
+
210
+ if _is_anthropic_routed(stripped):
211
+ return self._send_anthropic(request)
212
+ return self._send_openai(request)
213
+
214
+ def _send_anthropic(self, request: CompletionRequest) -> CompletionResponse:
215
+ url = f"{self._base_url}/v1/messages"
216
+ body = build_anthropic_body(request)
217
+ _apply_provider_routing(body, request)
218
+ try:
219
+ resp = httpx.post(
220
+ url,
221
+ headers=self._headers(anthropic_passthrough=True),
222
+ json=body,
223
+ timeout=120.0,
224
+ )
225
+ except httpx.HTTPError as exc:
226
+ raise LLMError(
227
+ f"Transport error: {exc}",
228
+ LLMErrorCategory.TRANSPORT,
229
+ cause=exc,
230
+ ) from exc
231
+
232
+ if resp.status_code != 200:
233
+ raise _openrouter_error_from_response(resp)
234
+
235
+ try:
236
+ data = resp.json()
237
+ except (json.JSONDecodeError, ValueError) as exc:
238
+ raise LLMError(
239
+ "Failed to parse OpenRouter Anthropic-passthrough response",
240
+ LLMErrorCategory.DESERIALIZATION,
241
+ cause=exc,
242
+ ) from exc
243
+
244
+ # Mirror _send_openai: pass the stripped upstream id as the
245
+ # fallback model. OR's response echoes the same form, so both
246
+ # transports produce a consistent ``response.model`` shape.
247
+ return parse_anthropic_response(data, request.model)
248
+
249
+ def _send_openai(self, request: CompletionRequest) -> CompletionResponse:
250
+ url = f"{self._base_url}/v1/chat/completions"
251
+ body = build_openai_body(request)
252
+ # Ask OpenRouter to always return the detailed usage block (including
253
+ # token counts and native cost). Without this flag, some upstream
254
+ # providers omit ``usage`` entirely, causing althing to compute
255
+ # $0 for the whole panel. See sp-2xy.
256
+ body["usage"] = {"include": True}
257
+ _apply_provider_routing(body, request)
258
+ try:
259
+ resp = httpx.post(url, headers=self._headers(), json=body, timeout=120.0)
260
+ except httpx.HTTPError as exc:
261
+ raise LLMError(
262
+ f"Transport error: {exc}",
263
+ LLMErrorCategory.TRANSPORT,
264
+ cause=exc,
265
+ ) from exc
266
+
267
+ if resp.status_code != 200:
268
+ raise _openrouter_error_from_response(resp)
269
+
270
+ try:
271
+ data = resp.json()
272
+ except (json.JSONDecodeError, ValueError) as exc:
273
+ raise LLMError(
274
+ "Failed to parse OpenRouter response",
275
+ LLMErrorCategory.DESERIALIZATION,
276
+ cause=exc,
277
+ ) from exc
278
+
279
+ return parse_openai_response(data, request.model)
280
+
281
+ # ------------------------------------------------------------------
282
+ # stream()
283
+ # ------------------------------------------------------------------
284
+
285
+ def stream(self, request: CompletionRequest) -> Iterator[StreamEvent]:
286
+ stripped = self._strip_prefix(request.model)
287
+ if request.model != stripped:
288
+ request = _rebind_model(request, stripped)
289
+
290
+ if _is_anthropic_routed(stripped):
291
+ yield from self._stream_anthropic(request)
292
+ else:
293
+ yield from self._stream_openai(request)
294
+
295
+ def _stream_anthropic(self, request: CompletionRequest) -> Iterator[StreamEvent]:
296
+ # Force stream=True; drop seed (Anthropic Messages API has no seed,
297
+ # mirroring the direct provider's behaviour in anthropic.py).
298
+ request = CompletionRequest(
299
+ model=request.model,
300
+ max_tokens=request.max_tokens,
301
+ messages=request.messages,
302
+ system=request.system,
303
+ tools=request.tools,
304
+ tool_choice=request.tool_choice,
305
+ stream=True,
306
+ temperature=request.temperature,
307
+ top_p=request.top_p,
308
+ cache_enabled=request.cache_enabled,
309
+ provider_routing=request.provider_routing,
310
+ )
311
+ url = f"{self._base_url}/v1/messages"
312
+ body = build_anthropic_body(request)
313
+ _apply_provider_routing(body, request)
314
+ try:
315
+ with httpx.stream(
316
+ "POST",
317
+ url,
318
+ headers=self._headers(anthropic_passthrough=True),
319
+ json=body,
320
+ timeout=120.0,
321
+ ) as resp:
322
+ if resp.status_code != 200:
323
+ resp.read()
324
+ raise _openrouter_error_from_response(resp)
325
+ yield from parse_anthropic_sse_stream(resp.iter_lines())
326
+ except httpx.HTTPError as exc:
327
+ raise LLMError(
328
+ f"Transport error during stream: {exc}",
329
+ LLMErrorCategory.TRANSPORT,
330
+ cause=exc,
331
+ ) from exc
332
+
333
+ def _stream_openai(self, request: CompletionRequest) -> Iterator[StreamEvent]:
334
+ url = f"{self._base_url}/v1/chat/completions"
335
+ body = build_openai_body(request, stream=True)
336
+ # Mirror send(): ensure OpenRouter emits usage details in the final
337
+ # stream chunk so althing can track cost accurately.
338
+ body["usage"] = {"include": True}
339
+ _apply_provider_routing(body, request)
340
+ try:
341
+ with httpx.stream(
342
+ "POST",
343
+ url,
344
+ headers=self._headers(),
345
+ json=body,
346
+ timeout=120.0,
347
+ ) as resp:
348
+ if resp.status_code != 200:
349
+ resp.read()
350
+ raise _openrouter_error_from_response(resp)
351
+ yield from parse_openai_sse_stream(resp.iter_lines())
352
+ except httpx.HTTPError as exc:
353
+ raise LLMError(
354
+ f"Transport error during stream: {exc}",
355
+ LLMErrorCategory.TRANSPORT,
356
+ cause=exc,
357
+ ) from exc
@@ -0,0 +1,98 @@
1
+ """xAI (Grok) provider implementation (SPEC.md §2).
2
+
3
+ xAI exposes an OpenAI-compatible chat completions endpoint, so this provider
4
+ translates our internal request/response models to the OpenAI chat format.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from collections.abc import Iterator
11
+
12
+ import httpx
13
+
14
+ from althing.llm.errors import LLMError, LLMErrorCategory, llm_error_from_response
15
+ from althing.llm.models import (
16
+ CompletionRequest,
17
+ CompletionResponse,
18
+ StreamEvent,
19
+ )
20
+ from althing.llm.providers._openai_format import (
21
+ build_openai_body,
22
+ parse_openai_response,
23
+ parse_openai_sse_stream,
24
+ )
25
+ from althing.llm.providers.base import LLMProvider, ProviderConfig
26
+
27
+ XAI_CONFIG = ProviderConfig(
28
+ api_key_env="XAI_API_KEY",
29
+ base_url_env="XAI_BASE_URL",
30
+ default_base_url="https://api.x.ai",
31
+ model_prefixes=("grok-",),
32
+ name="xAI",
33
+ supports_seed=True,
34
+ )
35
+
36
+
37
+ class XAIProvider(LLMProvider):
38
+ """xAI / Grok provider (OpenAI-compatible chat completions)."""
39
+
40
+ config = XAI_CONFIG
41
+
42
+ def __init__(self) -> None:
43
+ self._api_key = self.config.get_api_key()
44
+ self._base_url = self.config.get_base_url()
45
+
46
+ def _headers(self) -> dict[str, str]:
47
+ return {
48
+ "Authorization": f"Bearer {self._api_key}",
49
+ "Content-Type": "application/json",
50
+ }
51
+
52
+ def send(self, request: CompletionRequest) -> CompletionResponse:
53
+ url = f"{self._base_url}/v1/chat/completions"
54
+ body = build_openai_body(request)
55
+ try:
56
+ resp = httpx.post(url, headers=self._headers(), json=body, timeout=120.0)
57
+ except httpx.HTTPError as exc:
58
+ raise LLMError(
59
+ f"Transport error: {exc}",
60
+ LLMErrorCategory.TRANSPORT,
61
+ cause=exc,
62
+ ) from exc
63
+
64
+ if resp.status_code != 200:
65
+ raise llm_error_from_response(resp, "xAI")
66
+
67
+ try:
68
+ data = resp.json()
69
+ except (json.JSONDecodeError, ValueError) as exc:
70
+ raise LLMError(
71
+ "Failed to parse xAI response",
72
+ LLMErrorCategory.DESERIALIZATION,
73
+ cause=exc,
74
+ ) from exc
75
+
76
+ return parse_openai_response(data, request.model)
77
+
78
+ def stream(self, request: CompletionRequest) -> Iterator[StreamEvent]:
79
+ url = f"{self._base_url}/v1/chat/completions"
80
+ body = build_openai_body(request, stream=True)
81
+ try:
82
+ with httpx.stream(
83
+ "POST",
84
+ url,
85
+ headers=self._headers(),
86
+ json=body,
87
+ timeout=120.0,
88
+ ) as resp:
89
+ if resp.status_code != 200:
90
+ resp.read()
91
+ raise llm_error_from_response(resp, "xAI")
92
+ yield from parse_openai_sse_stream(resp.iter_lines())
93
+ except httpx.HTTPError as exc:
94
+ raise LLMError(
95
+ f"Transport error during stream: {exc}",
96
+ LLMErrorCategory.TRANSPORT,
97
+ cause=exc,
98
+ ) from exc