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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,4 @@
1
+ from nthlayer_common.llm import LLMError, LLMResponse, llm_call
2
+ from nthlayer_common.parsing import clamp, strip_markdown_fences
3
+
4
+ __all__ = ["llm_call", "LLMResponse", "LLMError", "strip_markdown_fences", "clamp"]
@@ -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,5 @@
1
+ httpx>=0.27
2
+
3
+ [dev]
4
+ pytest>=8.0
5
+ ruff>=0.8
@@ -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