confamnode 0.1.3__tar.gz → 0.2.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.
- {confamnode-0.1.3 → confamnode-0.2.0}/PKG-INFO +2 -2
- {confamnode-0.1.3 → confamnode-0.2.0}/confamnode/__init__.py +1 -1
- {confamnode-0.1.3 → confamnode-0.2.0}/confamnode/ansa.py +22 -1
- confamnode-0.2.0/confamnode/builders.py +21 -0
- confamnode-0.2.0/confamnode/client.py +210 -0
- confamnode-0.2.0/confamnode/config.py +3 -0
- {confamnode-0.1.3 → confamnode-0.2.0}/pyproject.toml +3 -3
- {confamnode-0.1.3 → confamnode-0.2.0}/tests/test_ansa.py +0 -1
- confamnode-0.2.0/tests/test_client.py +80 -0
- confamnode-0.2.0/tests/test_gist.py +250 -0
- confamnode-0.2.0/tests/test_stream.py +159 -0
- confamnode-0.2.0/uv.lock +276 -0
- 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.0}/.gitignore +0 -0
- {confamnode-0.1.3 → confamnode-0.2.0}/.python-version +0 -0
- {confamnode-0.1.3 → confamnode-0.2.0}/LICENSE +0 -0
- {confamnode-0.1.3 → confamnode-0.2.0}/README.md +0 -0
- {confamnode-0.1.3 → confamnode-0.2.0}/confamnode/exceptions.py +0 -0
- {confamnode-0.1.3 → confamnode-0.2.0}/confamnode/models.py +0 -0
- {confamnode-0.1.3 → confamnode-0.2.0}/confamnode/registry.py +0 -0
- {confamnode-0.1.3 → confamnode-0.2.0}/tests/__init__.py +0 -0
- {confamnode-0.1.3 → confamnode-0.2.0}/tests/test_exceptions.py +0 -0
- {confamnode-0.1.3 → confamnode-0.2.0}/tests/test_init.py +0 -0
- {confamnode-0.1.3 → confamnode-0.2.0}/tests/test_models.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: confamnode
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
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
|
|
@@ -18,7 +18,7 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.13
|
|
19
19
|
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
20
20
|
Requires-Python: >=3.10
|
|
21
|
-
Requires-Dist:
|
|
21
|
+
Requires-Dist: httpx>=0.28.1
|
|
22
22
|
Requires-Dist: python-dotenv>=1.2.2
|
|
23
23
|
Description-Content-Type: text/markdown
|
|
24
24
|
|
|
@@ -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.
|
|
3
|
+
version = "0.2.0"
|
|
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"
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from unittest.mock import patch, MagicMock
|
|
3
|
+
|
|
4
|
+
from confamnode import models
|
|
5
|
+
from confamnode.client import ConfamNode
|
|
6
|
+
from confamnode.ansa import Ansa, Usage, Cost
|
|
7
|
+
from confamnode.exceptions import ConfamModelError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture
|
|
12
|
+
def client():
|
|
13
|
+
return ConfamNode(api_key="confam-sk-test-abc123")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture
|
|
17
|
+
def mock_proxy_response():
|
|
18
|
+
mock = MagicMock()
|
|
19
|
+
mock.status_code = 200
|
|
20
|
+
mock.json.return_value = {
|
|
21
|
+
"id": "confam-test-id",
|
|
22
|
+
"object": "chat.completion",
|
|
23
|
+
"created": 1234567890,
|
|
24
|
+
"model": "confam-speed",
|
|
25
|
+
"choices": [{
|
|
26
|
+
"index": 0,
|
|
27
|
+
"finish_reason": "stop",
|
|
28
|
+
"message": {
|
|
29
|
+
"role": "assistant",
|
|
30
|
+
"content": "How far! I dey fine.",
|
|
31
|
+
"reasoning": None,
|
|
32
|
+
"tool_calls": None,
|
|
33
|
+
"citations": None
|
|
34
|
+
}
|
|
35
|
+
}],
|
|
36
|
+
"usage": {
|
|
37
|
+
"prompt_tokens": 10,
|
|
38
|
+
"completion_tokens": 20,
|
|
39
|
+
"total_tokens": 30
|
|
40
|
+
},
|
|
41
|
+
"confam": {
|
|
42
|
+
"request_id": "confam-test-id",
|
|
43
|
+
"cost": {"naira": 0.0, "naira_input": 0.0, "naira_output": 0.0},
|
|
44
|
+
"is_local": False,
|
|
45
|
+
"is_ngn_data_residency": False
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return mock
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_gist_requires_model(client):
|
|
52
|
+
with pytest.raises(TypeError):
|
|
53
|
+
client.gist(messages=[{"role": "user", "content": "How far?"}])
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_gist_requires_messages(client):
|
|
57
|
+
with pytest.raises(TypeError):
|
|
58
|
+
client.gist(model=models.SPEED)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_gist_rejects_invalid_model(client):
|
|
62
|
+
with pytest.raises(ConfamModelError, match="Invalid model name"):
|
|
63
|
+
client.gist(model="gpt-4o", messages="How far?")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_gist_returns_ansa_object(client, mock_proxy_response):
|
|
67
|
+
with patch("confamnode.client.httpx.post", return_value=mock_proxy_response):
|
|
68
|
+
ansa = client.gist(model=models.SPEED, messages="How far?")
|
|
69
|
+
assert isinstance(ansa, Ansa)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_gist_returns_content(client, mock_proxy_response):
|
|
73
|
+
with patch("confamnode.client.httpx.post", return_value=mock_proxy_response):
|
|
74
|
+
ansa = client.gist(model=models.SPEED, messages="How far?")
|
|
75
|
+
assert ansa.text == "How far! I dey fine."
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_gist_has_model(client, mock_proxy_response):
|
|
79
|
+
with patch("confamnode.client.httpx.post", return_value=mock_proxy_response):
|
|
80
|
+
ansa =client.gist(model="confam-speed", messages="How far?")
|
|
81
|
+
assert ansa.model == "confam-speed"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_gist_has_usage(client, mock_proxy_response):
|
|
85
|
+
with patch("confamnode.client.httpx.post", return_value=mock_proxy_response):
|
|
86
|
+
ansa = client.gist(model=models.SPEED, messages="How far?")
|
|
87
|
+
assert isinstance(ansa.usage, Usage)
|
|
88
|
+
assert ansa.usage.prompt_tokens == 10
|
|
89
|
+
assert ansa.usage.completion_tokens == 20
|
|
90
|
+
assert ansa.usage.total_tokens == 30
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_gist_has_cost(client, mock_proxy_response):
|
|
94
|
+
with patch("confamnode.client.httpx.post", return_value=mock_proxy_response):
|
|
95
|
+
ansa = client.gist(model=models.SPEED, messages="How far?")
|
|
96
|
+
assert isinstance(ansa.cost, Cost)
|
|
97
|
+
assert isinstance(ansa.cost.naira, float)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_gist_comes_from_confam_object(client, mock_proxy_response):
|
|
101
|
+
mock_proxy_response.json.return_value["confam"]["cost"] = {
|
|
102
|
+
"naira": 0.596,
|
|
103
|
+
"naira_input": 0.075,
|
|
104
|
+
"naira_output": 0.521
|
|
105
|
+
}
|
|
106
|
+
with patch("confamnode.client.httpx.post", return_value=mock_proxy_response):
|
|
107
|
+
ansa = client.gist(model="confam-intelligence", messages="How far?")
|
|
108
|
+
assert ansa.cost.naira == 0.596
|
|
109
|
+
assert ansa.cost.naira_input == 0.075
|
|
110
|
+
assert ansa.cost.naira_output == 0.521
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_gist_has_finish_reason(client, mock_proxy_response):
|
|
114
|
+
with patch("confamnode.client.httpx.post", return_value=mock_proxy_response):
|
|
115
|
+
ansa = client.gist(model=models.SPEED, messages="How far?")
|
|
116
|
+
assert ansa.finish_reason == "stop"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_gist_has_id(client, mock_proxy_response):
|
|
120
|
+
with patch("confamnode.client.httpx.post", return_value=mock_proxy_response):
|
|
121
|
+
ansa = client.gist(model=models.SPEED, messages="How far?")
|
|
122
|
+
assert ansa.id.startswith("confam-")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_gist_has_raw(client, mock_proxy_response):
|
|
126
|
+
with patch("confamnode.client.httpx.post", return_value=mock_proxy_response):
|
|
127
|
+
ansa = client.gist(model=models.SPEED, messages="How far?")
|
|
128
|
+
assert isinstance(ansa.raw, dict)
|
|
129
|
+
assert "_hidden_params" not in ansa.raw
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_gist_accepts_string_messages(client, mock_proxy_response):
|
|
133
|
+
with patch("confamnode.client.httpx.post", return_value=mock_proxy_response):
|
|
134
|
+
ansa = client.gist(model=models.SPEED, messages="How far?")
|
|
135
|
+
assert ansa is not None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_gist_converts_string_to_user_message_in_request(client, mock_proxy_response):
|
|
139
|
+
with patch("confamnode.client.httpx.post", return_value=mock_proxy_response) as mock_post:
|
|
140
|
+
client.gist(model=models.SPEED, messages="How far?")
|
|
141
|
+
body = mock_post.call_args.kwargs["json"]
|
|
142
|
+
user_msgs = [m for m in body["messages"] if m["role"] == "user"]
|
|
143
|
+
assert user_msgs[0]["content"] == "How far?"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_gist_accepts_list_messages(client, mock_proxy_response):
|
|
147
|
+
with patch("confamnode.client.httpx.post", return_value=mock_proxy_response):
|
|
148
|
+
ansa = client.gist(
|
|
149
|
+
model=models.SPEED,
|
|
150
|
+
messages=[{"role": "user", "content": "How far?"}]
|
|
151
|
+
)
|
|
152
|
+
assert ansa is not None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_gist_rejects_invalid_messages_type(client):
|
|
156
|
+
with pytest.raises(ValueError, match="messages must be a string or list"):
|
|
157
|
+
client.gist(model=models.SPEED, messages=123)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_gist_passes_kwargs_to_proxy(client, mock_proxy_response):
|
|
161
|
+
with patch("confamnode.client.httpx.post", return_value=mock_proxy_response) as mock_post:
|
|
162
|
+
client.gist(model=models.SPEED, messages="How far?", temperature=0.7, max_tokens=500)
|
|
163
|
+
body = mock_post.call_args.kwargs["json"]
|
|
164
|
+
assert body["temperature"] == 0.7
|
|
165
|
+
assert body["max_tokens"] == 500
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_gist_does_not_prepend_openai_prefix(client, mock_proxy_response):
|
|
169
|
+
with patch("confamnode.client.httpx.post", return_value=mock_proxy_response) as mock_post:
|
|
170
|
+
client.gist(model="confam-speed", messages="How far?", temperature=0.7, max_tokens=500)
|
|
171
|
+
body = mock_post.call_args.kwargs["json"]
|
|
172
|
+
assert body["model"] == "confam-speed"
|
|
173
|
+
assert "openai/" not in body["model"]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def test_gist_default_system_omits_system_field(client, mock_proxy_response):
|
|
177
|
+
with patch("confamnode.client.httpx.post", return_value=mock_proxy_response) as mock_post:
|
|
178
|
+
client.gist(model="confam-speed", messages="How far?")
|
|
179
|
+
body = mock_post.call_args.kwargs["json"]
|
|
180
|
+
assert "system" not in body
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_gist_custom_system_passes_as_field(client, mock_proxy_response):
|
|
184
|
+
with patch("confamnode.client.httpx.post", return_value=mock_proxy_response) as mock_post:
|
|
185
|
+
client.gist(model=models.SPEED, messages="Who you be?", system="You are a Konga assistant")
|
|
186
|
+
body = mock_post.call_args.kwargs["json"]
|
|
187
|
+
assert body["system"] == "You are a Konga assistant"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_gist_system_none_passes_null(client, mock_proxy_response):
|
|
191
|
+
with patch("confamnode.client.httpx.post", return_value=mock_proxy_response) as mock_post:
|
|
192
|
+
client.gist(model=models.SPEED, messages="Who you be?", system=None)
|
|
193
|
+
body = mock_post.call_args.kwargs["json"]
|
|
194
|
+
assert "system" in body
|
|
195
|
+
assert body["system"] is None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_gist_system_in_messages_list_is_respected(client, mock_proxy_response):
|
|
199
|
+
with patch("confamnode.client.httpx.post", return_value=mock_proxy_response) as mock_post:
|
|
200
|
+
client.gist(
|
|
201
|
+
model=models.SPEED,
|
|
202
|
+
messages=[
|
|
203
|
+
{"role": "system", "content": "You are a Konga assistant"},
|
|
204
|
+
{"role": "user", "content": "Who you be?"}
|
|
205
|
+
]
|
|
206
|
+
)
|
|
207
|
+
body = mock_post.call_args.kwargs["json"]
|
|
208
|
+
assert body["messages"][0]["role"] == "system"
|
|
209
|
+
assert body["messages"][0]["content"] == "You are a Konga assistant"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_gist_sets_local_from_confam_object(client, mock_proxy_response):
|
|
213
|
+
mock_proxy_response.json.return_value["confam"]["is_local"] = True
|
|
214
|
+
mock_proxy_response.json.return_value["confam"]["is_ngn_data_residency"] = True
|
|
215
|
+
with patch("confamnode.client.httpx.post", return_value=mock_proxy_response):
|
|
216
|
+
ansa = client.gist(model=models.NANO, messages="How you dey?")
|
|
217
|
+
assert ansa.is_local is True
|
|
218
|
+
assert ansa.is_ngn_data_residency is True
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_sets_is_local_false_for_cloud_model(client, mock_proxy_response):
|
|
222
|
+
with patch("confamnode.client.httpx.post", return_value=mock_proxy_response):
|
|
223
|
+
ansa = client.gist(model=models.SPEED, messages="How you dey?")
|
|
224
|
+
assert ansa.is_local is False
|
|
225
|
+
assert ansa.is_ngn_data_residency is False
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def test_gist_accepts_all_valid_models(client, mock_proxy_response):
|
|
229
|
+
valid_models = [models.LITE, models.SPEED, models.REASONING, models.NANO]
|
|
230
|
+
with patch("confamnode.client.httpx.post", return_value=mock_proxy_response):
|
|
231
|
+
for model in valid_models:
|
|
232
|
+
ansa = client.gist(model=model, messages="How far?")
|
|
233
|
+
assert ansa is not None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_gist_raises_on_proxy_error(client):
|
|
237
|
+
mock_error_response = MagicMock()
|
|
238
|
+
mock_error_response.status_code = 402
|
|
239
|
+
mock_error_response.json.return_value = {"detail": "Insufficient wallet balance."}
|
|
240
|
+
with patch("confamnode.client.httpx.post", return_value=mock_error_response):
|
|
241
|
+
with pytest.raises(Exception):
|
|
242
|
+
client.gist(model="confam-intelligence", messages="How far?")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_gist_reasoning_content_returned(client, mock_proxy_response):
|
|
246
|
+
mock_proxy_response.json.return_value["choices"][0]["message"]["reasoning"] = "step by step..."
|
|
247
|
+
with patch("confamnode.client.httpx.post", return_value=mock_proxy_response):
|
|
248
|
+
ansa = client.gist(model=models.REASONING, messages="Think carefully")
|
|
249
|
+
assert ansa.reasoning == "step by step..."
|
|
250
|
+
|