@miller-tech/uap 2.19.5 → 2.21.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.
- package/config/model-profiles/qwen38.json +2 -2
- package/dist/.tsbuildinfo +1 -1
- package/dist/benchmarks/benchmark.d.ts +6 -6
- package/dist/bin/cli.js +15 -0
- package/dist/bin/cli.js.map +1 -1
- package/dist/cli/deliver.d.ts +9 -0
- package/dist/cli/deliver.d.ts.map +1 -1
- package/dist/cli/deliver.js +41 -1
- package/dist/cli/deliver.js.map +1 -1
- package/dist/delivery/agentic-executor.d.ts +13 -1
- package/dist/delivery/agentic-executor.d.ts.map +1 -1
- package/dist/delivery/agentic-executor.js +61 -10
- package/dist/delivery/agentic-executor.js.map +1 -1
- package/dist/delivery/applier.d.ts +37 -1
- package/dist/delivery/applier.d.ts.map +1 -1
- package/dist/delivery/applier.js +49 -7
- package/dist/delivery/applier.js.map +1 -1
- package/dist/delivery/convergence-loop.d.ts +48 -0
- package/dist/delivery/convergence-loop.d.ts.map +1 -1
- package/dist/delivery/convergence-loop.js +161 -14
- package/dist/delivery/convergence-loop.js.map +1 -1
- package/dist/delivery/failing-files.d.ts +48 -0
- package/dist/delivery/failing-files.d.ts.map +1 -0
- package/dist/delivery/failing-files.js +112 -0
- package/dist/delivery/failing-files.js.map +1 -0
- package/dist/delivery/gate-spawn.d.ts +66 -0
- package/dist/delivery/gate-spawn.d.ts.map +1 -0
- package/dist/delivery/gate-spawn.js +192 -0
- package/dist/delivery/gate-spawn.js.map +1 -0
- package/dist/delivery/pending-intents.d.ts +37 -4
- package/dist/delivery/pending-intents.d.ts.map +1 -1
- package/dist/delivery/pending-intents.js +178 -20
- package/dist/delivery/pending-intents.js.map +1 -1
- package/dist/delivery/prescribed-replay.d.ts +53 -0
- package/dist/delivery/prescribed-replay.d.ts.map +1 -0
- package/dist/delivery/prescribed-replay.js +196 -0
- package/dist/delivery/prescribed-replay.js.map +1 -0
- package/dist/delivery/self-gate.d.ts +29 -0
- package/dist/delivery/self-gate.d.ts.map +1 -1
- package/dist/delivery/self-gate.js +149 -13
- package/dist/delivery/self-gate.js.map +1 -1
- package/dist/delivery/verifier-ladder.d.ts +7 -0
- package/dist/delivery/verifier-ladder.d.ts.map +1 -1
- package/dist/delivery/verifier-ladder.js +13 -4
- package/dist/delivery/verifier-ladder.js.map +1 -1
- package/docs/architecture/adr/0005-ollama-compatible-surface.md +74 -0
- package/docs/guides/PROXY.md +48 -0
- package/package.json +2 -2
- package/src/policies/enforcers/__pycache__/_common.cpython-312.pyc +0 -0
- package/src/policies/enforcers/__pycache__/expert_review_required.cpython-312.pyc +0 -0
- package/src/policies/enforcers/__pycache__/quality_metrics_gate.cpython-312.pyc +0 -0
- package/templates/hooks/__pycache__/deliver_autoroute.cpython-312.pyc +0 -0
- package/tools/agents/scripts/__pycache__/toolcall_path_normalizer.cpython-312.pyc +0 -0
- package/tools/agents/scripts/anthropic_proxy.py +778 -49
- package/tools/agents/scripts/requirements-proxy.txt +1 -1
- package/tools/agents/tests/test_ollama_compat.py +320 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
15731
|
-
|
|
15732
|
-
|
|
15733
|
-
|
|
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
|
-
|
|
15736
|
-
|
|
15737
|
-
|
|
15738
|
-
|
|
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
|
-
|
|
15741
|
-
|
|
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;
|
|
15770
|
-
#
|
|
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=
|
|
15828
|
-
status_code=
|
|
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
|
-
|
|
15833
|
-
|
|
15834
|
-
|
|
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
|
-
|
|
16281
|
-
|
|
16282
|
-
|
|
16283
|
-
port
|
|
16284
|
-
|
|
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
|
+
)
|