@miller-tech/uap 2.19.5 → 2.20.0

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.
@@ -1,5 +1,5 @@
1
1
  # Requirements for UAP Anthropic Proxy
2
2
  # Install: pip install -r requirements-proxy.txt
3
3
  fastapi>=0.104.0
4
- uvicorn>=0.24.0
4
+ uvicorn>=0.30.0 # _CompanionListener.capture_signals: override exists only on uvicorn >= 0.28; older floors silently install competing signal handlers (last wins) and hang systemd stops
5
5
  httpx>=0.25.0
@@ -0,0 +1,320 @@
1
+ #!/usr/bin/env python3
2
+ """Ollama-compatible surface (/api/*) on PROXY_OLLAMA_PORT.
3
+
4
+ The surface is a pure translation layer over the same guarded pipeline the
5
+ Anthropic and OpenAI inbounds use, so what needs pinning here is exactly the
6
+ translation: Ollama request fields -> OpenAI request fields, and OpenAI
7
+ response -> Ollama wire shapes (single JSON, and the two-line NDJSON stream
8
+ a stream-mode client waits on). Conversion helpers are exercised in
9
+ isolation per this suite's convention — the guarded core they call is the
10
+ same messages() pipeline the rest of the suite already covers.
11
+
12
+ The lifecycle machinery (companion listener) matters just as much: a second
13
+ uvicorn server must not install its own signal handlers (SIGTERM would land
14
+ on whichever registered last and leave the other port hanging on systemd
15
+ stops), and the app's startup handlers must not run twice.
16
+ """
17
+
18
+ import asyncio
19
+ import importlib.util
20
+ import json
21
+ import socket
22
+ import time
23
+ import unittest
24
+ from pathlib import Path
25
+
26
+
27
+ def _load():
28
+ p = Path(__file__).resolve().parents[1] / "scripts" / "anthropic_proxy.py"
29
+ spec = importlib.util.spec_from_file_location("anthropic_proxy", p)
30
+ m = importlib.util.module_from_spec(spec)
31
+ spec.loader.exec_module(m)
32
+ return m
33
+
34
+
35
+ proxy = _load()
36
+
37
+
38
+ class TestOllamaChatRequestConversion(unittest.TestCase):
39
+ def test_messages_options_tools_and_model_default(self):
40
+ body = {
41
+ "model": "qwen3.8-flash-next-iq3_xxs",
42
+ "messages": [
43
+ {"role": "system", "content": "be terse"},
44
+ {"role": "user", "content": "hi"},
45
+ ],
46
+ "stream": True,
47
+ "tools": [{"type": "function", "function": {"name": "f"}}],
48
+ "options": {
49
+ "temperature": 0.2,
50
+ "top_k": 40,
51
+ "num_predict": 512,
52
+ "num_ctx": 999999, # must be dropped: rail owns context sizing
53
+ "min_p": 0.05, # must be dropped: not forwarded by the 2nd hop
54
+ "stop": "END",
55
+ },
56
+ }
57
+ out = proxy.ollama_chat_to_openai_request(body)
58
+ self.assertEqual(
59
+ out["messages"],
60
+ [
61
+ {"role": "system", "content": "be terse"},
62
+ {"role": "user", "content": "hi"},
63
+ ],
64
+ )
65
+ self.assertEqual(out["model"], "qwen3.8-flash-next-iq3_xxs")
66
+ self.assertEqual(out["temperature"], 0.2)
67
+ self.assertEqual(out["top_k"], 40)
68
+ self.assertEqual(out["max_tokens"], 512)
69
+ self.assertEqual(out["stop"], ["END"])
70
+ # drops happen at the boundary, not silently mid-pipeline
71
+ for dropped in ("num_ctx", "min_p", "repeat_penalty", "seed"):
72
+ self.assertNotIn(dropped, json.dumps(out))
73
+ self.assertEqual(out["tool_choice"], "auto")
74
+ # buffered through the guardrails: always non-stream internally
75
+ self.assertFalse(out["stream"])
76
+
77
+ def test_content_parts_and_unknown_role(self):
78
+ body = {
79
+ "model": "m",
80
+ "messages": [
81
+ {"role": "supervisor", "content": [{"type": "text", "text": "part1"},
82
+ {"type": "image", "text": "drop"}]},
83
+ {"role": "user", "content": [{"type": "text", "text": "part2"}]},
84
+ ],
85
+ }
86
+ out = proxy.ollama_chat_to_openai_request(body)
87
+ self.assertEqual(out["messages"][0], {"role": "user", "content": "part1"})
88
+ self.assertEqual(out["messages"][1], {"role": "user", "content": "part2"})
89
+
90
+ def test_assistant_tool_calls_replay_object_args_to_string(self):
91
+ body = {
92
+ "model": "m",
93
+ "messages": [
94
+ {"role": "assistant", "content": "",
95
+ "tool_calls": [{"function": {"name": "bash",
96
+ "arguments": {"command": "ls"}}}]},
97
+ {"role": "tool", "content": "file_a\nfile_b"},
98
+ {"role": "user", "content": "thanks"},
99
+ ],
100
+ }
101
+ out = proxy.ollama_chat_to_openai_request(body)
102
+ entry = out["messages"][0]
103
+ self.assertEqual(entry["tool_calls"][0]["function"]["name"], "bash")
104
+ # OpenAI carries arguments as a JSON string
105
+ self.assertEqual(
106
+ json.loads(entry["tool_calls"][0]["function"]["arguments"]),
107
+ {"command": "ls"},
108
+ )
109
+ # Deterministic ids pair the replayed call with its tool result:
110
+ # Ollama associates by order, the Anthropic hop pairs by id.
111
+ call_id = entry["tool_calls"][0]["id"]
112
+ self.assertTrue(call_id)
113
+ self.assertEqual(out["messages"][1]["tool_call_id"], call_id)
114
+ self.assertEqual(out["messages"][2], {"role": "user", "content": "thanks"})
115
+
116
+ def test_format_field_is_accepted_and_dropped(self):
117
+ # response_format does not survive the second translation hop; the
118
+ # drop happens at the boundary instead of silently mid-pipeline.
119
+ out = proxy.ollama_chat_to_openai_request(
120
+ {"model": "m", "messages": [], "format": "json"}
121
+ )
122
+ self.assertNotIn("response_format", out)
123
+
124
+
125
+ class TestOllamaGenerateRequestConversion(unittest.TestCase):
126
+ def test_system_then_prompt_order(self):
127
+ out = proxy.ollama_generate_to_openai_request(
128
+ {"model": "m", "prompt": "write", "system": "you are terse"}
129
+ )
130
+ self.assertEqual(
131
+ out["messages"],
132
+ [
133
+ {"role": "system", "content": "you are terse"},
134
+ {"role": "user", "content": "write"},
135
+ ],
136
+ )
137
+
138
+ def test_prompt_only_and_missing_prompt(self):
139
+ out = proxy.ollama_generate_to_openai_request({"model": "m"})
140
+ self.assertEqual(out["messages"], [{"role": "user", "content": ""}])
141
+
142
+
143
+ class TestOllamaResponseConversion(unittest.TestCase):
144
+ OPENAI = {
145
+ "id": "x",
146
+ "model": "resolved-id",
147
+ "choices": [
148
+ {
149
+ "index": 0,
150
+ "message": {
151
+ "role": "assistant",
152
+ "content": "hello",
153
+ "tool_calls": [
154
+ {"function": {"name": "bash",
155
+ "arguments": "{\"command\": \"ls\"}"}}
156
+ ],
157
+ },
158
+ "finish_reason": "tool_calls",
159
+ }
160
+ ],
161
+ "usage": {"prompt_tokens": 11, "completion_tokens": 7},
162
+ }
163
+
164
+ def test_chat_response_shape_and_tool_args_object(self):
165
+ resp = proxy.openai_to_ollama_chat_response(self.OPENAI, "asked-id", 123)
166
+ self.assertEqual(resp["model"], "asked-id") # echo the REQUESTED id
167
+ self.assertTrue(resp["done"])
168
+ self.assertEqual(resp["done_reason"], "stop") # only "length" is special
169
+ self.assertEqual(resp["message"]["content"], "hello")
170
+ self.assertEqual(
171
+ resp["message"]["tool_calls"][0]["function"]["arguments"],
172
+ {"command": "ls"}, # Ollama carries arguments as an object
173
+ )
174
+ self.assertEqual(resp["prompt_eval_count"], 11)
175
+ self.assertEqual(resp["eval_count"], 7)
176
+ self.assertEqual(resp["total_duration"], 123)
177
+
178
+ def test_chat_response_unparseable_args_stay_string(self):
179
+ openai = {
180
+ "choices": [{"message": {"role": "assistant", "content": "",
181
+ "tool_calls": [{"function": {
182
+ "name": "f", "arguments": "{oops"}}]},
183
+ "finish_reason": "tool_calls"}],
184
+ "usage": {},
185
+ }
186
+ resp = proxy.openai_to_ollama_chat_response(openai, "m", 0)
187
+ self.assertEqual(resp["message"]["tool_calls"][0]["function"]["arguments"],
188
+ "{oops")
189
+
190
+ def test_chat_response_length_done_reason(self):
191
+ openai = {
192
+ "choices": [{"message": {"role": "assistant", "content": "x"},
193
+ "finish_reason": "length"}],
194
+ "usage": {},
195
+ }
196
+ resp = proxy.openai_to_ollama_chat_response(openai, "m", 0)
197
+ self.assertEqual(resp["done_reason"], "length")
198
+
199
+ def test_generate_response_and_tool_calls_as_text(self):
200
+ resp = proxy.openai_to_ollama_generate_response(self.OPENAI, "asked-id", 5)
201
+ self.assertTrue(resp["response"].startswith("hello"))
202
+ self.assertIn('"tool_calls"', resp["response"]) # not silently dropped
203
+ self.assertEqual(resp["eval_count"], 7)
204
+ self.assertTrue(resp["done"])
205
+
206
+ def test_ndjson_stream_two_lines_done_last(self):
207
+ resp = proxy.openai_to_ollama_chat_response(self.OPENAI, "m", 1)
208
+ out = asyncio.run(_collect(proxy._emit_ollama_ndjson(
209
+ resp, {"message": resp["message"]})))
210
+ lines = [json.loads(l) for l in out.splitlines()]
211
+ self.assertEqual(len(lines), 2)
212
+ self.assertFalse(lines[0]["done"])
213
+ self.assertEqual(lines[0]["message"]["content"], "hello")
214
+ self.assertTrue(lines[-1]["done"])
215
+ # final line carries the payload key EMPTY — concatenating content
216
+ # across lines must yield it exactly once
217
+ self.assertEqual(lines[-1]["message"], {"role": "assistant", "content": ""})
218
+ self.assertEqual(
219
+ lines[0]["message"]["content"] + lines[-1]["message"]["content"], "hello"
220
+ )
221
+ self.assertEqual(lines[-1]["eval_count"], 7)
222
+
223
+ def test_ndjson_stream_generate_empties_response_key(self):
224
+ resp = proxy.openai_to_ollama_generate_response(
225
+ {"choices": [{"message": {"role": "assistant", "content": "hi"},
226
+ "finish_reason": "stop"}], "usage": {}}, "m", 1)
227
+ out = asyncio.run(_collect(proxy._emit_ollama_ndjson(
228
+ resp, {"response": resp["response"]})))
229
+ lines = [json.loads(l) for l in out.splitlines()]
230
+ self.assertEqual(lines[0]["response"], "hi")
231
+ self.assertEqual(lines[-1]["response"], "")
232
+ self.assertTrue(lines[-1]["done"])
233
+
234
+
235
+ class TestOllamaModelCardHelpers(unittest.TestCase):
236
+ def test_quantization_and_family_from_model_id(self):
237
+ mid = "qwen3.8-flash-next-iq3_xxs"
238
+ self.assertEqual(proxy._ollama_quantization_level(mid), "IQ3_XXS")
239
+ self.assertEqual(proxy._ollama_family(mid), "qwen3")
240
+ self.assertEqual(proxy._ollama_family("llama3:8b"), "llama3")
241
+ self.assertEqual(proxy._ollama_quantization_level("plain"), "")
242
+
243
+ def test_digest_is_stable_and_prefixed_hex(self):
244
+ d1 = proxy._ollama_digest("m")
245
+ d2 = proxy._ollama_digest("m")
246
+ self.assertEqual(d1, d2)
247
+ self.assertTrue(d1.startswith("sha256:")) # some clients parse on the colon
248
+ int(d1[7:], 16) # hex
249
+ self.assertEqual(len(d1), 71)
250
+
251
+ def test_details_shape(self):
252
+ d = proxy._ollama_details("qwen3.8-flash-next-iq3_xxs")
253
+ self.assertEqual(d["format"], "gguf")
254
+ self.assertEqual(d["family"], d["families"][0])
255
+ self.assertEqual(d["quantization_level"], "IQ3_XXS")
256
+
257
+
258
+ class TestOllamaModelMeta(unittest.IsolatedAsyncioTestCase):
259
+ async def test_meta_placeholders_when_no_upstream(self):
260
+ meta = await proxy._ollama_model_meta("some-model")
261
+ self.assertEqual(meta, {"modified_at": proxy._OLLAMA_START_ISO, "size": 0})
262
+
263
+ async def test_meta_served_from_cache(self):
264
+ # These are discovery endpoints (some unauthenticated) — the meta
265
+ # probe must not hit the upstream once per request.
266
+ proxy._OLLAMA_META_CACHE.clear()
267
+ proxy._OLLAMA_META_CACHE["m"] = (
268
+ time.monotonic(), {"modified_at": "FRESH", "size": 2}
269
+ )
270
+ try:
271
+ self.assertEqual(
272
+ await proxy._ollama_model_meta("m"), {"modified_at": "FRESH", "size": 2}
273
+ )
274
+ finally:
275
+ proxy._OLLAMA_META_CACHE.clear()
276
+
277
+
278
+ class TestCompanionListenerLifecycle(unittest.TestCase):
279
+ def test_capture_signals_is_a_noop(self):
280
+ # A second uvicorn server installing signal handlers would hand
281
+ # SIGTERM to whichever registered last. The companion must not
282
+ # register any.
283
+ import signal
284
+
285
+ companion = proxy._CompanionListener.__new__(proxy._CompanionListener)
286
+ before = signal.getsignal(signal.SIGTERM)
287
+ with companion.capture_signals():
288
+ during = signal.getsignal(signal.SIGTERM)
289
+ after = signal.getsignal(signal.SIGTERM)
290
+ self.assertIs(before, during)
291
+ self.assertIs(before, after)
292
+
293
+ def test_port_bindable_detects_occupied_port(self):
294
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
295
+ s.bind(("127.0.0.1", 0))
296
+ s.listen(1)
297
+ busy_port = s.getsockname()[1]
298
+ self.assertFalse(proxy._ollama_port_bindable("127.0.0.1", busy_port))
299
+ # same port free after close
300
+ self.assertTrue(proxy._ollama_port_bindable("127.0.0.1", busy_port))
301
+
302
+ def test_port_bindable_free_port(self):
303
+ self.assertTrue(proxy._ollama_port_bindable("127.0.0.1", 0))
304
+ # port 0: bind with port 0 means "pick one" — always bindable; the
305
+ # function is only called with real ports, but must not raise.
306
+
307
+ def test_ollama_port_env_default_and_disable(self):
308
+ # module-level default (11434) was set at import
309
+ self.assertEqual(proxy.PROXY_OLLAMA_PORT, 11434)
310
+
311
+
312
+ async def _collect(agen):
313
+ parts = []
314
+ async for chunk in agen:
315
+ parts.append(chunk.decode() if isinstance(chunk, bytes) else chunk)
316
+ return "".join(parts)
317
+
318
+
319
+ if __name__ == "__main__":
320
+ unittest.main()