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.
- althing/__init__.py +46 -0
- althing/__main__.py +9 -0
- althing/__version__.py +1 -0
- althing/_compat_env.py +27 -0
- althing/_runners.py +953 -0
- althing/agent_assets/__init__.py +3 -0
- althing/agent_assets/commands/althing-poll.md +75 -0
- althing/agent_assets/skills/concept-test/SKILL.md +72 -0
- althing/agent_assets/skills/focus-group/SKILL.md +77 -0
- althing/agent_assets/skills/name-test/SKILL.md +62 -0
- althing/agent_assets/skills/pricing-probe/SKILL.md +75 -0
- althing/agent_assets/skills/survey-prescreen/SKILL.md +76 -0
- althing/aggregation.py +240 -0
- althing/analysis/__init__.py +46 -0
- althing/analysis/distribution.py +323 -0
- althing/analysis/inspect.py +586 -0
- althing/analysis/subgroup.py +168 -0
- althing/analysis/subgroup_cli.py +598 -0
- althing/analyze.py +839 -0
- althing/attachments/__init__.py +77 -0
- althing/attachments/filter.py +67 -0
- althing/attachments/models.py +50 -0
- althing/attachments/pdf.py +537 -0
- althing/attachments/store.py +111 -0
- althing/calibration.py +239 -0
- althing/checkpoint.py +763 -0
- althing/cli/__init__.py +1 -0
- althing/cli/commands.py +8080 -0
- althing/cli/mcp_install.py +487 -0
- althing/cli/output.py +106 -0
- althing/cli/parser.py +1706 -0
- althing/cli/progress.py +93 -0
- althing/cli/repl.py +122 -0
- althing/cli/slash.py +197 -0
- althing/conditions.py +261 -0
- althing/convergence.py +1091 -0
- althing/cost.py +950 -0
- althing/cost_summary.py +452 -0
- althing/credentials.py +311 -0
- althing/data/__init__.py +8 -0
- althing/data/synthbench-snapshot.json +120 -0
- althing/diff.py +331 -0
- althing/domains.py +284 -0
- althing/ensemble.py +922 -0
- althing/fetch/__init__.py +38 -0
- althing/fetch/cache.py +360 -0
- althing/fetch/ladder.py +437 -0
- althing/fetch/lower.py +199 -0
- althing/fetch/perimeter.py +494 -0
- althing/instrument.py +751 -0
- althing/integrations/__init__.py +21 -0
- althing/integrations/composio.py +345 -0
- althing/llm/__init__.py +32 -0
- althing/llm/aliases.py +138 -0
- althing/llm/capabilities.py +86 -0
- althing/llm/client.py +267 -0
- althing/llm/errors.py +162 -0
- althing/llm/fast_default.py +71 -0
- althing/llm/models.py +340 -0
- althing/llm/providers/__init__.py +18 -0
- althing/llm/providers/_anthropic_format.py +281 -0
- althing/llm/providers/_openai_format.py +381 -0
- althing/llm/providers/anthropic.py +172 -0
- althing/llm/providers/base.py +60 -0
- althing/llm/providers/gemini.py +107 -0
- althing/llm/providers/openai_compat.py +102 -0
- althing/llm/providers/openrouter.py +357 -0
- althing/llm/providers/xai.py +98 -0
- althing/llm/retry.py +113 -0
- althing/logging_config.py +80 -0
- althing/main.py +308 -0
- althing/mcp/__init__.py +7 -0
- althing/mcp/compat.py +110 -0
- althing/mcp/data.py +700 -0
- althing/mcp/sampling.py +380 -0
- althing/mcp/server.py +3371 -0
- althing/metadata.py +214 -0
- althing/metadata_migrations.py +70 -0
- althing/orchestrator.py +2119 -0
- althing/pack_diff.py +271 -0
- althing/packs/__init__.py +3 -0
- althing/packs/ai-eval-buyers.yaml +247 -0
- althing/packs/broad-professionals.yaml +251 -0
- althing/packs/developer.yaml +271 -0
- althing/packs/enterprise-ai-buyers.yaml +245 -0
- althing/packs/enterprise-buyer.yaml +252 -0
- althing/packs/general-consumer.yaml +271 -0
- althing/packs/healthcare-patient.yaml +271 -0
- althing/packs/instruments/__init__.py +1 -0
- althing/packs/instruments/churn-diagnosis.yaml +73 -0
- althing/packs/instruments/feature-prioritization.yaml +75 -0
- althing/packs/instruments/general-survey.yaml +46 -0
- althing/packs/instruments/happiness-probe.yaml +41 -0
- althing/packs/instruments/landing-page-comprehension.yaml +75 -0
- althing/packs/instruments/market-research.yaml +50 -0
- althing/packs/instruments/name-test.yaml +74 -0
- althing/packs/instruments/pricing-discovery.yaml +80 -0
- althing/packs/instruments/product-feedback.yaml +46 -0
- althing/packs/job-seekers.yaml +188 -0
- althing/packs/market-research-critics.yaml +217 -0
- althing/packs/product-research.yaml +248 -0
- althing/packs/recruiters-talent.yaml +264 -0
- althing/packs/skeptical-executives.yaml +227 -0
- althing/packs/startup-founder.yaml +242 -0
- althing/packs/students.yaml +352 -0
- althing/persistence.py +510 -0
- althing/perturbation.py +333 -0
- althing/plugins/__init__.py +34 -0
- althing/plugins/hooks.py +159 -0
- althing/plugins/lint.py +352 -0
- althing/plugins/manager.py +176 -0
- althing/plugins/manifest.py +100 -0
- althing/plugins/registry.py +66 -0
- althing/poll_summary.py +1195 -0
- althing/preflight.py +155 -0
- althing/profiles.py +264 -0
- althing/prompts.py +311 -0
- althing/py.typed +0 -0
- althing/question_budget.py +140 -0
- althing/registry/__init__.py +178 -0
- althing/registry/cache.py +223 -0
- althing/registry/fetch.py +104 -0
- althing/registry/github.py +212 -0
- althing/reporting/__init__.py +17 -0
- althing/reporting/loader.py +69 -0
- althing/reporting/markdown.py +450 -0
- althing/response_coercion.py +161 -0
- althing/routing.py +132 -0
- althing/runtime.py +552 -0
- althing/schemas/__init__.py +53 -0
- althing/schemas/v1.0.0.json +161 -0
- althing/sdk.py +1458 -0
- althing/stats.py +1522 -0
- althing/structured/__init__.py +5 -0
- althing/structured/models.py +149 -0
- althing/structured/output.py +376 -0
- althing/structured/retry.py +170 -0
- althing/structured/schemas.py +127 -0
- althing/structured/validate.py +303 -0
- althing/structured/verdict.py +146 -0
- althing/synthbench.py +768 -0
- althing/synthbench_submit.py +582 -0
- althing/synthesis.py +1529 -0
- althing/synthesis_recovery.py +467 -0
- althing/templates.py +179 -0
- althing/text_width.py +126 -0
- althing-1.7.0.dist-info/METADATA +1473 -0
- althing-1.7.0.dist-info/RECORD +156 -0
- althing-1.7.0.dist-info/WHEEL +5 -0
- althing-1.7.0.dist-info/entry_points.txt +3 -0
- althing-1.7.0.dist-info/licenses/LICENSE +21 -0
- althing-1.7.0.dist-info/top_level.txt +3 -0
- synth_panel/__init__.py +60 -0
- synth_panel/__main__.py +17 -0
- synthpanel/__init__.py +60 -0
- 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
|