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.
Files changed (30) hide show
  1. {confamnode-0.1.3 → confamnode-0.2.0}/PKG-INFO +2 -2
  2. {confamnode-0.1.3 → confamnode-0.2.0}/confamnode/__init__.py +1 -1
  3. {confamnode-0.1.3 → confamnode-0.2.0}/confamnode/ansa.py +22 -1
  4. confamnode-0.2.0/confamnode/builders.py +21 -0
  5. confamnode-0.2.0/confamnode/client.py +210 -0
  6. confamnode-0.2.0/confamnode/config.py +3 -0
  7. {confamnode-0.1.3 → confamnode-0.2.0}/pyproject.toml +3 -3
  8. {confamnode-0.1.3 → confamnode-0.2.0}/tests/test_ansa.py +0 -1
  9. confamnode-0.2.0/tests/test_client.py +80 -0
  10. confamnode-0.2.0/tests/test_gist.py +250 -0
  11. confamnode-0.2.0/tests/test_stream.py +159 -0
  12. confamnode-0.2.0/uv.lock +276 -0
  13. confamnode-0.1.3/confamnode/client.py +0 -184
  14. confamnode-0.1.3/confamnode/prompts.py +0 -32
  15. confamnode-0.1.3/confamnode/utils.py +0 -29
  16. confamnode-0.1.3/tests/test_client.py +0 -56
  17. confamnode-0.1.3/tests/test_gist.py +0 -324
  18. confamnode-0.1.3/tests/test_stream.py +0 -170
  19. confamnode-0.1.3/uv.lock +0 -2324
  20. {confamnode-0.1.3 → confamnode-0.2.0}/.gitignore +0 -0
  21. {confamnode-0.1.3 → confamnode-0.2.0}/.python-version +0 -0
  22. {confamnode-0.1.3 → confamnode-0.2.0}/LICENSE +0 -0
  23. {confamnode-0.1.3 → confamnode-0.2.0}/README.md +0 -0
  24. {confamnode-0.1.3 → confamnode-0.2.0}/confamnode/exceptions.py +0 -0
  25. {confamnode-0.1.3 → confamnode-0.2.0}/confamnode/models.py +0 -0
  26. {confamnode-0.1.3 → confamnode-0.2.0}/confamnode/registry.py +0 -0
  27. {confamnode-0.1.3 → confamnode-0.2.0}/tests/__init__.py +0 -0
  28. {confamnode-0.1.3 → confamnode-0.2.0}/tests/test_exceptions.py +0 -0
  29. {confamnode-0.1.3 → confamnode-0.2.0}/tests/test_init.py +0 -0
  30. {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.1.3
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: litellm>=1.87.1
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
 
@@ -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.0"
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.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
- "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"
@@ -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
+