Ollama-AI / server.py
Sk0lovek's picture
Update server.py
ead588f verified
Raw
History Blame Contribute Delete
27 kB
"""
FastAPI сервер — OpenAI-compatible REST API поверх Ollama
Самодостаточный файл: весь код провайдера встроен.
Эндпоинты:
GET /health
GET /v1/models
POST /v1/chat/completions (tools, stream)
POST /v1/embeddings
"""
from __future__ import annotations
import json
import os
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, AsyncGenerator, Dict, List, Optional, Union
import requests
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
# ИСПРАВЛЕНИЕ: Заменен localhost на 127.0.0.1 во избежание зависаний из-за IPv6
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://127.0.0.1:11434")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3.2")
OLLAMA_TIMEOUT = int(os.getenv("OLLAMA_TIMEOUT", "120"))
# ---------------------------------------------------------------------------
# Internal dataclasses
# ---------------------------------------------------------------------------
@dataclass
class FunctionCall:
name: str
arguments: str
@dataclass
class ToolCall:
id: str
type: str = "function"
function: FunctionCall = None
@dataclass
class Message:
role: str
content: Optional[str] = None
tool_calls: Optional[List[ToolCall]] = None
tool_call_id: Optional[str] = None
# ---------------------------------------------------------------------------
# Converters
# ---------------------------------------------------------------------------
def _messages_to_ollama(messages: List[dict]) -> List[dict]:
result = []
for msg in messages:
role = msg["role"]
content = msg.get("content") or ""
if role == "tool":
result.append({"role": "tool", "content": content})
continue
if role == "assistant" and msg.get("tool_calls"):
tcs = []
for tc in msg["tool_calls"]:
fn = tc["function"]
args = fn["arguments"]
tcs.append({"function": {
"name": fn["name"],
"arguments": json.loads(args) if isinstance(args, str) else args,
}})
result.append({"role": "assistant", "content": content, "tool_calls": tcs})
continue
if isinstance(content, list):
texts, images = [], []
for block in content:
if block.get("type") == "text":
texts.append(block["text"])
elif block.get("type") == "image_url":
url = block["image_url"].get("url", "")
images.append(url.split(",", 1)[1] if url.startswith("data:") else url)
entry: dict = {"role": role, "content": " ".join(texts)}
if images:
entry["images"] = images
result.append(entry)
continue
result.append({"role": role, "content": content})
return result
def _tools_to_ollama(tools: Optional[List[dict]]) -> Optional[List[dict]]:
if not tools:
return None
return [
{"type": "function", "function": {
"name": t["function"]["name"],
"description": t["function"].get("description", ""),
"parameters": t["function"].get("parameters", {}),
}}
for t in tools if t.get("type") == "function"
]
def _parse_message(msg: dict) -> Message:
tcs_raw = msg.get("tool_calls", [])
tool_calls = None
if tcs_raw:
tool_calls = []
for tc in tcs_raw:
fn = tc.get("function", {})
args = fn.get("arguments", {})
if isinstance(args, dict):
args = json.dumps(args, ensure_ascii=False)
tool_calls.append(ToolCall(
id=f"call_{uuid.uuid4().hex[:8]}",
type="function",
function=FunctionCall(name=fn.get("name", ""), arguments=args),
))
return Message(
role=msg.get("role", "assistant"),
content=msg.get("content") or None,
tool_calls=tool_calls,
)
def _finish_reason(msg: dict, done: bool) -> str:
return "tool_calls" if msg.get("tool_calls") else ("stop" if done else "length")
# ---------------------------------------------------------------------------
# HTTP session
# ---------------------------------------------------------------------------
_session = requests.Session()
_session.headers.update({"Content-Type": "application/json"})
def _post(path: str, payload: dict, stream: bool = False):
r = _session.post(f"{OLLAMA_BASE_URL}{path}", json=payload,
stream=stream, timeout=OLLAMA_TIMEOUT)
r.raise_for_status()
return r
def _get(path: str, timeout: int = 5):
# ИСПРАВЛЕНИЕ: Таймаут для обычных GET запросов снижен до 5 сек, чтобы не ждать 2 минуты при неработающей Ollama
r = _session.get(f"{OLLAMA_BASE_URL}{path}", timeout=timeout)
r.raise_for_status()
return r
# ---------------------------------------------------------------------------
# FastAPI
# ---------------------------------------------------------------------------
app = FastAPI(
title="Ollama OpenAI-Compatible API",
description="OpenAI-compatible REST API backed by Ollama",
version="1.0.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
class _FunctionDef(BaseModel):
name: str
description: Optional[str] = None
parameters: Optional[Dict[str, Any]] = None
class _Tool(BaseModel):
type: str = "function"
function: _FunctionDef
class _Message(BaseModel):
role: str
content: Optional[Union[str, List[Dict[str, Any]]]] = None
tool_calls: Optional[List[Dict[str, Any]]] = None
tool_call_id: Optional[str] = None
name: Optional[str] = None
class ChatRequest(BaseModel):
model: str = Field(default=OLLAMA_MODEL)
messages: List[_Message]
tools: Optional[List[_Tool]] = None
tool_choice: Optional[Union[str, Dict[str, Any]]] = None
temperature: Optional[float] = None
top_p: Optional[float] = None
max_tokens: Optional[int] = None
stream: bool = False
stop: Optional[Union[str, List[str]]] = None
seed: Optional[int] = None
class EmbeddingRequest(BaseModel):
model: str = Field(default=OLLAMA_MODEL)
input: Union[str, List[str]]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _msg_to_dict(m: _Message) -> dict:
d: dict = {"role": m.role}
if m.content is not None: d["content"] = m.content
if m.tool_calls: d["tool_calls"] = m.tool_calls
if m.tool_call_id: d["tool_call_id"] = m.tool_call_id
if m.name: d["name"] = m.name
return d
def _tool_to_dict(t: _Tool) -> dict:
return {"type": t.type, "function": {
"name": t.function.name,
"description": t.function.description or "",
"parameters": t.function.parameters or {},
}}
async def _sse_stream(model: str, payload: dict) -> AsyncGenerator[str, None]:
cid = f"chatcmpl-{uuid.uuid4().hex}"
created = int(time.time())
with _session.post(f"{OLLAMA_BASE_URL}/api/chat",
json=payload, stream=True, timeout=OLLAMA_TIMEOUT) as resp:
resp.raise_for_status()
for line in resp.iter_lines():
if not line:
continue
try:
data = json.loads(line)
except json.JSONDecodeError:
continue
msg_data = data.get("message", {})
done = data.get("done", False)
finish_reason = _finish_reason(msg_data, done) if done else None
delta: dict = {"role": "assistant", "content": msg_data.get("content") or None}
if msg_data.get("tool_calls"):
tcs = []
for i, tc in enumerate(msg_data["tool_calls"]):
fn = tc.get("function", {})
args = fn.get("arguments", {})
if isinstance(args, dict):
args = json.dumps(args, ensure_ascii=False)
tcs.append({
"index": i, "id": f"call_{uuid.uuid4().hex[:8]}",
"type": "function",
"function": {"name": fn.get("name", ""), "arguments": args},
})
delta["tool_calls"] = tcs
chunk = {
"id": cid, "object": "chat.completion.chunk",
"created": created, "model": model,
"choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}],
}
yield f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n"
if done:
break
yield "data: [DONE]\n\n"
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.get("/health")
def health():
try:
data = _get("/api/tags").json()
return {"status": "ok", "ollama": OLLAMA_BASE_URL,
"models_count": len(data.get("models", []))}
except Exception as e:
raise HTTPException(status_code=503, detail=f"Ollama недоступен: {e}")
@app.get("/v1/models")
def list_models():
try:
data = _get("/api/tags").json()
return {"object": "list", "data": [
{"id": m["name"], "object": "model",
"created": int(time.time()), "owned_by": "ollama"}
for m in data.get("models", [])
]}
except Exception as e:
raise HTTPException(status_code=503, detail=str(e))
@app.post("/v1/chat/completions")
async def chat_completions(req: ChatRequest):
messages = [_msg_to_dict(m) for m in req.messages]
tools = [_tool_to_dict(t) for t in req.tools] if req.tools else None
ollama_messages = _messages_to_ollama(messages)
ollama_tools = _tools_to_ollama(tools)
options: dict = {}
if req.temperature is not None: options["temperature"] = req.temperature
if req.top_p is not None: options["top_p"] = req.top_p
if req.max_tokens is not None: options["num_predict"] = req.max_tokens
if req.stop is not None: options["stop"] = [req.stop] if isinstance(req.stop, str) else req.stop
if req.seed is not None: options["seed"] = req.seed
payload: dict = {"model": req.model, "messages": ollama_messages, "stream": req.stream}
if options: payload["options"] = options
if ollama_tools: payload["tools"] = ollama_tools
if req.stream:
return StreamingResponse(
_sse_stream(req.model, payload),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
try:
resp_data = _post("/api/chat", payload).json()
except Exception as e:
raise HTTPException(status_code=502, detail=str(e))
msg_data = resp_data.get("message", {})
message = _parse_message(msg_data)
done = resp_data.get("done", True)
finish = _finish_reason(msg_data, done)
message_dict: dict = {"role": message.role, "content": message.content}
if message.tool_calls:
message_dict["tool_calls"] = [
{"id": tc.id, "type": tc.type,
"function": {"name": tc.function.name, "arguments": tc.function.arguments}}
for tc in message.tool_calls
]
result: dict = {
"id": f"chatcmpl-{uuid.uuid4().hex}",
"object": "chat.completion",
"created": int(time.time()),
"model": req.model,
"choices": [{"index": 0, "message": message_dict, "finish_reason": finish}],
}
pt = resp_data.get("prompt_eval_count", 0)
ct = resp_data.get("eval_count", 0)
if pt or ct:
result["usage"] = {"prompt_tokens": pt, "completion_tokens": ct, "total_tokens": pt + ct}
return JSONResponse(result)
@app.post("/v1/embeddings")
def embeddings(req: EmbeddingRequest):
texts = [req.input] if isinstance(req.input, str) else req.input
data_out: list = []
total = 0
try:
for i, text in enumerate(texts):
r = _post("/api/embed", {"model": req.model, "input": text}).json()
vec = r.get("embeddings", [[]])[0]
data_out.append({"object": "embedding", "index": i, "embedding": vec})
total += r.get("prompt_eval_count", len(text.split()))
except Exception as e:
raise HTTPException(status_code=502, detail=str(e))
return {"object": "list", "data": data_out, "model": req.model,
"usage": {"prompt_tokens": total, "total_tokens": total}}
# ---------------------------------------------------------------------------
# /v1/mess — веб-интерфейс чата
# ---------------------------------------------------------------------------
from fastapi.responses import HTMLResponse
CHAT_HTML = """<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Ollama Chat</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Inter:wght@300;400;500;600&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d0f14;
--surface: #151820;
--surface2: #1c2030;
--border: #2a2f42;
--accent: #7c6af7;
--accent2: #56cfb2;
--text: #e2e5f0;
--muted: #6b7394;
--user-bg: #1e2340;
--ai-bg: #151820;
--danger: #f06080;
--radius: 14px;
}
body {
font-family: 'Inter', sans-serif;
background: var(--bg);
color: var(--text);
height: 100dvh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Header ── */
header {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 24px;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.logo {
width: 36px; height: 36px;
background: linear-gradient(135deg, var(--accent), var(--accent2));
border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 18px;
}
header h1 { font-size: 16px; font-weight: 600; letter-spacing: .3px; }
header .sub { font-size: 12px; color: var(--muted); margin-left: 2px; }
.header-right { margin-left: auto; display: flex; align-items: center; gap: 10px; }
select#modelSelect {
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text);
padding: 6px 10px;
border-radius: 8px;
font-size: 13px;
font-family: 'JetBrains Mono', monospace;
cursor: pointer;
outline: none;
}
select#modelSelect:focus { border-color: var(--accent); }
.status-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--muted);
transition: background .3s;
}
.status-dot.online { background: var(--accent2); box-shadow: 0 0 6px var(--accent2); }
.status-dot.error { background: var(--danger); }
/* ── Messages ── */
#messages {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
scroll-behavior: smooth;
}
#messages::-webkit-scrollbar { width: 4px; }
#messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.msg {
display: flex;
gap: 12px;
max-width: 820px;
animation: fadeUp .25s ease;
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.msg.user { align-self: flex-end; flex-direction: row-reverse; }
.msg.assistant { align-self: flex-start; }
.avatar {
width: 32px; height: 32px; border-radius: 10px;
display: flex; align-items: center; justify-content: center;
font-size: 15px; flex-shrink: 0; margin-top: 2px;
}
.msg.user .avatar { background: linear-gradient(135deg, var(--accent), #a78bfa); }
.msg.assistant .avatar { background: linear-gradient(135deg, var(--accent2), #3b9e8a); }
.bubble {
padding: 12px 16px;
border-radius: var(--radius);
font-size: 14.5px;
line-height: 1.65;
max-width: 680px;
white-space: pre-wrap;
word-break: break-word;
}
.msg.user .bubble { background: var(--user-bg); border: 1px solid #2d3560; border-bottom-right-radius: 4px; }
.msg.assistant .bubble { background: var(--ai-bg); border: 1px solid var(--border); border-bottom-left-radius: 4px; }
.bubble code {
font-family: 'JetBrains Mono', monospace;
background: #0a0c10;
padding: 2px 6px;
border-radius: 5px;
font-size: 13px;
color: var(--accent2);
}
.bubble pre {
background: #0a0c10;
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px;
overflow-x: auto;
margin: 8px 0;
}
.bubble pre code { background: none; padding: 0; color: #c8d3f5; }
.ts { font-size: 11px; color: var(--muted); margin-top: 5px; padding: 0 4px; }
.msg.user .ts { text-align: right; }
/* thinking dots */
.thinking { display: flex; align-items: center; gap: 5px; padding: 14px 16px; }
.thinking span {
width: 7px; height: 7px; border-radius: 50%;
background: var(--muted);
animation: blink 1.2s infinite;
}
.thinking span:nth-child(2) { animation-delay: .2s; }
.thinking span:nth-child(3) { animation-delay: .4s; }
@keyframes blink {
0%,80%,100% { opacity: .3; transform: scale(1); }
40% { opacity: 1; transform: scale(1.3); }
}
/* ── Input area ── */
footer {
padding: 16px 24px 20px;
background: var(--surface);
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.input-row {
display: flex;
gap: 10px;
align-items: flex-end;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
transition: border-color .2s;
}
.input-row:focus-within { border-color: var(--accent); }
textarea#input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text);
font-family: 'Inter', sans-serif;
font-size: 14.5px;
line-height: 1.5;
resize: none;
max-height: 160px;
min-height: 24px;
overflow-y: auto;
}
textarea#input::placeholder { color: var(--muted); }
.btn-send {
width: 36px; height: 36px;
background: linear-gradient(135deg, var(--accent), #a78bfa);
border: none; border-radius: 10px;
color: #fff; cursor: pointer;
display: flex; align-items: center; justify-content: center;
font-size: 16px;
transition: opacity .15s, transform .1s;
flex-shrink: 0;
}
.btn-send:hover { opacity: .85; }
.btn-send:active { transform: scale(.93); }
.btn-send:disabled { opacity: .35; cursor: not-allowed; }
.footer-hint {
font-size: 11.5px; color: var(--muted);
margin-top: 8px; text-align: center;
}
.btn-clear {
background: none; border: 1px solid var(--border);
color: var(--muted); padding: 5px 12px;
border-radius: 8px; cursor: pointer; font-size: 12px;
transition: color .15s, border-color .15s;
}
.btn-clear:hover { color: var(--danger); border-color: var(--danger); }
/* empty state */
#empty {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 12px;
color: var(--muted);
}
#empty .big { font-size: 48px; }
#empty p { font-size: 15px; }
#empty small { font-size: 12px; opacity: .6; }
</style>
</head>
<body>
<header>
<div class="logo">🦙</div>
<div>
<h1>Ollama Chat</h1>
<div class="sub">OpenAI-compatible API</div>
</div>
<div class="header-right">
<select id="modelSelect"><option>Загрузка...</option></select>
<div class="status-dot" id="statusDot" title="Статус соединения"></div>
</div>
</header>
<div id="messages">
<div id="empty">
<div class="big">🦙</div>
<p>Начни диалог с моделью</p>
<small>Поддерживается Markdown и блоки кода</small>
</div>
</div>
<footer>
<div class="input-row">
<textarea id="input" rows="1" placeholder="Напиши сообщение... (Shift+Enter — новая строка)"></textarea>
<button class="btn-send" id="sendBtn" title="Отправить">➤</button>
</div>
<div class="footer-hint" style="display:flex;justify-content:space-between;align-items:center;margin-top:8px">
<span>Enter — отправить &nbsp;·&nbsp; Shift+Enter — новая строка</span>
<button class="btn-clear" onclick="clearChat()">🗑 Очистить</button>
</div>
</footer>
<script>
const messagesEl = document.getElementById('messages');
const inputEl = document.getElementById('input');
const sendBtn = document.getElementById('sendBtn');
const modelSel = document.getElementById('modelSelect');
const statusDot = document.getElementById('statusDot');
const emptyEl = document.getElementById('empty');
let history = []; // {role, content}[]
let busy = false;
// ИСПРАВЛЕНИЕ: Добавлена проверка r.ok и `(e)` в catch для совместимости
async function loadModels() {
try {
const r = await fetch('/v1/models');
if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json();
const models = d.data || [];
modelSel.innerHTML = models.length
? models.map(m => `<option value="${m.id}">${m.id}</option>`).join('')
: '<option value="">Нет моделей</option>';
statusDot.className = 'status-dot online';
} catch (e) {
statusDot.className = 'status-dot error';
modelSel.innerHTML = '<option value="">Ошибка API</option>';
}
}
loadModels();
function now() {
return new Date().toLocaleTimeString('ru', {hour:'2-digit', minute:'2-digit'});
}
function escapeHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function renderMarkdown(raw) {
let s = raw.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) =>
`<pre><code class="${lang}">${escapeHtml(code.trim())}</code></pre>`
);
s = s.replace(/`([^`]+)`/g, '<code>$1</code>');
s = s.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
s = s.replace(/\*(.+?)\*/g, '<em>$1</em>');
s = s.replace(/\n/g, '<br>');
return s;
}
function addMessage(role, content, streaming = false) {
emptyEl && emptyEl.remove();
const wrap = document.createElement('div');
wrap.className = `msg ${role}`;
const avatar = role === 'user' ? '👤' : '🤖';
const bubble = document.createElement('div');
bubble.className = 'bubble';
if (!streaming) bubble.innerHTML = renderMarkdown(content);
const ts = document.createElement('div');
ts.className = 'ts';
ts.textContent = now();
wrap.innerHTML = `<div class="avatar">${avatar}</div>`;
wrap.appendChild(Object.assign(document.createElement('div'),
{ style: 'display:flex;flex-direction:column' }));
wrap.lastChild.appendChild(bubble);
wrap.lastChild.appendChild(ts);
messagesEl.appendChild(wrap);
messagesEl.scrollTop = messagesEl.scrollHeight;
return bubble;
}
function addThinking() {
const wrap = document.createElement('div');
wrap.className = 'msg assistant';
wrap.id = 'thinking';
wrap.innerHTML = `
<div class="avatar">🤖</div>
<div class="bubble thinking">
<span></span><span></span><span></span>
</div>`;
messagesEl.appendChild(wrap);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function removeThinking() {
document.getElementById('thinking')?.remove();
}
async function send() {
const text = inputEl.value.trim();
if (!text || busy) return;
const model = modelSel.value;
if (!model) return;
busy = true;
sendBtn.disabled = true;
inputEl.value = '';
inputEl.style.height = 'auto';
history.push({ role: 'user', content: text });
addMessage('user', text);
addThinking();
try {
const resp = await fetch('/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
messages: history,
stream: true,
temperature: 0.7,
}),
});
removeThinking();
const bubble = addMessage('assistant', '', true);
const reader = resp.body.getReader();
const dec = new TextDecoder();
let full = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = dec.decode(value);
for (const line of chunk.split('\n')) {
if (!line.startsWith('data:')) continue;
const data = line.slice(5).trim();
if (data === '[DONE]') break;
try {
const j = JSON.parse(data);
const delta = j.choices?.[0]?.delta?.content || '';
full += delta;
bubble.innerHTML = renderMarkdown(full);
messagesEl.scrollTop = messagesEl.scrollHeight;
} catch (err) {}
}
}
history.push({ role: 'assistant', content: full });
} catch (e) {
removeThinking();
addMessage('assistant', '⚠️ Ошибка: ' + e.message);
}
busy = false;
sendBtn.disabled = false;
inputEl.focus();
}
function clearChat() {
history = [];
messagesEl.innerHTML = '';
const e = document.createElement('div');
e.id = 'empty';
e.innerHTML = '<div class="big">🦙</div><p>Начни диалог с моделью</p><small>Поддерживается Markdown и блоки кода</small>';
messagesEl.appendChild(e);
}
sendBtn.addEventListener('click', send);
inputEl.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
});
inputEl.addEventListener('input', () => {
inputEl.style.height = 'auto';
inputEl.style.height = Math.min(inputEl.scrollHeight, 160) + 'px';
});
</script>
</body>
</html>"""
@app.get("/v1/mess", response_class=HTMLResponse)
def chat_ui():
return HTMLResponse(content=CHAT_HTML)