@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.
@@ -106,6 +106,10 @@ Usage
106
106
  # Custom proxy port:
107
107
  PROXY_PORT=5000 python anthropic_proxy.py
108
108
 
109
+ # Ollama-compatible surface on the well-known port (default; 0 disables —
110
+ # set that on a box running a real Ollama, which owns 11434):
111
+ PROXY_OLLAMA_PORT=11434 python anthropic_proxy.py
112
+
109
113
  # Via npx (after npm install):
110
114
  npx uap-anthropic-proxy
111
115
 
@@ -126,6 +130,8 @@ import json
126
130
  import logging
127
131
  import os
128
132
  import re
133
+ import socket
134
+ import stat
129
135
  import sys
130
136
  import time
131
137
  import uuid
@@ -139,7 +145,7 @@ from dataclasses import dataclass, field
139
145
  from pathlib import Path
140
146
 
141
147
  import httpx
142
- from contextlib import asynccontextmanager
148
+ from contextlib import asynccontextmanager, contextmanager
143
149
  from fastapi import FastAPI, Request, Response
144
150
  from fastapi.responses import StreamingResponse
145
151
  import uvicorn
@@ -287,6 +293,12 @@ ANTHROPIC_API_BASE = os.environ.get(
287
293
  ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "")
288
294
  ANTHROPIC_PASSTHROUGH_MODELS = os.environ.get("ANTHROPIC_PASSTHROUGH_MODELS", "")
289
295
  PROXY_PORT = int(os.environ.get("PROXY_PORT", "4000"))
296
+ # The Ollama-compatible surface: the SAME app served on a SECOND port so
297
+ # Ollama-native tooling (which auto-discovers localhost:11434 and cannot be
298
+ # pointed at a path-prefixed alternative) works against this proxy. 0 disables
299
+ # it — set that on a box running a real Ollama, which owns 11434 by convention.
300
+ # See the "Ollama-compatible surface" section near the end of this file.
301
+ PROXY_OLLAMA_PORT = int(os.environ.get("PROXY_OLLAMA_PORT", "11434"))
290
302
  # Bind loopback by DEFAULT (security audit: an unauthenticated 0.0.0.0 listener
291
303
  # let any LAN host drive the local model and reach cloud passthrough). To run
292
304
  # the proxy as a shared LAN service, set PROXY_HOST=0.0.0.0 AND set
@@ -5749,7 +5761,14 @@ async def _pool_timeout_handler(request: Request, exc: httpx.PoolTimeout):
5749
5761
 
5750
5762
  # Open paths that never require the shared secret (liveness / discovery), so a
5751
5763
  # LAN health check or an SDK model-list probe works without the token.
5752
- _PROXY_AUTH_OPEN_PATHS = frozenset({"/health", "/", "/v1/models"})
5764
+ # /api/version, /api/tags and /api/ps stay open for the same reason /v1/models
5765
+ # does: Ollama clients probe them BEFORE their first real request, and a 401 on
5766
+ # discovery breaks them before any credential could even be presented. The
5767
+ # model-serving /api routes (/api/chat, /api/generate, /api/show) are NOT open
5768
+ # — they carry the same content as /v1/messages and gate identically.
5769
+ _PROXY_AUTH_OPEN_PATHS = frozenset(
5770
+ {"/health", "/", "/v1/models", "/api/version", "/api/tags", "/api/ps"}
5771
+ )
5753
5772
 
5754
5773
 
5755
5774
  # OUTERMOST — and it must be registered AFTER the auth middleware below to BE
@@ -15727,47 +15746,29 @@ async def messages_anthropic(request: Request):
15727
15746
  return await messages(request)
15728
15747
 
15729
15748
 
15730
- @app.post("/v1/chat/completions")
15731
- async def chat_completions(request: Request):
15732
- """OpenAI-compatible chat/completions endpoint for clients like Forge
15733
- that require the OpenAI API shape.
15749
+ async def _guarded_openai_completion(
15750
+ request: Request, openai_body: dict
15751
+ ) -> tuple[dict | None, int, str | None]:
15752
+ """Run an OpenAI Chat Completions body through the guarded Anthropic
15753
+ pipeline and return the OpenAI-shaped response.
15734
15754
 
15735
- FULL GUARDRAIL PATH: Converts the OpenAI request to Anthropic format,
15736
- runs the full /v1/messages pipeline (loop detection, tool narrowing,
15737
- cycle breaking, malformed tool retry, context pruning, etc.), then
15738
- converts the Anthropic response back to OpenAI format.
15755
+ Shared core of BOTH dual-protocol inbound surfaces: the OpenAI route
15756
+ (/v1/chat/completions) and the Ollama surface (/api/chat, /api/generate).
15757
+ Everything except the protocol framing lives here, so the surfaces cannot
15758
+ drift apart in guardrail coverage — an Ollama client gets the same
15759
+ admission control, rail budgeting, loop detection, tool narrowing,
15760
+ malformed-tool retry and context pruning an Anthropic client gets.
15739
15761
 
15740
- Streaming is down-converted to a single final OpenAI SSE chunk sequence
15741
- built from the completed Anthropic response (not token-by-token from
15742
- upstream). This preserves guardrails at the cost of stream granularity.
15762
+ Returns (openai_response, http_status, error_message). On failure the
15763
+ response is None and exactly one of status/error carries the cause.
15743
15764
  """
15744
- body_bytes = await request.body()
15745
- try:
15746
- openai_body = json.loads(body_bytes) if body_bytes else {}
15747
- except (ValueError, TypeError):
15748
- return Response(
15749
- content=b'{"error":{"message":"invalid JSON","type":"invalid_request_error"}}',
15750
- status_code=400,
15751
- media_type="application/json",
15752
- )
15753
-
15754
- requested_stream = bool(openai_body.get("stream", False))
15755
15765
  model = openai_body.get("model", "default")
15756
- client_id = resolve_client_id(request)
15757
-
15758
- logger.info(
15759
- "CHAT (guarded): client=%s model=%s stream=%s msgs=%d tools=%d",
15760
- client_id,
15761
- model,
15762
- requested_stream,
15763
- len(openai_body.get("messages", [])),
15764
- len(openai_body.get("tools", []) or []),
15765
- )
15766
15766
 
15767
15767
  # Convert OpenAI request -> Anthropic request
15768
15768
  anthropic_body = openai_to_anthropic_request(openai_body)
15769
- # Force non-streaming through the pipeline; we re-stream at the end if the
15770
- # client wanted streaming. This keeps guardrail logic simpler/consistent.
15769
+ # Force non-streaming through the pipeline; callers re-stream from the
15770
+ # completed response in whatever dialect they serve. This keeps guardrail
15771
+ # logic simpler/consistent.
15771
15772
  anthropic_body["stream"] = False
15772
15773
 
15773
15774
  # Build a synthetic Request that the existing messages() handler can consume
@@ -15819,19 +15820,63 @@ async def chat_completions(request: Request):
15819
15820
  anthropic_resp_dict = inner_resp
15820
15821
 
15821
15822
  if anthropic_resp_dict is None or "content" not in anthropic_resp_dict:
15822
- # Upstream error: forward as-is in OpenAI error shape
15823
15823
  err_msg = "upstream returned no message"
15824
15824
  if isinstance(anthropic_resp_dict, dict) and "error" in anthropic_resp_dict:
15825
15825
  err_msg = anthropic_resp_dict["error"].get("message", err_msg)
15826
+ return None, status_code, err_msg
15827
+
15828
+ anthropic_resp_dict.setdefault("model", model)
15829
+ return anthropic_to_openai_response(anthropic_resp_dict), 200, None
15830
+
15831
+
15832
+ @app.post("/v1/chat/completions")
15833
+ async def chat_completions(request: Request):
15834
+ """OpenAI-compatible chat/completions endpoint for clients like Forge
15835
+ that require the OpenAI API shape.
15836
+
15837
+ FULL GUARDRAIL PATH: Converts the OpenAI request to Anthropic format,
15838
+ runs the full /v1/messages pipeline (loop detection, tool narrowing,
15839
+ cycle breaking, malformed tool retry, context pruning, etc.), then
15840
+ converts the Anthropic response back to OpenAI format.
15841
+
15842
+ Streaming is down-converted to a single final OpenAI SSE chunk sequence
15843
+ built from the completed Anthropic response (not token-by-token from
15844
+ upstream). This preserves guardrails at the cost of stream granularity.
15845
+ """
15846
+ body_bytes = await request.body()
15847
+ try:
15848
+ openai_body = json.loads(body_bytes) if body_bytes else {}
15849
+ except (ValueError, TypeError):
15826
15850
  return Response(
15827
- content=json.dumps({"error": {"message": err_msg, "type": "upstream_error"}}).encode(),
15828
- status_code=status_code if status_code >= 400 else 502,
15851
+ content=b'{"error":{"message":"invalid JSON","type":"invalid_request_error"}}',
15852
+ status_code=400,
15829
15853
  media_type="application/json",
15830
15854
  )
15831
15855
 
15832
- # Ensure model field is set for response
15833
- anthropic_resp_dict.setdefault("model", model)
15834
- openai_resp = anthropic_to_openai_response(anthropic_resp_dict)
15856
+ requested_stream = bool(openai_body.get("stream", False))
15857
+ model = openai_body.get("model", "default")
15858
+ client_id = resolve_client_id(request)
15859
+
15860
+ logger.info(
15861
+ "CHAT (guarded): client=%s model=%s stream=%s msgs=%d tools=%d",
15862
+ client_id,
15863
+ model,
15864
+ requested_stream,
15865
+ len(openai_body.get("messages", [])),
15866
+ len(openai_body.get("tools", []) or []),
15867
+ )
15868
+
15869
+ openai_resp, status_code, err_msg = await _guarded_openai_completion(
15870
+ request, openai_body
15871
+ )
15872
+ if openai_resp is None:
15873
+ return Response(
15874
+ content=json.dumps(
15875
+ {"error": {"message": err_msg, "type": "upstream_error"}}
15876
+ ).encode(),
15877
+ status_code=status_code if status_code >= 400 else 502,
15878
+ media_type="application/json",
15879
+ )
15835
15880
 
15836
15881
  if not requested_stream:
15837
15882
  return Response(
@@ -16091,6 +16136,557 @@ def _model_entry(model_id: str) -> dict:
16091
16136
  return entry
16092
16137
 
16093
16138
 
16139
+ # ===========================================================================
16140
+ # Ollama-compatible surface (/api/*) — PROXY_OLLAMA_PORT, default 11434
16141
+ # ===========================================================================
16142
+ # The proxy already speaks two inbound dialects (Anthropic Messages on
16143
+ # /v1/messages, OpenAI Chat Completions on /v1/chat/completions), both
16144
+ # normalised through the same guarded pipeline. Ollama clients are a third
16145
+ # dialect: their own discovery handshake (/api/version, /api/tags, /api/show),
16146
+ # their own NDJSON streaming, and their own field vocabulary — but the
16147
+ # semantics underneath are identical. So this surface is a pure translation
16148
+ # layer: Ollama request -> OpenAI request -> _guarded_openai_completion ->
16149
+ # OpenAI response -> Ollama response. Every guardrail (admission, rail
16150
+ # budgeting, loop detection, tool narrowing, malformed-tool retry, context
16151
+ # pruning) therefore applies to Ollama clients too.
16152
+ #
16153
+ # The known tradeoff, identical to the OpenAI inbound surface: streaming is
16154
+ # buffered through the guardrails and re-emitted as Ollama NDJSON afterwards
16155
+ # (one content line + one final line), not token-by-token from upstream. A
16156
+ # client sees its reply appear all at once; correctness of the guardrails is
16157
+ # preserved. Token-granular streaming would require bypassing the pipeline.
16158
+ #
16159
+ # Served from the SAME process on a SECOND port (Ollama's well-known 11434)
16160
+ # because Ollama tooling auto-discovers localhost:11434 and offers no way to
16161
+ # point it at a path-prefixed alternative. Same process, not a second one, is
16162
+ # the whole point: a second process would carry its own admission table and
16163
+ # rail semaphore, silently doubling the concurrency budget the backend was
16164
+ # sized for.
16165
+
16166
+ _OLLAMA_API_VERSION = "0.12.6" # the Ollama release whose wire shape this mirrors
16167
+ _OLLAMA_START_ISO = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
16168
+
16169
+
16170
+ def _ollama_error(status: int, message: str) -> Response:
16171
+ return Response(
16172
+ content=json.dumps({"error": message}).encode(),
16173
+ status_code=status,
16174
+ media_type="application/json",
16175
+ )
16176
+
16177
+
16178
+ def _ollama_content_to_text(content) -> str:
16179
+ """Ollama message content: a plain string, or (newer clients) a list of
16180
+ typed parts. Images have no rail in this pipeline — extract text only."""
16181
+ if isinstance(content, str):
16182
+ return content
16183
+ if isinstance(content, list):
16184
+ return "".join(
16185
+ p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text"
16186
+ )
16187
+ return "" if content is None else str(content)
16188
+
16189
+
16190
+ def _ollama_options_to_openai(options) -> dict:
16191
+ """Translate Ollama's `options` object to OpenAI Chat Completions fields.
16192
+
16193
+ Deliberately minimal: only what survives BOTH translation hops. The second
16194
+ hop (openai_to_anthropic_request) forwards temperature/top_p/top_k/stop/
16195
+ max_tokens, so those are mapped and NOTHING else — an Ollama client's
16196
+ min_p/repeat_penalty/seed are dropped here rather than accepted and
16197
+ silently ignored mid-pipeline (the honest drop also keeps this function
16198
+ truthful about what it claims to do). num_ctx is dropped for a different
16199
+ reason: the rail budget owns context sizing, and forwarding a client's
16200
+ num_ctx would silently fight the admission controller."""
16201
+ if not isinstance(options, dict) or not options:
16202
+ return {}
16203
+ out: dict = {}
16204
+ for src, dst in (("temperature", "temperature"), ("top_p", "top_p"), ("top_k", "top_k")):
16205
+ if src in options:
16206
+ out[dst] = options[src]
16207
+ if "num_predict" in options:
16208
+ try:
16209
+ out["max_tokens"] = max(1, int(options["num_predict"]))
16210
+ except (TypeError, ValueError):
16211
+ pass
16212
+ stop = options.get("stop")
16213
+ if isinstance(stop, str) and stop:
16214
+ out["stop"] = [stop]
16215
+ elif isinstance(stop, list) and stop:
16216
+ out["stop"] = [str(s) for s in stop]
16217
+ return out
16218
+
16219
+
16220
+ def _finish_common_ollama_fields(body: dict) -> dict:
16221
+ """Fields shared by the /api/chat and /api/generate request builders:
16222
+ option mapping, tools passthrough, and the buffered stream decision (the
16223
+ pipeline always runs non-stream; callers re-emit)."""
16224
+ fields: dict = {}
16225
+ fields.update(_ollama_options_to_openai(body.get("options")))
16226
+ if isinstance(body.get("tools"), list) and body["tools"]:
16227
+ fields["tools"] = body["tools"]
16228
+ fields["tool_choice"] = body.get("tool_choice") or "auto"
16229
+ # Ollama's `format` field (structured output) is accepted and DROPPED:
16230
+ # the second translation hop (openai_to_anthropic_request) does not carry
16231
+ # response_format, so wiring it through is a pipeline contract change that
16232
+ # deserves its own schema-diff pass — not something to fake acceptance of.
16233
+ # keep_alive / raw / template are Ollama lifecycle features with no local
16234
+ # equivalent: the backend stays loaded (capacity policy owns that) and the
16235
+ # chat template is the backend's own.
16236
+ fields["stream"] = False
16237
+ return fields
16238
+
16239
+
16240
+ def ollama_chat_to_openai_request(body: dict) -> dict:
16241
+ """Ollama /api/chat body -> OpenAI Chat Completions body."""
16242
+ messages = []
16243
+ # Deterministic tool-call ids for replayed assistant turns. Ollama
16244
+ # associates a tool result with its call by ORDER within the message list;
16245
+ # OpenAI (and the Anthropic hop after it) pairs by id. Without explicit
16246
+ # ids, the second hop mints random ids while the tool results carry "" —
16247
+ # pairs that can never match.
16248
+ pending_call_ids: list[str] = []
16249
+ for m in body.get("messages") or []:
16250
+ if not isinstance(m, dict):
16251
+ continue
16252
+ role = m.get("role") or "user"
16253
+ content = _ollama_content_to_text(m.get("content"))
16254
+ # Ollama roles map onto the OpenAI four. An unknown role is demoted
16255
+ # to user rather than rejected — clients invent roles, and dropping
16256
+ # the text would silently lose conversation history.
16257
+ if role not in ("system", "user", "assistant", "tool"):
16258
+ role = "user"
16259
+ entry: dict = {"role": role, "content": content}
16260
+ if role == "assistant" and isinstance(m.get("tool_calls"), list) and m["tool_calls"]:
16261
+ # Conversation replay of a prior tool call. Ollama carries the
16262
+ # arguments as an object; OpenAI as a JSON string.
16263
+ rebuilt = []
16264
+ pending_call_ids = []
16265
+ for i, tc in enumerate(m["tool_calls"]):
16266
+ fn = (tc or {}).get("function", {}) or {}
16267
+ args = fn.get("arguments", "")
16268
+ if not isinstance(args, str):
16269
+ try:
16270
+ args = json.dumps(args)
16271
+ except (TypeError, ValueError):
16272
+ args = "{}"
16273
+ call_id = f"call_{len(messages)}_{i}"
16274
+ pending_call_ids.append(call_id)
16275
+ rebuilt.append(
16276
+ {
16277
+ "id": call_id,
16278
+ "function": {"name": fn.get("name", ""), "arguments": args},
16279
+ }
16280
+ )
16281
+ entry["tool_calls"] = rebuilt
16282
+ elif role == "tool" and pending_call_ids:
16283
+ # Consume the ids in order: this tool result answers the oldest
16284
+ # still-unpaired call, which is how Ollama ordered them.
16285
+ entry["tool_call_id"] = pending_call_ids.pop(0)
16286
+ messages.append(entry)
16287
+ openai_body: dict = {
16288
+ "model": body.get("model") or FALLBACK_LOCAL_MODEL_ID,
16289
+ "messages": messages,
16290
+ }
16291
+ openai_body.update(_finish_common_ollama_fields(body))
16292
+ return openai_body
16293
+
16294
+
16295
+ def ollama_generate_to_openai_request(body: dict) -> dict:
16296
+ """Ollama /api/generate body -> OpenAI Chat Completions body.
16297
+
16298
+ generate() has no messages array — just prompt (+ optional system). The
16299
+ pipeline has no notion of raw completion mode, so the prompt rides as a
16300
+ user turn. Tools are accepted because some clients pass them to generate;
16301
+ a tool-call finish then surfaces in the Ollama response text as the
16302
+ serialized call rather than being dropped."""
16303
+ messages = []
16304
+ system = body.get("system")
16305
+ if isinstance(system, str) and system.strip():
16306
+ messages.append({"role": "system", "content": system})
16307
+ prompt = body.get("prompt")
16308
+ messages.append({"role": "user", "content": "" if prompt is None else str(prompt)})
16309
+ openai_body: dict = {
16310
+ "model": body.get("model") or FALLBACK_LOCAL_MODEL_ID,
16311
+ "messages": messages,
16312
+ }
16313
+ openai_body.update(_finish_common_ollama_fields(body))
16314
+ return openai_body
16315
+
16316
+
16317
+ def _ollama_done_reason(finish_reason) -> str:
16318
+ return "length" if finish_reason == "length" else "stop"
16319
+
16320
+
16321
+ def _ollama_now_iso() -> str:
16322
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
16323
+
16324
+
16325
+ def _ollama_usage_lines(usage: dict, duration_ns: int) -> dict:
16326
+ """The trailing count block shared by every Ollama response shape.
16327
+ Real Ollama reports nanosecond durations and token counts; our numbers
16328
+ come from the guarded pipeline's usage accounting."""
16329
+ usage = usage or {}
16330
+ return {
16331
+ "total_duration": duration_ns,
16332
+ "load_duration": 0,
16333
+ "prompt_eval_count": usage.get("prompt_tokens", 0),
16334
+ "prompt_eval_duration": 0,
16335
+ "eval_count": usage.get("completion_tokens", 0),
16336
+ "eval_duration": duration_ns,
16337
+ }
16338
+
16339
+
16340
+ def openai_to_ollama_chat_response(
16341
+ openai_resp: dict, requested_model: str, duration_ns: int
16342
+ ) -> dict:
16343
+ """OpenAI Chat Completions response -> Ollama /api/chat response."""
16344
+ choice = (openai_resp.get("choices") or [{}])[0] or {}
16345
+ message = choice.get("message", {}) or {}
16346
+ ollama_message: dict = {
16347
+ "role": "assistant",
16348
+ "content": message.get("content") or "",
16349
+ }
16350
+ if isinstance(message.get("tool_calls"), list) and message["tool_calls"]:
16351
+ tool_calls = []
16352
+ for tc in message["tool_calls"]:
16353
+ fn = (tc or {}).get("function", {}) or {}
16354
+ args = fn.get("arguments", "")
16355
+ if isinstance(args, str):
16356
+ # Ollama carries arguments as an object; OpenAI as a string.
16357
+ # An unparseable string stays a string — a mangled call beats
16358
+ # a silently dropped one.
16359
+ try:
16360
+ args = json.loads(args) if args.strip() else {}
16361
+ except (ValueError, TypeError):
16362
+ pass
16363
+ tool_calls.append(
16364
+ {"function": {"name": fn.get("name", ""), "arguments": args}}
16365
+ )
16366
+ ollama_message["tool_calls"] = tool_calls
16367
+ resp = {
16368
+ "model": requested_model,
16369
+ "created_at": _ollama_now_iso(),
16370
+ "message": ollama_message,
16371
+ "done": True,
16372
+ "done_reason": _ollama_done_reason(choice.get("finish_reason")),
16373
+ }
16374
+ resp.update(_ollama_usage_lines(openai_resp.get("usage"), duration_ns))
16375
+ return resp
16376
+
16377
+
16378
+ def openai_to_ollama_generate_response(
16379
+ openai_resp: dict, requested_model: str, duration_ns: int
16380
+ ) -> dict:
16381
+ """OpenAI Chat Completions response -> Ollama /api/generate response."""
16382
+ choice = (openai_resp.get("choices") or [{}])[0] or {}
16383
+ message = choice.get("message", {}) or {}
16384
+ text = message.get("content") or ""
16385
+ if isinstance(message.get("tool_calls"), list) and message["tool_calls"]:
16386
+ # generate() has no tool contract; surface the call as text rather
16387
+ # than lose it.
16388
+ text += "\n" + json.dumps({"tool_calls": message["tool_calls"]})
16389
+ resp = {
16390
+ "model": requested_model,
16391
+ "created_at": _ollama_now_iso(),
16392
+ "response": text,
16393
+ "done": True,
16394
+ "done_reason": _ollama_done_reason(choice.get("finish_reason")),
16395
+ }
16396
+ resp.update(_ollama_usage_lines(openai_resp.get("usage"), duration_ns))
16397
+ return resp
16398
+
16399
+
16400
+ async def _emit_ollama_ndjson(resp: dict, chunk_fields: dict):
16401
+ """Buffered Ollama streaming: one content line, then the final line.
16402
+
16403
+ The pipeline completed before this generator starts, so both lines are
16404
+ emitted back-to-back — a client sees the reply arrive at once. The line
16405
+ split exists so stream-mode clients (which parse NDJSON line-by-line and
16406
+ wait for done:true) terminate correctly.
16407
+
16408
+ chunk_fields names the payload key(s) of this dialect ("message" for
16409
+ /api/chat, "response" for /api/generate). The final line carries the same
16410
+ key EMPTY, exactly like real Ollama — a client concatenating payload
16411
+ fields across lines sees the content exactly once."""
16412
+ chunk = {"model": resp["model"], "created_at": resp["created_at"], "done": False}
16413
+ chunk.update(chunk_fields)
16414
+ yield (json.dumps(chunk) + "\n").encode()
16415
+ final = dict(resp)
16416
+ for k in chunk_fields:
16417
+ if k == "message":
16418
+ final["message"] = {"role": "assistant", "content": ""}
16419
+ else:
16420
+ final[k] = ""
16421
+ final["done"] = True
16422
+ yield (json.dumps(final) + "\n").encode()
16423
+
16424
+
16425
+ _OLLAMA_META_CACHE: "OrderedDict[str, tuple[float, dict]]" = OrderedDict()
16426
+ _OLLAMA_META_TTL_SECS = 60.0
16427
+
16428
+
16429
+ async def _ollama_model_meta(model_id: str) -> dict:
16430
+ """Best-effort card data for /api/tags, /api/show, /api/ps.
16431
+
16432
+ Real Ollama reads these from its blob store. We mirror what the backend
16433
+ actually exposes (llama.cpp /props: model path -> file size + mtime) and
16434
+ leave unknowables as stable placeholders instead of inventing
16435
+ plausible-looking numbers — a fabricated parameter_size would show up in
16436
+ clients' model pickers as fact.
16437
+
16438
+ The result is cached per model id (60s): these are discovery endpoints,
16439
+ some of them unauthenticated, and a 1:1 request -> upstream probe would
16440
+ make them an amplifier against the backend. The upstream-provided path is
16441
+ validated as an existing regular FILE before stat results are reflected
16442
+ to clients — the backend is inside the trust boundary, but an open
16443
+ endpoint should not become a generic filesystem oracle for it."""
16444
+ now = time.monotonic()
16445
+ hit = _OLLAMA_META_CACHE.get(model_id)
16446
+ if hit is not None and now - hit[0] < _OLLAMA_META_TTL_SECS:
16447
+ return hit[1]
16448
+ meta = {"modified_at": _OLLAMA_START_ISO, "size": 0}
16449
+ try:
16450
+ if http_client:
16451
+ r = await http_client.get(
16452
+ LLAMA_CPP_BASE.replace("/v1", "/props"), timeout=5.0
16453
+ )
16454
+ if r.status_code == 200:
16455
+ props = r.json() or {}
16456
+ path = props.get("model_path") or ""
16457
+ if isinstance(path, str) and path:
16458
+ st = os.stat(path)
16459
+ if stat.S_ISREG(st.st_mode):
16460
+ meta["size"] = st.st_size
16461
+ meta["modified_at"] = time.strftime(
16462
+ "%Y-%m-%dT%H:%M:%SZ", time.gmtime(st.st_mtime)
16463
+ )
16464
+ except Exception as exc:
16465
+ # Best-effort by design (the card degrades to placeholders), but a
16466
+ # silently misconfigured upstream is invisible without this line —
16467
+ # e.g. an LLAMA_CPP_BASE without the /v1 suffix breaks the .replace.
16468
+ logger.debug("OLLAMA: /props metadata miss for %s: %s", model_id, exc)
16469
+ _OLLAMA_META_CACHE[model_id] = (now, meta)
16470
+ while len(_OLLAMA_META_CACHE) > 16:
16471
+ _OLLAMA_META_CACHE.popitem(last=False)
16472
+ return meta
16473
+
16474
+
16475
+ def _ollama_digest(model_id: str) -> str:
16476
+ """Stable pseudo-digest. Real Ollama's digest addresses a content blob;
16477
+ the equivalent truth here is 'this backend serving this model id'. The
16478
+ sha256: prefix matches real Ollama — some clients parse on the colon."""
16479
+ return "sha256:" + hashlib.sha256(f"uap-strata::{model_id}".encode()).hexdigest()
16480
+
16481
+
16482
+ def _ollama_family(model_id: str) -> str:
16483
+ lower = (model_id or "").lower()
16484
+ for fam in (
16485
+ "qwen3",
16486
+ "qwen2",
16487
+ "qwen",
16488
+ "llama3",
16489
+ "llama",
16490
+ "gemma",
16491
+ "mistral",
16492
+ "phi",
16493
+ "deepseek",
16494
+ "codestral",
16495
+ ):
16496
+ if lower.startswith(fam):
16497
+ return fam
16498
+ return "llama"
16499
+
16500
+
16501
+ def _ollama_quantization_level(model_id: str) -> str:
16502
+ m = re.search(
16503
+ r"(iq\d+[a-z0-9_]*|q\d+_[a-z0-9]+|f16|bf16|fp16|f32)", model_id or "", re.IGNORECASE
16504
+ )
16505
+ return m.group(1).upper() if m else ""
16506
+
16507
+
16508
+ def _ollama_details(model_id: str) -> dict:
16509
+ return {
16510
+ "parent_model": "",
16511
+ "format": "gguf",
16512
+ "family": _ollama_family(model_id),
16513
+ "families": [_ollama_family(model_id)],
16514
+ "parameter_size": "",
16515
+ "quantization_level": _ollama_quantization_level(model_id),
16516
+ }
16517
+
16518
+
16519
+ async def _ollama_served_model_ids() -> tuple[str, ...]:
16520
+ """Backend-served ids only. The Claude contract ids that /v1/models
16521
+ advertises for Anthropic-protocol SDK compatibility are protocol fictions
16522
+ — they must not leak into an Ollama client's model picker, where a client
16523
+ would select them and send them to /api/chat as if they were real."""
16524
+ served = await _upstream_model_ids_cached() or ()
16525
+ local = tuple(m for m in served if m not in ADVERTISED_CLAUDE_MODEL_IDS)
16526
+ return local or (FALLBACK_LOCAL_MODEL_ID,)
16527
+
16528
+
16529
+ @app.get("/api/version")
16530
+ async def ollama_version():
16531
+ return {"version": _OLLAMA_API_VERSION}
16532
+
16533
+
16534
+ @app.get("/api/tags")
16535
+ async def ollama_tags():
16536
+ """Ollama model discovery. Served ids only — see _ollama_served_model_ids."""
16537
+ # Same rationale as /v1/models: refresh the rail before answering, because
16538
+ # this is the first thing Ollama clients call and they cache the list.
16539
+ await _maybe_recheck_context_window()
16540
+ models_out = []
16541
+ for mid in await _ollama_served_model_ids():
16542
+ meta = await _ollama_model_meta(mid)
16543
+ models_out.append(
16544
+ {
16545
+ "name": mid,
16546
+ "model": mid,
16547
+ "modified_at": meta["modified_at"],
16548
+ "size": meta["size"],
16549
+ "digest": _ollama_digest(mid),
16550
+ "details": _ollama_details(mid),
16551
+ }
16552
+ )
16553
+ return {"models": models_out}
16554
+
16555
+
16556
+ @app.post("/api/show")
16557
+ async def ollama_show(request: Request):
16558
+ """Ollama model card. An unknown model 404s like real Ollama, because
16559
+ clients use this to validate a model id they remembered."""
16560
+ try:
16561
+ body = json.loads(await request.body() or b"{}")
16562
+ except (ValueError, TypeError):
16563
+ return _ollama_error(400, "invalid JSON body")
16564
+ if not isinstance(body, dict):
16565
+ return _ollama_error(400, "body must be a JSON object")
16566
+ requested = body.get("model") or ""
16567
+ served = await _ollama_served_model_ids()
16568
+ if requested and requested not in served:
16569
+ return _ollama_error(404, f"model '{requested}' not found")
16570
+ model_id = requested or served[0]
16571
+ meta = await _ollama_model_meta(model_id)
16572
+ details = _ollama_details(model_id)
16573
+ parameters: dict = {}
16574
+ window = _effective_context_window() if _context_window_measured else 0
16575
+ if window > 0:
16576
+ # The honest number: the measured rail, not the backend's raw n_ctx.
16577
+ parameters["num_ctx"] = window
16578
+ family = details["family"]
16579
+ return {
16580
+ "modelfile": (
16581
+ "# Modelfile synthesised by the UAP proxy for the live backend.\n"
16582
+ f"FROM {model_id}\n"
16583
+ ),
16584
+ "parameters": parameters,
16585
+ "template": "{{ .System }} {{ .Prompt }}",
16586
+ "details": details,
16587
+ "model_info": {
16588
+ "general.architecture": family,
16589
+ f"{family}.context_length": window or 0,
16590
+ "general.file_size": meta["size"],
16591
+ },
16592
+ }
16593
+
16594
+
16595
+ @app.get("/api/ps")
16596
+ async def ollama_ps():
16597
+ """Running-model list. The backend stays loaded (capacity policy owns
16598
+ that), so a far-future expiry is the honest answer — models do not
16599
+ unload after Ollama's default keep_alive here."""
16600
+ models_out = []
16601
+ for mid in await _ollama_served_model_ids():
16602
+ meta = await _ollama_model_meta(mid)
16603
+ entry = {
16604
+ "name": mid,
16605
+ "model": mid,
16606
+ "size": meta["size"],
16607
+ "digest": _ollama_digest(mid),
16608
+ "details": _ollama_details(mid),
16609
+ "expires_at": time.strftime(
16610
+ "%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() + 86400)
16611
+ ),
16612
+ "size_vram": meta["size"],
16613
+ }
16614
+ models_out.append(entry)
16615
+ return {"models": models_out}
16616
+
16617
+
16618
+ @app.post("/api/chat")
16619
+ async def ollama_chat(request: Request):
16620
+ """Ollama /api/chat: full guardrail path, Ollama wire shape."""
16621
+ try:
16622
+ body = json.loads(await request.body() or b"{}")
16623
+ except (ValueError, TypeError):
16624
+ return _ollama_error(400, "invalid JSON body")
16625
+ if not isinstance(body, dict):
16626
+ return _ollama_error(400, "body must be a JSON object")
16627
+ # Ollama defaults to streaming.
16628
+ requested_stream = bool(body.get("stream", True))
16629
+ requested_model = body.get("model") or FALLBACK_LOCAL_MODEL_ID
16630
+ client_id = resolve_client_id(request)
16631
+ openai_body = ollama_chat_to_openai_request(body)
16632
+ logger.info(
16633
+ "OLLAMA chat: client=%s model=%s stream=%s msgs=%d tools=%d",
16634
+ client_id,
16635
+ requested_model,
16636
+ requested_stream,
16637
+ len(openai_body.get("messages", [])),
16638
+ len(openai_body.get("tools", []) or []),
16639
+ )
16640
+ t0 = time.monotonic()
16641
+ openai_resp, status, err = await _guarded_openai_completion(request, openai_body)
16642
+ duration_ns = int((time.monotonic() - t0) * 1e9)
16643
+ if openai_resp is None:
16644
+ return _ollama_error(status if status >= 400 else 502, err or "upstream returned no message")
16645
+ resp = openai_to_ollama_chat_response(openai_resp, requested_model, duration_ns)
16646
+ if not requested_stream:
16647
+ return Response(content=json.dumps(resp).encode(), media_type="application/json")
16648
+ return StreamingResponse(
16649
+ _emit_ollama_ndjson(resp, {"message": resp["message"]}),
16650
+ media_type="application/x-ndjson",
16651
+ headers={"Cache-Control": "no-cache"},
16652
+ )
16653
+
16654
+
16655
+ @app.post("/api/generate")
16656
+ async def ollama_generate(request: Request):
16657
+ """Ollama /api/generate: full guardrail path, Ollama wire shape."""
16658
+ try:
16659
+ body = json.loads(await request.body() or b"{}")
16660
+ except (ValueError, TypeError):
16661
+ return _ollama_error(400, "invalid JSON body")
16662
+ if not isinstance(body, dict):
16663
+ return _ollama_error(400, "body must be a JSON object")
16664
+ requested_stream = bool(body.get("stream", True))
16665
+ requested_model = body.get("model") or FALLBACK_LOCAL_MODEL_ID
16666
+ client_id = resolve_client_id(request)
16667
+ openai_body = ollama_generate_to_openai_request(body)
16668
+ logger.info(
16669
+ "OLLAMA generate: client=%s model=%s stream=%s prompt_len=%d",
16670
+ client_id,
16671
+ requested_model,
16672
+ requested_stream,
16673
+ len(str(body.get("prompt") or "")),
16674
+ )
16675
+ t0 = time.monotonic()
16676
+ openai_resp, status, err = await _guarded_openai_completion(request, openai_body)
16677
+ duration_ns = int((time.monotonic() - t0) * 1e9)
16678
+ if openai_resp is None:
16679
+ return _ollama_error(status if status >= 400 else 502, err or "upstream returned no message")
16680
+ resp = openai_to_ollama_generate_response(openai_resp, requested_model, duration_ns)
16681
+ if not requested_stream:
16682
+ return Response(content=json.dumps(resp).encode(), media_type="application/json")
16683
+ return StreamingResponse(
16684
+ _emit_ollama_ndjson(resp, {"response": resp["response"]}),
16685
+ media_type="application/x-ndjson",
16686
+ headers={"Cache-Control": "no-cache"},
16687
+ )
16688
+
16689
+
16094
16690
  @app.get("/v1/models")
16095
16691
  async def models():
16096
16692
  """Return available model list.
@@ -16265,21 +16861,154 @@ def _assert_bind_is_authenticated(host: str, token: str) -> None:
16265
16861
  )
16266
16862
 
16267
16863
 
16864
+ def _ollama_port_bindable(host: str, port: int) -> bool:
16865
+ """True when the Ollama surface can bind its port.
16866
+
16867
+ A real Ollama is the likeliest collision (it owns 11434 by convention),
16868
+ and uvicorn exits the whole process on a bind failure — which must never
16869
+ take the primary Anthropic surface down with it. This check is advisory;
16870
+ the race after it (a port grabbed between this check and the companion's
16871
+ bind) is contained by _serve_companion_guarded below, which turns a
16872
+ companion SystemExit into a log line and keeps the primary serving."""
16873
+ try:
16874
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
16875
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
16876
+ s.bind((host, port))
16877
+ return True
16878
+ except OSError:
16879
+ return False
16880
+
16881
+
16882
+ class _CompanionListener(uvicorn.Server):
16883
+ """The second uvicorn server for the Ollama port.
16884
+
16885
+ uvicorn installs signal handlers per Server.serve() call: two servers would
16886
+ hand SIGTERM to whichever installed last and leave the other listening,
16887
+ hanging systemd stops. The companion installs none and instead mirrors
16888
+ the primary's should_exit, so one SIGTERM stops both ports. lifespan="off"
16889
+ (set by the caller) matters even more — the app's startup handlers own the
16890
+ http client and the session monitors, and a second lifespan would run
16891
+ them twice against shared state."""
16892
+
16893
+ @contextmanager
16894
+ def capture_signals(self):
16895
+ yield
16896
+
16897
+
16898
+ async def _run_proxy_servers() -> None:
16899
+ """Serve the app on PROXY_PORT, plus the Ollama surface port when enabled.
16900
+
16901
+ One process, two listeners, one asyncio loop: the Ollama surface shares
16902
+ the app object — and with it the admission table, rail semaphore and every
16903
+ guardrail — with the Anthropic surface."""
16904
+ primary = uvicorn.Server(
16905
+ uvicorn.Config(
16906
+ app, host=PROXY_HOST, port=PROXY_PORT, log_level=PROXY_LOG_LEVEL.lower()
16907
+ )
16908
+ )
16909
+ primary_task = asyncio.ensure_future(primary.serve())
16910
+ tasks: list = [primary_task]
16911
+ ollama_enabled = PROXY_OLLAMA_PORT > 0
16912
+ if ollama_enabled and PROXY_OLLAMA_PORT == PROXY_PORT:
16913
+ # The advisory bind check cannot catch this one: it runs before the
16914
+ # primary has bound, so it would pass for the primary's own port and
16915
+ # the companion would later die on EADDRINUSE.
16916
+ logger.error(
16917
+ "OLLAMA: PROXY_OLLAMA_PORT=%d equals PROXY_PORT — Ollama surface "
16918
+ "disabled. Pick a different port or set 0.",
16919
+ PROXY_OLLAMA_PORT,
16920
+ )
16921
+ ollama_enabled = False
16922
+ if ollama_enabled:
16923
+ if not _ollama_port_bindable(PROXY_HOST, PROXY_OLLAMA_PORT):
16924
+ logger.error(
16925
+ "OLLAMA: port %d is busy (a real Ollama?) — Ollama surface "
16926
+ "disabled, Anthropic/OpenAI surface unaffected. Set "
16927
+ "PROXY_OLLAMA_PORT=0 to silence this.",
16928
+ PROXY_OLLAMA_PORT,
16929
+ )
16930
+ else:
16931
+ companion = _CompanionListener(
16932
+ uvicorn.Config(
16933
+ app,
16934
+ host=PROXY_HOST,
16935
+ port=PROXY_OLLAMA_PORT,
16936
+ log_level=PROXY_LOG_LEVEL.lower(),
16937
+ log_config=None, # the primary already owns logging config
16938
+ lifespan="off", # app startup must not run twice
16939
+ )
16940
+ )
16941
+
16942
+ async def _serve_companion_guarded() -> None:
16943
+ # uvicorn handles a bind failure with sys.exit(1) INSIDE
16944
+ # serve(). SystemExit is a BaseException: it is NOT contained
16945
+ # by gather(return_exceptions=True) — it tears the process
16946
+ # down and takes the primary with it. Contain it here so a
16947
+ # companion failure only ever disables the Ollama surface.
16948
+ try:
16949
+ await companion.serve()
16950
+ except BaseException as exc: # containment is the point
16951
+ logger.error(
16952
+ "OLLAMA: companion listener died (%s: %s) — Ollama "
16953
+ "surface disabled, Anthropic/OpenAI surface "
16954
+ "unaffected.",
16955
+ type(exc).__name__,
16956
+ exc,
16957
+ )
16958
+
16959
+ companion_task = asyncio.ensure_future(_serve_companion_guarded())
16960
+
16961
+ async def _tie_companion() -> None:
16962
+ # The companion cannot see signals (it installs no handlers).
16963
+ # Mirror the primary two ways: when SIGTERM sets the primary's
16964
+ # should_exit, and when the primary task ENDS for any other
16965
+ # reason (crash) — one surface must never outlive the other,
16966
+ # or the process would hang half-alive on systemd stops.
16967
+ while not primary_task.done() and not primary.should_exit:
16968
+ await asyncio.sleep(0.25)
16969
+ companion.should_exit = True
16970
+
16971
+ logger.info(
16972
+ "OLLAMA: serving /api/* on %s:%d (guarded, same process)",
16973
+ PROXY_HOST,
16974
+ PROXY_OLLAMA_PORT,
16975
+ )
16976
+ tasks.append(companion_task)
16977
+ tasks.append(_tie_companion())
16978
+ # return_exceptions: a companion bind failure (the advisory check raced)
16979
+ # must not cancel the primary. A primary failure still exits the process,
16980
+ # because _tie_companion flags the companion and every task then ends —
16981
+ # and the raise below restores the pre-change contract that a dead
16982
+ # primary aborts startup non-zero (Restart=on-failure reads that).
16983
+ await asyncio.gather(*tasks, return_exceptions=True)
16984
+ if not primary_task.cancelled() and primary_task.exception() is not None:
16985
+ raise primary_task.exception()
16986
+
16987
+
16268
16988
  if __name__ == "__main__":
16269
16989
  # Config discovery is silent by design (it fails open so a missing file can
16270
16990
  # never stop the proxy). Say what it found, so a miss is visible in the
16271
16991
  # journal instead of showing up later as unexplained default behaviour.
16272
16992
  logger.info(
16273
- "proxy env: %s | auth=%s | bind=%s:%d",
16993
+ "proxy env: %s | auth=%s | bind=%s:%d | ollama=%s",
16274
16994
  f"loaded {_PROXY_ENV_FILE_LOADED}" if _PROXY_ENV_FILE_LOADED else "no proxy.env found",
16275
16995
  "enabled" if PROXY_AUTH_TOKEN else "DISABLED",
16276
16996
  PROXY_HOST,
16277
16997
  PROXY_PORT,
16998
+ f"port {PROXY_OLLAMA_PORT}" if PROXY_OLLAMA_PORT > 0 else "off",
16278
16999
  )
16279
17000
  _assert_bind_is_authenticated(PROXY_HOST, PROXY_AUTH_TOKEN)
16280
- uvicorn.run(
16281
- app,
16282
- host=PROXY_HOST,
16283
- port=PROXY_PORT,
16284
- log_level=PROXY_LOG_LEVEL.lower(),
16285
- )
17001
+ if PROXY_OLLAMA_PORT > 0:
17002
+ asyncio.run(_run_proxy_servers())
17003
+ else:
17004
+ # Legacy single-port path kept byte-identical to the pre-Ollama
17005
+ # launcher ON PURPOSE: PROXY_OLLAMA_PORT=0 deployments must not
17006
+ # start exercising the gather/tie machinery just because it exists
17007
+ # (blast-radius minimization; a lifecycle regression in the dual path
17008
+ # must not reproduce in single-port setups).
17009
+ uvicorn.run(
17010
+ app,
17011
+ host=PROXY_HOST,
17012
+ port=PROXY_PORT,
17013
+ log_level=PROXY_LOG_LEVEL.lower(),
17014
+ )