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.
- {confamnode-0.1.3 → confamnode-0.2.1}/PKG-INFO +5 -12
- {confamnode-0.1.3 → confamnode-0.2.1}/README.md +3 -9
- {confamnode-0.1.3 → confamnode-0.2.1}/confamnode/__init__.py +1 -1
- {confamnode-0.1.3 → confamnode-0.2.1}/confamnode/ansa.py +22 -1
- confamnode-0.2.1/confamnode/builders.py +21 -0
- confamnode-0.2.1/confamnode/client.py +210 -0
- confamnode-0.2.1/confamnode/config.py +3 -0
- {confamnode-0.1.3 → confamnode-0.2.1}/pyproject.toml +3 -3
- {confamnode-0.1.3 → confamnode-0.2.1}/tests/test_ansa.py +0 -1
- confamnode-0.2.1/tests/test_client.py +80 -0
- confamnode-0.2.1/tests/test_gist.py +250 -0
- confamnode-0.2.1/tests/test_stream.py +159 -0
- confamnode-0.2.1/uv.lock +276 -0
- confamnode-0.1.3/LICENSE +0 -201
- confamnode-0.1.3/confamnode/client.py +0 -184
- confamnode-0.1.3/confamnode/prompts.py +0 -32
- confamnode-0.1.3/confamnode/utils.py +0 -29
- confamnode-0.1.3/tests/test_client.py +0 -56
- confamnode-0.1.3/tests/test_gist.py +0 -324
- confamnode-0.1.3/tests/test_stream.py +0 -170
- confamnode-0.1.3/uv.lock +0 -2324
- {confamnode-0.1.3 → confamnode-0.2.1}/.gitignore +0 -0
- {confamnode-0.1.3 → confamnode-0.2.1}/.python-version +0 -0
- {confamnode-0.1.3 → confamnode-0.2.1}/confamnode/exceptions.py +0 -0
- {confamnode-0.1.3 → confamnode-0.2.1}/confamnode/models.py +0 -0
- {confamnode-0.1.3 → confamnode-0.2.1}/confamnode/registry.py +0 -0
- {confamnode-0.1.3 → confamnode-0.2.1}/tests/__init__.py +0 -0
- {confamnode-0.1.3 → confamnode-0.2.1}/tests/test_exceptions.py +0 -0
- {confamnode-0.1.3 → confamnode-0.2.1}/tests/test_init.py +0 -0
- {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
|
+
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:
|
|
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
|
-
|
|
269
|
-
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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
|
-
|
|
261
|
-
reasoning_effort={"effort": "high", "summary": "detailed"}
|
|
255
|
+
reasoning_effort="high"
|
|
262
256
|
)
|
|
263
257
|
|
|
264
258
|
print(ansa.reasoning) # full thinking trace
|
|
@@ -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
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "confamnode"
|
|
3
|
-
version = "0.1
|
|
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
|
-
"
|
|
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"
|
|
@@ -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"
|