nthlayer-common 0.1.0__tar.gz
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.
- nthlayer_common-0.1.0/PKG-INFO +10 -0
- nthlayer_common-0.1.0/pyproject.toml +25 -0
- nthlayer_common-0.1.0/setup.cfg +4 -0
- nthlayer_common-0.1.0/src/nthlayer_common/__init__.py +4 -0
- nthlayer_common-0.1.0/src/nthlayer_common/llm.py +234 -0
- nthlayer_common-0.1.0/src/nthlayer_common/parsing.py +26 -0
- nthlayer_common-0.1.0/src/nthlayer_common.egg-info/PKG-INFO +10 -0
- nthlayer_common-0.1.0/src/nthlayer_common.egg-info/SOURCES.txt +10 -0
- nthlayer_common-0.1.0/src/nthlayer_common.egg-info/dependency_links.txt +1 -0
- nthlayer_common-0.1.0/src/nthlayer_common.egg-info/requires.txt +5 -0
- nthlayer_common-0.1.0/src/nthlayer_common.egg-info/top_level.txt +1 -0
- nthlayer_common-0.1.0/tests/test_llm.py +183 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nthlayer-common
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared utilities for the NthLayer ecosystem
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
10
|
+
Requires-Dist: ruff>=0.8; extra == "dev"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nthlayer-common"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Shared utilities for the NthLayer ecosystem"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
license = {text = "Apache-2.0"}
|
|
11
|
+
dependencies = [
|
|
12
|
+
"httpx>=0.27",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
dev = [
|
|
17
|
+
"pytest>=8.0",
|
|
18
|
+
"ruff>=0.8",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.packages.find]
|
|
22
|
+
where = ["src"]
|
|
23
|
+
|
|
24
|
+
[tool.pytest.ini_options]
|
|
25
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unified LLM interface for NthLayer agentic components.
|
|
3
|
+
|
|
4
|
+
Two API formats cover the entire market:
|
|
5
|
+
- Anthropic Messages API (Anthropic only)
|
|
6
|
+
- OpenAI Chat Completions API (everyone else)
|
|
7
|
+
|
|
8
|
+
No third-party LLM libraries. No LiteLLM.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
from nthlayer_common.llm import llm_call
|
|
12
|
+
|
|
13
|
+
response = llm_call(
|
|
14
|
+
system="You are a triage agent...",
|
|
15
|
+
user="Evaluate this incident...",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
Configuration via environment:
|
|
19
|
+
NTHLAYER_MODEL - provider/model (default: anthropic/claude-sonnet-4-20250514)
|
|
20
|
+
NTHLAYER_LLM_TIMEOUT - seconds (default: 60)
|
|
21
|
+
ANTHROPIC_API_KEY - for anthropic/* models
|
|
22
|
+
OPENAI_API_KEY - for openai/*, together/*, groq/*, mistral/*, azure/* models
|
|
23
|
+
OPENAI_API_BASE - override endpoint URL for any provider
|
|
24
|
+
AZURE_OPENAI_ENDPOINT - Azure OpenAI resource URL
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import os
|
|
28
|
+
import json
|
|
29
|
+
import httpx
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
|
|
32
|
+
DEFAULT_MODEL = os.environ.get("NTHLAYER_MODEL", "anthropic/claude-sonnet-4-20250514")
|
|
33
|
+
try:
|
|
34
|
+
TIMEOUT = int(os.environ.get("NTHLAYER_LLM_TIMEOUT", "60"))
|
|
35
|
+
except (ValueError, TypeError):
|
|
36
|
+
TIMEOUT = 60
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class LLMResponse:
|
|
41
|
+
"""Response from an LLM call."""
|
|
42
|
+
text: str # The response content
|
|
43
|
+
model: str # Model that was used
|
|
44
|
+
provider: str # Provider that was used
|
|
45
|
+
input_tokens: int | None = None # Token count for input (if available)
|
|
46
|
+
output_tokens: int | None = None # Token count for output (if available)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class LLMError(Exception):
|
|
50
|
+
"""Raised when an LLM call fails."""
|
|
51
|
+
def __init__(self, message: str, provider: str, model: str, cause: Exception | None = None):
|
|
52
|
+
self.provider = provider
|
|
53
|
+
self.model = model
|
|
54
|
+
self.cause = cause
|
|
55
|
+
super().__init__(f"[{provider}/{model}] {message}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def llm_call(
|
|
59
|
+
system: str,
|
|
60
|
+
user: str,
|
|
61
|
+
model: str | None = None,
|
|
62
|
+
max_tokens: int = 2000,
|
|
63
|
+
timeout: int | None = None,
|
|
64
|
+
) -> LLMResponse:
|
|
65
|
+
"""
|
|
66
|
+
Unified LLM call for all NthLayer agentic components.
|
|
67
|
+
|
|
68
|
+
Model format: "provider/model-name"
|
|
69
|
+
- anthropic/claude-sonnet-4-20250514
|
|
70
|
+
- openai/gpt-4o
|
|
71
|
+
- ollama/llama3.1
|
|
72
|
+
- azure/my-deployment
|
|
73
|
+
- together/meta-llama/Llama-3-70b
|
|
74
|
+
- groq/llama-3.1-70b-versatile
|
|
75
|
+
- mistral/mistral-large-latest
|
|
76
|
+
- vllm/my-model
|
|
77
|
+
- lmstudio/my-model
|
|
78
|
+
- custom/my-model (with OPENAI_API_BASE set)
|
|
79
|
+
|
|
80
|
+
Provider determines the API format and endpoint:
|
|
81
|
+
- "anthropic/*" -> Anthropic Messages API
|
|
82
|
+
- Everything else -> OpenAI-compatible Chat Completions API
|
|
83
|
+
|
|
84
|
+
Returns LLMResponse with the text content, model, and provider.
|
|
85
|
+
Raises LLMError on failure with provider/model context.
|
|
86
|
+
|
|
87
|
+
Note: callers that wrap llm_call() in asyncio.wait_for(timeout=T) should
|
|
88
|
+
use the same timeout value. httpx fires the network timeout first; the
|
|
89
|
+
asyncio.wait_for is a safety net for thread scheduling delays.
|
|
90
|
+
"""
|
|
91
|
+
model = model or DEFAULT_MODEL
|
|
92
|
+
_timeout = timeout if timeout is not None else TIMEOUT
|
|
93
|
+
|
|
94
|
+
# Parse provider from model string
|
|
95
|
+
if "/" in model:
|
|
96
|
+
provider, _, model_name = model.partition("/")
|
|
97
|
+
else:
|
|
98
|
+
# Bare model name - guess provider from known prefixes
|
|
99
|
+
provider = _guess_provider(model)
|
|
100
|
+
model_name = model
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
if provider == "anthropic":
|
|
104
|
+
text, in_tok, out_tok = _call_anthropic(system, user, model_name, max_tokens, _timeout)
|
|
105
|
+
else:
|
|
106
|
+
text, in_tok, out_tok = _call_openai_compat(system, user, model_name, provider, max_tokens, _timeout)
|
|
107
|
+
|
|
108
|
+
return LLMResponse(
|
|
109
|
+
text=text, model=model_name, provider=provider,
|
|
110
|
+
input_tokens=in_tok, output_tokens=out_tok,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
except httpx.HTTPStatusError as e:
|
|
114
|
+
raise LLMError(
|
|
115
|
+
f"HTTP {e.response.status_code}: {e.response.text[:200]}",
|
|
116
|
+
provider, model_name, e,
|
|
117
|
+
) from e
|
|
118
|
+
except httpx.TimeoutException as e:
|
|
119
|
+
raise LLMError(
|
|
120
|
+
f"Timeout after {_timeout}s",
|
|
121
|
+
provider, model_name, e,
|
|
122
|
+
) from e
|
|
123
|
+
except Exception as e:
|
|
124
|
+
if isinstance(e, LLMError):
|
|
125
|
+
raise
|
|
126
|
+
raise LLMError(str(e), provider, model_name, e) from e
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _call_anthropic(system: str, user: str, model: str, max_tokens: int, timeout: int) -> tuple[str, int | None, int | None]:
|
|
130
|
+
"""Call Anthropic Messages API."""
|
|
131
|
+
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
|
132
|
+
if not api_key:
|
|
133
|
+
raise LLMError("ANTHROPIC_API_KEY not set", "anthropic", model)
|
|
134
|
+
|
|
135
|
+
response = httpx.post(
|
|
136
|
+
"https://api.anthropic.com/v1/messages",
|
|
137
|
+
headers={
|
|
138
|
+
"x-api-key": api_key,
|
|
139
|
+
"anthropic-version": "2023-06-01",
|
|
140
|
+
"content-type": "application/json",
|
|
141
|
+
},
|
|
142
|
+
json={
|
|
143
|
+
"model": model,
|
|
144
|
+
"max_tokens": max_tokens,
|
|
145
|
+
"system": system,
|
|
146
|
+
"messages": [{"role": "user", "content": user}],
|
|
147
|
+
},
|
|
148
|
+
timeout=timeout,
|
|
149
|
+
)
|
|
150
|
+
response.raise_for_status()
|
|
151
|
+
data = response.json()
|
|
152
|
+
content = data.get("content", [])
|
|
153
|
+
if not content:
|
|
154
|
+
raise LLMError("Model returned empty content", "anthropic", model)
|
|
155
|
+
text = content[0].get("text", "")
|
|
156
|
+
usage = data.get("usage", {})
|
|
157
|
+
return text, usage.get("input_tokens"), usage.get("output_tokens")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _call_openai_compat(
|
|
161
|
+
system: str, user: str, model: str, provider: str, max_tokens: int, timeout: int
|
|
162
|
+
) -> tuple[str, int | None, int | None]:
|
|
163
|
+
"""
|
|
164
|
+
Call OpenAI-compatible Chat Completions API.
|
|
165
|
+
|
|
166
|
+
Works with: OpenAI, Azure OpenAI, Ollama, vLLM, Together AI,
|
|
167
|
+
Groq, Mistral, LM Studio, any OpenAI-compatible server.
|
|
168
|
+
"""
|
|
169
|
+
base_url = os.environ.get("OPENAI_API_BASE") or _default_base_url(provider)
|
|
170
|
+
if not base_url and provider == "azure":
|
|
171
|
+
raise LLMError("AZURE_OPENAI_ENDPOINT not set", "azure", model)
|
|
172
|
+
api_key = os.environ.get("OPENAI_API_KEY", "not-needed") # Ollama/vLLM don't require keys
|
|
173
|
+
|
|
174
|
+
# Azure uses api-key header; everything else uses Bearer token
|
|
175
|
+
if provider == "azure":
|
|
176
|
+
headers = {
|
|
177
|
+
"api-key": api_key,
|
|
178
|
+
"content-type": "application/json",
|
|
179
|
+
}
|
|
180
|
+
url = f"{base_url}/{model}/chat/completions?api-version=2024-02-01"
|
|
181
|
+
else:
|
|
182
|
+
headers = {
|
|
183
|
+
"Authorization": f"Bearer {api_key}",
|
|
184
|
+
"content-type": "application/json",
|
|
185
|
+
}
|
|
186
|
+
url = f"{base_url}/chat/completions"
|
|
187
|
+
|
|
188
|
+
response = httpx.post(
|
|
189
|
+
url,
|
|
190
|
+
headers=headers,
|
|
191
|
+
json={
|
|
192
|
+
"model": model,
|
|
193
|
+
"max_tokens": max_tokens,
|
|
194
|
+
"messages": [
|
|
195
|
+
{"role": "system", "content": system},
|
|
196
|
+
{"role": "user", "content": user},
|
|
197
|
+
],
|
|
198
|
+
},
|
|
199
|
+
timeout=timeout,
|
|
200
|
+
)
|
|
201
|
+
response.raise_for_status()
|
|
202
|
+
data = response.json()
|
|
203
|
+
choices = data.get("choices", [])
|
|
204
|
+
if not choices:
|
|
205
|
+
raise LLMError("Model returned empty choices", provider, model)
|
|
206
|
+
text = (choices[0].get("message") or {}).get("content", "")
|
|
207
|
+
usage = data.get("usage", {})
|
|
208
|
+
return text, usage.get("prompt_tokens"), usage.get("completion_tokens")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _default_base_url(provider: str) -> str:
|
|
212
|
+
"""Default API base URLs by provider."""
|
|
213
|
+
defaults = {
|
|
214
|
+
"openai": "https://api.openai.com/v1",
|
|
215
|
+
"ollama": "http://localhost:11434/v1",
|
|
216
|
+
"vllm": "http://localhost:8000/v1",
|
|
217
|
+
"lmstudio": "http://localhost:1234/v1",
|
|
218
|
+
"together": "https://api.together.xyz/v1",
|
|
219
|
+
"groq": "https://api.groq.com/openai/v1",
|
|
220
|
+
"mistral": "https://api.mistral.ai/v1",
|
|
221
|
+
"azure": os.environ.get("AZURE_OPENAI_ENDPOINT", ""),
|
|
222
|
+
}
|
|
223
|
+
return defaults.get(provider, "https://api.openai.com/v1")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _guess_provider(model: str) -> str:
|
|
227
|
+
"""Guess provider from bare model name."""
|
|
228
|
+
if model.startswith("claude"):
|
|
229
|
+
return "anthropic"
|
|
230
|
+
if model.startswith("gpt") or model.startswith("o1") or model.startswith("o3"):
|
|
231
|
+
return "openai"
|
|
232
|
+
if model.startswith("llama") or model.startswith("mistral") or model.startswith("gemma"):
|
|
233
|
+
return "ollama"
|
|
234
|
+
return "openai" # Default: assume OpenAI-compatible
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Shared parsing utilities for LLM responses.
|
|
2
|
+
|
|
3
|
+
Used by every component that calls llm_call() and parses the response.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def strip_markdown_fences(text: str) -> str:
|
|
9
|
+
"""Strip markdown code fences from model response text.
|
|
10
|
+
|
|
11
|
+
Handles ```json, ```, and bare ``` patterns.
|
|
12
|
+
"""
|
|
13
|
+
text = text.strip()
|
|
14
|
+
if text.startswith("```"):
|
|
15
|
+
lines = text.split("\n")
|
|
16
|
+
if lines[0].strip().startswith("```"):
|
|
17
|
+
lines = lines[1:]
|
|
18
|
+
if lines and lines[-1].strip().startswith("```"):
|
|
19
|
+
lines = lines[:-1]
|
|
20
|
+
text = "\n".join(lines)
|
|
21
|
+
return text
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def clamp(value: float, low: float = 0.0, high: float = 1.0) -> float:
|
|
25
|
+
"""Clamp a value to [low, high]. Default: [0.0, 1.0]."""
|
|
26
|
+
return max(low, min(high, value))
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nthlayer-common
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared utilities for the NthLayer ecosystem
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: httpx>=0.27
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
10
|
+
Requires-Dist: ruff>=0.8; extra == "dev"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
src/nthlayer_common/__init__.py
|
|
3
|
+
src/nthlayer_common/llm.py
|
|
4
|
+
src/nthlayer_common/parsing.py
|
|
5
|
+
src/nthlayer_common.egg-info/PKG-INFO
|
|
6
|
+
src/nthlayer_common.egg-info/SOURCES.txt
|
|
7
|
+
src/nthlayer_common.egg-info/dependency_links.txt
|
|
8
|
+
src/nthlayer_common.egg-info/requires.txt
|
|
9
|
+
src/nthlayer_common.egg-info/top_level.txt
|
|
10
|
+
tests/test_llm.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nthlayer_common
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# tests/test_llm.py
|
|
2
|
+
"""Unit tests for the unified LLM wrapper."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from unittest.mock import MagicMock, patch
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from nthlayer_common.llm import (
|
|
12
|
+
LLMError,
|
|
13
|
+
LLMResponse,
|
|
14
|
+
_guess_provider,
|
|
15
|
+
llm_call,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _mock_response(body: dict, status_code: int = 200) -> httpx.Response:
|
|
20
|
+
"""Build a mock httpx.Response."""
|
|
21
|
+
resp = httpx.Response(
|
|
22
|
+
status_code=status_code,
|
|
23
|
+
json=body,
|
|
24
|
+
request=httpx.Request("POST", "https://mock"),
|
|
25
|
+
)
|
|
26
|
+
return resp
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TestAnthropicPath:
|
|
30
|
+
def test_successful_call(self, monkeypatch):
|
|
31
|
+
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
|
|
32
|
+
mock_resp = _mock_response({
|
|
33
|
+
"content": [{"text": "hello from claude"}],
|
|
34
|
+
"usage": {"input_tokens": 100, "output_tokens": 50},
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
with patch("nthlayer_common.llm.httpx.post", return_value=mock_resp) as mock_post:
|
|
38
|
+
result = llm_call("system", "user", model="anthropic/claude-sonnet-4-20250514")
|
|
39
|
+
|
|
40
|
+
assert result.text == "hello from claude"
|
|
41
|
+
assert result.provider == "anthropic"
|
|
42
|
+
assert result.model == "claude-sonnet-4-20250514"
|
|
43
|
+
|
|
44
|
+
call_args = mock_post.call_args
|
|
45
|
+
assert "api.anthropic.com/v1/messages" in call_args.args[0]
|
|
46
|
+
assert call_args.kwargs["headers"]["x-api-key"] == "sk-ant-test"
|
|
47
|
+
assert call_args.kwargs["headers"]["anthropic-version"] == "2023-06-01"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TestOpenAIPath:
|
|
51
|
+
def test_successful_call(self, monkeypatch):
|
|
52
|
+
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
|
53
|
+
mock_resp = _mock_response({
|
|
54
|
+
"choices": [{"message": {"content": "hello from gpt"}}],
|
|
55
|
+
"usage": {"prompt_tokens": 80, "completion_tokens": 40},
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
with patch("nthlayer_common.llm.httpx.post", return_value=mock_resp) as mock_post:
|
|
59
|
+
result = llm_call("system", "user", model="openai/gpt-4o")
|
|
60
|
+
|
|
61
|
+
assert result.text == "hello from gpt"
|
|
62
|
+
assert result.provider == "openai"
|
|
63
|
+
assert result.model == "gpt-4o"
|
|
64
|
+
|
|
65
|
+
call_args = mock_post.call_args
|
|
66
|
+
assert "api.openai.com/v1/chat/completions" in call_args.args[0]
|
|
67
|
+
assert "Bearer sk-test" in call_args.kwargs["headers"]["Authorization"]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TestOllamaPath:
|
|
71
|
+
def test_correct_url(self, monkeypatch):
|
|
72
|
+
monkeypatch.delenv("OPENAI_API_BASE", raising=False)
|
|
73
|
+
mock_resp = _mock_response({"choices": [{"message": {"content": "local response"}}]})
|
|
74
|
+
|
|
75
|
+
with patch("nthlayer_common.llm.httpx.post", return_value=mock_resp) as mock_post:
|
|
76
|
+
result = llm_call("system", "user", model="ollama/llama3.1")
|
|
77
|
+
|
|
78
|
+
call_args = mock_post.call_args
|
|
79
|
+
assert "localhost:11434/v1/chat/completions" in call_args.args[0]
|
|
80
|
+
assert result.provider == "ollama"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class TestCustomBaseURL:
|
|
84
|
+
def test_openai_api_base_override(self, monkeypatch):
|
|
85
|
+
monkeypatch.setenv("OPENAI_API_BASE", "http://custom:1234/v1")
|
|
86
|
+
mock_resp = _mock_response({"choices": [{"message": {"content": "custom"}}]})
|
|
87
|
+
|
|
88
|
+
with patch("nthlayer_common.llm.httpx.post", return_value=mock_resp) as mock_post:
|
|
89
|
+
result = llm_call("system", "user", model="custom/my-model")
|
|
90
|
+
|
|
91
|
+
call_args = mock_post.call_args
|
|
92
|
+
assert "http://custom:1234/v1/chat/completions" == call_args.args[0]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class TestMissingAPIKey:
|
|
96
|
+
def test_anthropic_no_key_raises(self, monkeypatch):
|
|
97
|
+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
|
98
|
+
|
|
99
|
+
with pytest.raises(LLMError, match="ANTHROPIC_API_KEY not set"):
|
|
100
|
+
llm_call("system", "user", model="anthropic/claude-sonnet-4-20250514")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class TestTimeout:
|
|
104
|
+
def test_timeout_raises_llm_error(self, monkeypatch):
|
|
105
|
+
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
|
|
106
|
+
|
|
107
|
+
with patch("nthlayer_common.llm.httpx.post", side_effect=httpx.TimeoutException("timed out")):
|
|
108
|
+
with pytest.raises(LLMError, match="Timeout"):
|
|
109
|
+
llm_call("system", "user", model="anthropic/claude-sonnet-4-20250514", timeout=5)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class TestHTTPError:
|
|
113
|
+
def test_429_raises_llm_error(self, monkeypatch):
|
|
114
|
+
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
|
115
|
+
resp = httpx.Response(
|
|
116
|
+
status_code=429,
|
|
117
|
+
text="Rate limit exceeded",
|
|
118
|
+
request=httpx.Request("POST", "https://api.openai.com/v1/chat/completions"),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
with patch("nthlayer_common.llm.httpx.post", return_value=resp):
|
|
122
|
+
with pytest.raises(LLMError, match="HTTP 429"):
|
|
123
|
+
llm_call("system", "user", model="openai/gpt-4o")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class TestGuessProvider:
|
|
127
|
+
def test_claude_is_anthropic(self):
|
|
128
|
+
assert _guess_provider("claude-sonnet-4-20250514") == "anthropic"
|
|
129
|
+
|
|
130
|
+
def test_gpt_is_openai(self):
|
|
131
|
+
assert _guess_provider("gpt-4o") == "openai"
|
|
132
|
+
|
|
133
|
+
def test_o_series_is_openai(self):
|
|
134
|
+
assert _guess_provider("o1-preview") == "openai"
|
|
135
|
+
assert _guess_provider("o3-mini") == "openai"
|
|
136
|
+
|
|
137
|
+
def test_llama_is_ollama(self):
|
|
138
|
+
assert _guess_provider("llama3.1") == "ollama"
|
|
139
|
+
|
|
140
|
+
def test_unknown_defaults_to_openai(self):
|
|
141
|
+
assert _guess_provider("some-unknown-model") == "openai"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class TestLLMResponseFields:
|
|
145
|
+
def test_all_fields_populated(self, monkeypatch):
|
|
146
|
+
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
|
147
|
+
mock_resp = _mock_response({
|
|
148
|
+
"choices": [{"message": {"content": "test output"}}],
|
|
149
|
+
"usage": {"prompt_tokens": 80, "completion_tokens": 40},
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
with patch("nthlayer_common.llm.httpx.post", return_value=mock_resp):
|
|
153
|
+
result = llm_call("system", "user", model="openai/gpt-4o")
|
|
154
|
+
|
|
155
|
+
assert isinstance(result, LLMResponse)
|
|
156
|
+
assert result.text == "test output"
|
|
157
|
+
assert result.model == "gpt-4o"
|
|
158
|
+
assert result.provider == "openai"
|
|
159
|
+
assert result.input_tokens == 80
|
|
160
|
+
assert result.output_tokens == 40
|
|
161
|
+
|
|
162
|
+
def test_token_counts_from_anthropic(self, monkeypatch):
|
|
163
|
+
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
|
|
164
|
+
mock_resp = _mock_response({
|
|
165
|
+
"content": [{"text": "hello"}],
|
|
166
|
+
"usage": {"input_tokens": 150, "output_tokens": 75},
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
with patch("nthlayer_common.llm.httpx.post", return_value=mock_resp):
|
|
170
|
+
result = llm_call("system", "user", model="anthropic/claude-sonnet-4-20250514")
|
|
171
|
+
|
|
172
|
+
assert result.input_tokens == 150
|
|
173
|
+
assert result.output_tokens == 75
|
|
174
|
+
|
|
175
|
+
def test_missing_usage_returns_none(self, monkeypatch):
|
|
176
|
+
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
|
177
|
+
mock_resp = _mock_response({"choices": [{"message": {"content": "no usage"}}]})
|
|
178
|
+
|
|
179
|
+
with patch("nthlayer_common.llm.httpx.post", return_value=mock_resp):
|
|
180
|
+
result = llm_call("system", "user", model="openai/gpt-4o")
|
|
181
|
+
|
|
182
|
+
assert result.input_tokens is None
|
|
183
|
+
assert result.output_tokens is None
|