confamnode 0.1.3__tar.gz → 0.2.1__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.
Files changed (30) hide show
  1. {confamnode-0.1.3 → confamnode-0.2.1}/PKG-INFO +5 -12
  2. {confamnode-0.1.3 → confamnode-0.2.1}/README.md +3 -9
  3. {confamnode-0.1.3 → confamnode-0.2.1}/confamnode/__init__.py +1 -1
  4. {confamnode-0.1.3 → confamnode-0.2.1}/confamnode/ansa.py +22 -1
  5. confamnode-0.2.1/confamnode/builders.py +21 -0
  6. confamnode-0.2.1/confamnode/client.py +210 -0
  7. confamnode-0.2.1/confamnode/config.py +3 -0
  8. {confamnode-0.1.3 → confamnode-0.2.1}/pyproject.toml +3 -3
  9. {confamnode-0.1.3 → confamnode-0.2.1}/tests/test_ansa.py +0 -1
  10. confamnode-0.2.1/tests/test_client.py +80 -0
  11. confamnode-0.2.1/tests/test_gist.py +250 -0
  12. confamnode-0.2.1/tests/test_stream.py +159 -0
  13. confamnode-0.2.1/uv.lock +276 -0
  14. confamnode-0.1.3/LICENSE +0 -201
  15. confamnode-0.1.3/confamnode/client.py +0 -184
  16. confamnode-0.1.3/confamnode/prompts.py +0 -32
  17. confamnode-0.1.3/confamnode/utils.py +0 -29
  18. confamnode-0.1.3/tests/test_client.py +0 -56
  19. confamnode-0.1.3/tests/test_gist.py +0 -324
  20. confamnode-0.1.3/tests/test_stream.py +0 -170
  21. confamnode-0.1.3/uv.lock +0 -2324
  22. {confamnode-0.1.3 → confamnode-0.2.1}/.gitignore +0 -0
  23. {confamnode-0.1.3 → confamnode-0.2.1}/.python-version +0 -0
  24. {confamnode-0.1.3 → confamnode-0.2.1}/confamnode/exceptions.py +0 -0
  25. {confamnode-0.1.3 → confamnode-0.2.1}/confamnode/models.py +0 -0
  26. {confamnode-0.1.3 → confamnode-0.2.1}/confamnode/registry.py +0 -0
  27. {confamnode-0.1.3 → confamnode-0.2.1}/tests/__init__.py +0 -0
  28. {confamnode-0.1.3 → confamnode-0.2.1}/tests/test_exceptions.py +0 -0
  29. {confamnode-0.1.3 → confamnode-0.2.1}/tests/test_init.py +0 -0
  30. {confamnode-0.1.3 → confamnode-0.2.1}/tests/test_models.py +0 -0
@@ -1,12 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: confamnode
3
- Version: 0.1.3
3
+ Version: 0.2.1
4
4
  Summary: The Nigerian AI inference gateway
5
5
  Project-URL: Repository, https://github.com/confamnodeai/confamnode
6
6
  Project-URL: Bug Tracker, https://github.com/confamnodeai/confamnode/issues
7
7
  Author-email: JoTeq the First <hello@confamnode.com>
8
8
  License: Apache-2.0
9
- License-File: LICENSE
10
9
  Keywords: ai,confamnode,inference,joteq,llm,nigeria
11
10
  Classifier: Development Status :: 3 - Alpha
12
11
  Classifier: Intended Audience :: Developers
@@ -18,7 +17,7 @@ Classifier: Programming Language :: Python :: 3.12
18
17
  Classifier: Programming Language :: Python :: 3.13
19
18
  Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
19
  Requires-Python: >=3.10
21
- Requires-Dist: litellm>=1.87.1
20
+ Requires-Dist: httpx>=0.28.1
22
21
  Requires-Dist: python-dotenv>=1.2.2
23
22
  Description-Content-Type: text/markdown
24
23
 
@@ -124,8 +123,6 @@ ansa = stream.get_ansa()
124
123
  print(f"\nModel: {ansa.model}")
125
124
  print(f"Tokens: {ansa.usage.total_tokens}")
126
125
  print(f"Cost: ₦{ansa.cost.naira:.6f}")
127
- if ansa.cost.dollars:
128
- print(f"${ansa.cost.dollars:.8f}")
129
126
  print(f"ID: {ansa.id}")
130
127
  ```
131
128
 
@@ -155,7 +152,6 @@ ansa.usage.total_tokens # total tokens used
155
152
  ansa.cost.naira # total cost in Naira ← primary
156
153
  ansa.cost.naira_input # input cost in Naira
157
154
  ansa.cost.naira_output # output cost in Naira
158
- ansa.cost.dollars # cost in USD (if available)
159
155
 
160
156
  # Identity
161
157
  ansa.is_local # True — runs on Nigerian hardware
@@ -265,10 +261,8 @@ Enable extended thinking for complex problems:
265
261
  ansa = client.gist(
266
262
  model="confam-reasoning",
267
263
  messages="One trader buy goods for ₦50,000 sell am for ₦75,000. After e pay ₦5,000 for transport and ₦3,000 for market, wetin be the real profit? Show how you calculate am.",
268
- allowed_openai_params=["reasoning_effort"],
269
- reasoning_effort={"effort": "low", "summary": "detailed"}
270
- # effort: "low", "medium", "high", or "xhigh"
271
- # summary: "detailed" or "concise"
264
+ reasoning_effort="low"
265
+ # one of: "xhigh", "high", "medium", "low", "minimal", "none"
272
266
  )
273
267
 
274
268
  print(ansa.reasoning) # thinking trace
@@ -281,8 +275,7 @@ Also available on `confam-deep-reasoning` for more complex multi-step problems:
281
275
  ansa = client.gist(
282
276
  model="confam-deep-reasoning",
283
277
  messages="Analyse the financial risk of a Nigerian fintech expanding to Ghana...",
284
- allowed_openai_params=["reasoning_effort"],
285
- reasoning_effort={"effort": "high", "summary": "detailed"}
278
+ reasoning_effort="high"
286
279
  )
287
280
 
288
281
  print(ansa.reasoning) # full thinking trace
@@ -100,8 +100,6 @@ ansa = stream.get_ansa()
100
100
  print(f"\nModel: {ansa.model}")
101
101
  print(f"Tokens: {ansa.usage.total_tokens}")
102
102
  print(f"Cost: ₦{ansa.cost.naira:.6f}")
103
- if ansa.cost.dollars:
104
- print(f"${ansa.cost.dollars:.8f}")
105
103
  print(f"ID: {ansa.id}")
106
104
  ```
107
105
 
@@ -131,7 +129,6 @@ ansa.usage.total_tokens # total tokens used
131
129
  ansa.cost.naira # total cost in Naira ← primary
132
130
  ansa.cost.naira_input # input cost in Naira
133
131
  ansa.cost.naira_output # output cost in Naira
134
- ansa.cost.dollars # cost in USD (if available)
135
132
 
136
133
  # Identity
137
134
  ansa.is_local # True — runs on Nigerian hardware
@@ -241,10 +238,8 @@ Enable extended thinking for complex problems:
241
238
  ansa = client.gist(
242
239
  model="confam-reasoning",
243
240
  messages="One trader buy goods for ₦50,000 sell am for ₦75,000. After e pay ₦5,000 for transport and ₦3,000 for market, wetin be the real profit? Show how you calculate am.",
244
- allowed_openai_params=["reasoning_effort"],
245
- reasoning_effort={"effort": "low", "summary": "detailed"}
246
- # effort: "low", "medium", "high", or "xhigh"
247
- # summary: "detailed" or "concise"
241
+ reasoning_effort="low"
242
+ # one of: "xhigh", "high", "medium", "low", "minimal", "none"
248
243
  )
249
244
 
250
245
  print(ansa.reasoning) # thinking trace
@@ -257,8 +252,7 @@ Also available on `confam-deep-reasoning` for more complex multi-step problems:
257
252
  ansa = client.gist(
258
253
  model="confam-deep-reasoning",
259
254
  messages="Analyse the financial risk of a Nigerian fintech expanding to Ghana...",
260
- allowed_openai_params=["reasoning_effort"],
261
- reasoning_effort={"effort": "high", "summary": "detailed"}
255
+ reasoning_effort="high"
262
256
  )
263
257
 
264
258
  print(ansa.reasoning) # full thinking trace
@@ -8,7 +8,7 @@ from confamnode.exceptions import (
8
8
  from confamnode.ansa import Ansa, Usage, Cost
9
9
  from confamnode import models
10
10
 
11
- __version__ = "0.1.3"
11
+ __version__ = "0.2.1"
12
12
 
13
13
  __all__ = [
14
14
  "ConfamNode",
@@ -30,4 +30,25 @@ class Ansa:
30
30
  citations: list = field(default_factory=list)
31
31
  id: str = field(default_factory=lambda: f"confam-{uuid.uuid4()}")
32
32
  is_local: bool = False
33
- is_ngn_data_residency: bool = False
33
+ is_ngn_data_residency: bool = False
34
+
35
+
36
+ @dataclass
37
+ class StreamDelta:
38
+ role: str | None = None
39
+ content: str | None = None
40
+ reasoning: str | None = None
41
+
42
+
43
+ @dataclass
44
+ class StreamChoice:
45
+ index: int = 0
46
+ delta: StreamDelta = field(default_factory=StreamDelta)
47
+ finish_reason: str | None = None
48
+
49
+
50
+ @dataclass
51
+ class StreamChunk:
52
+ id: str = ""
53
+ model: str = ""
54
+ choices: list = field(default_factory=list)
@@ -0,0 +1,21 @@
1
+ from confamnode.ansa import StreamChunk, StreamChoice, StreamDelta
2
+
3
+
4
+ def parse_chunk(raw: dict) -> StreamChunk:
5
+ choices = []
6
+ for c in raw.get("choices", []):
7
+ d = c.get("delta", {})
8
+ choices.append(StreamChoice(
9
+ index=c.get("index", 0),
10
+ finish_reason=c.get("finish_reason"),
11
+ delta=StreamDelta(
12
+ role=d.get("role"),
13
+ content=d.get("content"),
14
+ reasoning=d.get("reasoning")
15
+ )
16
+ ))
17
+ return StreamChunk(
18
+ id=raw.get("id", ""),
19
+ model=raw.get("model", ""),
20
+ choices=choices,
21
+ )
@@ -0,0 +1,210 @@
1
+ import os
2
+ import json
3
+ import httpx
4
+
5
+ from typing import Union, List, Dict
6
+
7
+ from confamnode.builders import parse_chunk
8
+ from confamnode.ansa import Ansa, Usage, Cost
9
+ from confamnode.registry import VALID_MODELS
10
+ from confamnode.exceptions import ConfamAuthError, ConfamModelError
11
+ from confamnode.config import DEFAULT_BASE_URL, DEFAULT_TIMEOUT, DEFAULT_CONNECT_TIMEOUT
12
+
13
+
14
+ class ConfamNode:
15
+ def __init__(
16
+ self,
17
+ api_key: str = None,
18
+ base_url: str = None
19
+ ):
20
+ # Pick up from environment if not provided
21
+ api_key = api_key or os.environ.get("CONFAMNODE_API_KEY")
22
+
23
+ if not api_key:
24
+ raise ValueError("api_key is required")
25
+
26
+ if not api_key.startswith("confam-"):
27
+ raise ConfamAuthError()
28
+
29
+ self.api_key = api_key
30
+ self.base_url = base_url or DEFAULT_BASE_URL
31
+
32
+ def gist(
33
+ self,
34
+ model: str,
35
+ messages: Union[str, List[Dict[str, str]]],
36
+ system: str | None = "default",
37
+ **kwargs
38
+ ) -> "Ansa | ConfamStream":
39
+ if model not in VALID_MODELS:
40
+ raise ConfamModelError(model)
41
+
42
+ # Handle string messages
43
+ if isinstance(messages, str):
44
+ messages = [{"role": "user", "content": messages}]
45
+ elif not isinstance(messages, list):
46
+ raise ValueError("messages must be a string or list")
47
+
48
+ body = {
49
+ "model": model,
50
+ "messages": messages,
51
+ **kwargs
52
+ }
53
+
54
+ # System message tri-state
55
+ has_system_in_messages = any(m.get("role") == "system" for m in messages)
56
+ if not has_system_in_messages:
57
+ if system == "default":
58
+ pass
59
+ else:
60
+ body["system"] = system # None or custom string
61
+
62
+ if kwargs.get("stream", False):
63
+ http_client = httpx.Client(
64
+ timeout=httpx.Timeout(DEFAULT_TIMEOUT, connect=DEFAULT_CONNECT_TIMEOUT)
65
+ )
66
+ req = http_client.build_request(
67
+ "POST",
68
+ f"{self.base_url}/chat/completions",
69
+ headers={
70
+ "Authorization": f"Bearer {self.api_key}",
71
+ "Content-Type": "application/json",
72
+ },
73
+ json=body,
74
+ )
75
+ stream_response = http_client.send(req, stream=True)
76
+
77
+ if stream_response.status_code >= 400:
78
+ stream_response.read()
79
+ stream_response.close()
80
+ http_client.close()
81
+ error = stream_response.json().get("detail", "Requeest failed")
82
+ raise Exception(f"ConfamNode error {stream_response.status_code}: {error}")
83
+
84
+ return ConfamStream(stream_response, http_client, model)
85
+
86
+ response = httpx.post(
87
+ f"{self.base_url}/chat/completions",
88
+ headers={
89
+ "Authorization": f"Bearer {self.api_key}",
90
+ "Content-Type": "application/json",
91
+ },
92
+ json=body,
93
+ timeout=httpx.Timeout(DEFAULT_TIMEOUT, connect=DEFAULT_CONNECT_TIMEOUT)
94
+ )
95
+
96
+ if response.status_code >= 400:
97
+ error = response.json().get("detail", "Request failed")
98
+ raise Exception(f"ConfamNode error {response.status_code}: {error}")
99
+
100
+ data = response.json()
101
+ msg = data["choices"][0]["message"]
102
+ usage_data = data.get("usage", {})
103
+ confam = data.get("confam", {})
104
+ cost_data = confam.get("cost", {})
105
+
106
+ return Ansa(
107
+ id=confam.get("request_id", data.get("id", "")),
108
+ text=msg.get("content") or "",
109
+ model=model,
110
+ reasoning=msg.get("reasoning"),
111
+ tools=msg.get("tool_calls") or [],
112
+ citations=msg.get("citations") or [],
113
+ usage=Usage(
114
+ prompt_tokens=usage_data.get("prompt_tokens", 0),
115
+ completion_tokens=usage_data.get("completion_tokens", 0),
116
+ total_tokens=usage_data.get("total_tokens", 0),
117
+ ),
118
+ cost=Cost(
119
+ naira=cost_data.get("naira", 0.0),
120
+ naira_input=cost_data.get("naira_input", 0.0),
121
+ naira_output=cost_data.get("naira_output", 0.0),
122
+ ),
123
+ finish_reason=data["choices"][0].get("finish_reason", "stop"),
124
+ raw={
125
+ "id": data.get("id"),
126
+ "usage": {
127
+ "prompt_tokens": usage_data.get("prompt_tokens", 0),
128
+ "completion_tokens": usage_data.get("completion_tokens", 0)
129
+ }
130
+ },
131
+ is_local=confam.get("is_local", False),
132
+ is_ngn_data_residency=confam.get("is_ngn_data_residency", False),
133
+ )
134
+
135
+
136
+ class ConfamStream:
137
+ def __init__(self, stream_response, http_client, model: str):
138
+ self._stream_response = stream_response
139
+ self._http_client = http_client
140
+ self._model = model
141
+ self._chunks = []
142
+ self._ansa = None
143
+ self._confam_meta = {}
144
+
145
+ def __iter__(self):
146
+ try:
147
+ for line in self._stream_response.iter_lines():
148
+ if not line or not line.startswith("data: "):
149
+ continue
150
+ payload_str = line[len("data: "):]
151
+ if payload_str.strip() == "[DONE]":
152
+ break
153
+ try:
154
+ raw = json.loads(payload_str)
155
+ except json.JSONDecodeError:
156
+ continue
157
+
158
+ if "confam" in raw:
159
+ self._confam_meta = raw["confam"]
160
+ continue
161
+
162
+ if not raw.get("choices"):
163
+ continue
164
+
165
+ chunk = parse_chunk(raw)
166
+ self._chunks.append(chunk)
167
+ yield chunk
168
+ finally:
169
+ self._stream_response.close()
170
+ self._http_client.close()
171
+
172
+ self._ansa = self._build_ansa()
173
+
174
+ def get_ansa(self) -> Ansa:
175
+ if self._ansa is None:
176
+ raise RuntimeError("Stream not complete yet. Iterate through all chunks first.")
177
+ return self._ansa
178
+
179
+ def _build_ansa(self) -> Ansa:
180
+ # Collect text from all chunks
181
+ text = "".join([
182
+ c.choices[0].delta.content or ""
183
+ for c in self._chunks
184
+ if c.choices and c.choices[0].delta.content
185
+ ])
186
+
187
+ # Get finish reason from last chunk
188
+ if self._chunks and self._chunks[-1].choices:
189
+ finish_reason = self._chunks[-1].choices[0].finish_reason or "stop"
190
+
191
+ cost_data = self._confam_meta.get("cost", {})
192
+
193
+ return Ansa(
194
+ id=self._confam_meta.get("request_id", ""),
195
+ text=text,
196
+ model=self._model,
197
+ usage=Usage(prompt_tokens=0, completion_tokens=0, total_tokens=0),
198
+ cost=Cost(
199
+ naira=cost_data.get("naira", 0.0),
200
+ naira_input=cost_data.get("naira_input", 0.0),
201
+ naira_output=cost_data.get("naira_output", 0.0),
202
+ ),
203
+ finish_reason=finish_reason,
204
+ raw={
205
+ "id": self._confam_meta.get("request_id", ""),
206
+ "usage": {"prompt_tokens": 0, "completion_tokens": 0}
207
+ },
208
+ is_local=self._confam_meta.get("is_local", False),
209
+ is_ngn_data_residency=self._confam_meta.get("is_ngn_data_residency", False),
210
+ )
@@ -0,0 +1,3 @@
1
+ DEFAULT_BASE_URL = "https://api.confamnode.com/v1"
2
+ DEFAULT_TIMEOUT = 300.0
3
+ DEFAULT_CONNECT_TIMEOUT = 10.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "confamnode"
3
- version = "0.1.3"
3
+ version = "0.2.1"
4
4
  description = "The Nigerian AI inference gateway"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -21,7 +21,7 @@ classifiers = [
21
21
  "Topic :: Scientific/Engineering :: Artificial Intelligence",
22
22
  ]
23
23
  dependencies = [
24
- "litellm>=1.87.1",
24
+ "httpx>=0.28.1",
25
25
  "python-dotenv>=1.2.2",
26
26
  ]
27
27
 
@@ -43,4 +43,4 @@ anyio_mode = "auto"
43
43
 
44
44
  [build-system]
45
45
  requires = ["hatchling"]
46
- build-backend = "hatchling.build"
46
+ build-backend = "hatchling.build"
@@ -1,4 +1,3 @@
1
- import pytest
2
1
  from confamnode.ansa import Ansa, Usage, Cost
3
2
 
4
3
 
@@ -0,0 +1,80 @@
1
+ import os
2
+ import pytest
3
+ from unittest.mock import patch, MagicMock
4
+
5
+ from confamnode.client import ConfamNode
6
+ from confamnode.exceptions import ConfamAuthError
7
+
8
+
9
+ def test_client_accepts_api_key():
10
+ client = ConfamNode(api_key="confam-abc123")
11
+ assert client.api_key == "confam-abc123"
12
+
13
+
14
+ def test_client_raises_error_without_api_key():
15
+ with pytest.raises(ValueError, match="api_key is required"):
16
+ ConfamNode()
17
+
18
+
19
+ def test_client_raises_error_on_invalid_key_format():
20
+ with pytest.raises(ConfamAuthError, match="Invalid ConfamNode API key format"):
21
+ ConfamNode(api_key="sk-openai-abc123")
22
+
23
+
24
+ def test_client_key_starts_with_confam():
25
+ client = ConfamNode(api_key="confam-abc123")
26
+ assert client.api_key.startswith("confam-")
27
+
28
+
29
+ def test_client_does_not_have_litellm_key():
30
+ client = ConfamNode(api_key="confam-abc123")
31
+ assert not hasattr(client, "litellm_key")
32
+
33
+
34
+ def test_client_has_default_base_url():
35
+ client = ConfamNode(api_key="confam-abc123")
36
+ assert client.base_url == "https://api.confamnode.com/v1"
37
+
38
+
39
+ def test_client_accepts_custom_base_url():
40
+ client = ConfamNode(
41
+ api_key="confam-abc123",
42
+ base_url="http://192.168.1.100:8000/v1"
43
+ )
44
+ assert client.base_url == "http://192.168.1.100:8000/v1"
45
+
46
+
47
+ def test_client_picks_up_api_key_from_environment():
48
+ with patch.dict(os.environ, {"CONFAMNODE_API_KEY": "confam-abc123"}):
49
+ client = ConfamNode()
50
+ assert client.api_key == "confam-abc123"
51
+
52
+
53
+ def test_client_explicit_key_overrides_environment():
54
+ with patch.dict(os.environ, {"CONFAMNODE_API_KEY": "confam-env123"}):
55
+ client = ConfamNode(api_key="confam-explicit123")
56
+ assert client.api_key == "confam-explicit123"
57
+
58
+
59
+ def test_client_sends_confam_key_in_authorization_header():
60
+ mock_response = MagicMock()
61
+ mock_response.status_code = 200
62
+ mock_response.json.return_value = {
63
+ "id": "confam-xxx",
64
+ "object": "chat.completion",
65
+ "created": 1,
66
+ "model": "confam-speed",
67
+ "choices": [{"index": 0, "finish_reason": "stop",
68
+ "message": {"role": "assistant", "content": "hi",
69
+ "reasoning": None, "tool_calls": None, "citations": None}}],
70
+ "usage": {"prompt_tokens": 5, "completion_tokens": 5, "total_tokens": 10},
71
+ "confam": {"request_id": "confam-xxx",
72
+ "cost": {"naira": 0.0, "naira_input": 0.0, "naira_output": 0.0},
73
+ "is_local": False, "is_ngn_data_residency": False}
74
+ }
75
+
76
+ client = ConfamNode(api_key="confam-abc123")
77
+ with patch("confamnode.client.httpx.post", return_value=mock_response) as mock_post:
78
+ client.gist(model="confam-speed", messages="hi")
79
+ call_headers = mock_post.call_args.kwargs["headers"]
80
+ assert call_headers["Authorization"] == "Bearer confam-abc123"