Add Anthropic Messages API compatibility layer (/v1/messages)
- POST /v1/messages endpoint with full quota enforcement and auth - Accepts x-api-key and anthropic-auth-token headers (for Claude Code) - Transforms Anthropic request/response format ↔ Ollama /api/chat - Streaming support via Anthropic SSE format - Tool use support (request and response transformation) - ANTHROPIC_DEFAULT_MODEL env var for model selection without admin UI - BACKEND_API_KEY env var for forwarding auth to upstream proxies - Fix SQLite path always resolved relative to database.py location - start.sh and start_claude.sh load .env relative to script location
This commit is contained in:
parent
70fd61608b
commit
cc3ee5a03c
@ -16,3 +16,8 @@ DATABASE_URL=sqlite:///./test.db
|
|||||||
OLLAMA_URL=http://localhost:11434
|
OLLAMA_URL=http://localhost:11434
|
||||||
DEFAULT_MODEL=llama3
|
DEFAULT_MODEL=llama3
|
||||||
APP_TZ=Europe/Berlin
|
APP_TZ=Europe/Berlin
|
||||||
|
|
||||||
|
# Standard-Modell für den Anthropic-kompatiblen Endpunkt (/v1/messages)
|
||||||
|
# Wird verwendet, wenn der Client kein Modell angibt oder ein Anthropic-Modellname
|
||||||
|
# (z.B. claude-opus-4-7) auf kein lokales Modell passt.
|
||||||
|
ANTHROPIC_DEFAULT_MODEL=llama3
|
||||||
|
|||||||
@ -1,12 +1,20 @@
|
|||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
|
|
||||||
load_dotenv(dotenv_path=os.path.join(os.path.dirname(__file__), '..', '.env'))
|
load_dotenv(dotenv_path=Path(__file__).resolve().parent.parent / ".env")
|
||||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||||
|
|
||||||
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///./test.db")
|
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///./test.db")
|
||||||
|
|
||||||
|
# Relative SQLite-Pfade immer relativ zu dieser Datei auflösen, nicht zum cwd
|
||||||
|
if DATABASE_URL.startswith("sqlite:///") and not DATABASE_URL.startswith("sqlite:////"):
|
||||||
|
db_path = DATABASE_URL[len("sqlite:///"):]
|
||||||
|
if not os.path.isabs(db_path):
|
||||||
|
db_path = str(Path(__file__).resolve().parent / db_path)
|
||||||
|
DATABASE_URL = f"sqlite:///{db_path}"
|
||||||
|
|
||||||
if "sqlite" in DATABASE_URL:
|
if "sqlite" in DATABASE_URL:
|
||||||
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
|
||||||
else:
|
else:
|
||||||
|
|||||||
220
backend/main.py
220
backend/main.py
@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
import time
|
import time
|
||||||
from logging.handlers import RotatingFileHandler
|
from logging.handlers import RotatingFileHandler
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -51,10 +52,16 @@ def _last_user_msg(messages: list, max_len: int = 120) -> str:
|
|||||||
|
|
||||||
async def require_api_key(request: Request, db: Session = Depends(get_db)):
|
async def require_api_key(request: Request, db: Session = Depends(get_db)):
|
||||||
auth_header = request.headers.get("Authorization", "")
|
auth_header = request.headers.get("Authorization", "")
|
||||||
|
x_api_key = request.headers.get("x-api-key", "")
|
||||||
|
auth_token = request.headers.get("anthropic-auth-token", "")
|
||||||
if auth_header.startswith("Bearer "):
|
if auth_header.startswith("Bearer "):
|
||||||
api_key = auth_header[7:]
|
api_key = auth_header[7:]
|
||||||
elif auth_header.startswith("sk-"):
|
elif auth_header.startswith("sk-"):
|
||||||
api_key = auth_header
|
api_key = auth_header
|
||||||
|
elif x_api_key:
|
||||||
|
api_key = x_api_key
|
||||||
|
elif auth_token:
|
||||||
|
api_key = auth_token
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=401, detail="Invalid or missing API key")
|
raise HTTPException(status_code=401, detail="Invalid or missing API key")
|
||||||
db_key = crud.verify_api_key(db, api_key)
|
db_key = crud.verify_api_key(db, api_key)
|
||||||
@ -82,9 +89,14 @@ async def unhandled_exception_handler(request: Request, exc: Exception):
|
|||||||
request.method, request.url.path, type(exc).__name__, exc, exc_info=exc)
|
request.method, request.url.path, type(exc).__name__, exc, exc_info=exc)
|
||||||
return JSONResponse(status_code=500, content={"error": {"message": "Internal server error", "type": "server_error"}})
|
return JSONResponse(status_code=500, content={"error": {"message": "Internal server error", "type": "server_error"}})
|
||||||
|
|
||||||
|
def _backend_headers() -> dict:
|
||||||
|
key = os.getenv("BACKEND_API_KEY")
|
||||||
|
return {"Authorization": f"Bearer {key}"} if key else {}
|
||||||
|
|
||||||
|
|
||||||
async def proxy_request(url: str, method: str = "GET", json_data: dict = None):
|
async def proxy_request(url: str, method: str = "GET", json_data: dict = None):
|
||||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||||
response = await client.request(method=method, url=url, json=json_data)
|
response = await client.request(method=method, url=url, json=json_data, headers=_backend_headers())
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@app.post("/api/generate")
|
@app.post("/api/generate")
|
||||||
@ -171,6 +183,210 @@ async def versions(db: Session = Depends(get_db)):
|
|||||||
response = await proxy_request(f"{ollama_url}/api/versions", method="GET")
|
response = await proxy_request(f"{ollama_url}/api/versions", method="GET")
|
||||||
return JSONResponse(content=response.json(), status_code=response.status_code)
|
return JSONResponse(content=response.json(), status_code=response.status_code)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Anthropic Messages API compatibility layer ---
|
||||||
|
|
||||||
|
def _anthropic_content_to_str(content) -> str:
|
||||||
|
"""Flatten Anthropic content (string or block array) to a plain string."""
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content
|
||||||
|
if isinstance(content, list):
|
||||||
|
parts = []
|
||||||
|
for block in content:
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
continue
|
||||||
|
if block.get("type") == "text":
|
||||||
|
parts.append(block.get("text", ""))
|
||||||
|
elif block.get("type") == "tool_result":
|
||||||
|
raw = block.get("content", "")
|
||||||
|
if isinstance(raw, list):
|
||||||
|
raw = " ".join(r.get("text", "") for r in raw if isinstance(r, dict) and r.get("type") == "text")
|
||||||
|
parts.append(str(raw))
|
||||||
|
return " ".join(parts)
|
||||||
|
return str(content) if content else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _anthropic_messages_to_ollama(messages: list, system: str = None) -> list:
|
||||||
|
"""Transform Anthropic messages array to Ollama /api/chat format."""
|
||||||
|
result = []
|
||||||
|
if system:
|
||||||
|
result.append({"role": "system", "content": system})
|
||||||
|
for msg in messages:
|
||||||
|
role = msg.get("role")
|
||||||
|
content = msg.get("content")
|
||||||
|
if role == "assistant" and isinstance(content, list):
|
||||||
|
text = " ".join(b.get("text", "") for b in content if isinstance(b, dict) and b.get("type") == "text")
|
||||||
|
tool_calls = [
|
||||||
|
{"function": {"name": b["name"], "arguments": b.get("input", {})}}
|
||||||
|
for b in content if isinstance(b, dict) and b.get("type") == "tool_use"
|
||||||
|
]
|
||||||
|
entry = {"role": "assistant", "content": text}
|
||||||
|
if tool_calls:
|
||||||
|
entry["tool_calls"] = tool_calls
|
||||||
|
result.append(entry)
|
||||||
|
elif role == "user" and isinstance(content, list):
|
||||||
|
text_parts = []
|
||||||
|
for block in content:
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
continue
|
||||||
|
if block.get("type") == "tool_result":
|
||||||
|
if text_parts:
|
||||||
|
result.append({"role": "user", "content": " ".join(text_parts)})
|
||||||
|
text_parts = []
|
||||||
|
raw = block.get("content", "")
|
||||||
|
if isinstance(raw, list):
|
||||||
|
raw = " ".join(r.get("text", "") for r in raw if isinstance(r, dict) and r.get("type") == "text")
|
||||||
|
result.append({"role": "tool", "content": str(raw)})
|
||||||
|
elif block.get("type") == "text":
|
||||||
|
text_parts.append(block.get("text", ""))
|
||||||
|
if text_parts:
|
||||||
|
result.append({"role": "user", "content": " ".join(text_parts)})
|
||||||
|
else:
|
||||||
|
result.append({"role": role, "content": _anthropic_content_to_str(content)})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _anthropic_tools_to_ollama(tools: list) -> list:
|
||||||
|
"""Transform Anthropic tools to Ollama/OpenAI function format."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": t["name"],
|
||||||
|
"description": t.get("description", ""),
|
||||||
|
"parameters": t.get("input_schema", {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for t in tools
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _ollama_to_anthropic_response(ollama_resp: dict, model_name: str, msg_id: str) -> dict:
|
||||||
|
"""Transform an Ollama /api/chat response to Anthropic Messages API format."""
|
||||||
|
msg = ollama_resp.get("message", {})
|
||||||
|
text = msg.get("content", "")
|
||||||
|
tool_calls = msg.get("tool_calls") or []
|
||||||
|
|
||||||
|
content_blocks = []
|
||||||
|
if text:
|
||||||
|
content_blocks.append({"type": "text", "text": text})
|
||||||
|
|
||||||
|
stop_reason = "end_turn"
|
||||||
|
for i, tc in enumerate(tool_calls):
|
||||||
|
stop_reason = "tool_use"
|
||||||
|
fn = tc.get("function", {})
|
||||||
|
args = fn.get("arguments", {})
|
||||||
|
if isinstance(args, str):
|
||||||
|
try:
|
||||||
|
args = json.loads(args)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
args = {}
|
||||||
|
content_blocks.append({
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": f"toolu_{msg_id}_{i}",
|
||||||
|
"name": fn.get("name", ""),
|
||||||
|
"input": args,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": f"msg_{msg_id}",
|
||||||
|
"type": "message",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": content_blocks,
|
||||||
|
"model": model_name,
|
||||||
|
"stop_reason": stop_reason,
|
||||||
|
"stop_sequence": None,
|
||||||
|
"usage": {
|
||||||
|
"input_tokens": ollama_resp.get("prompt_eval_count", 0),
|
||||||
|
"output_tokens": ollama_resp.get("eval_count", 0),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v1/messages")
|
||||||
|
async def anthropic_messages(request: Request, db: Session = Depends(get_db)):
|
||||||
|
ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434"))
|
||||||
|
body = await request.json()
|
||||||
|
|
||||||
|
force_model = crud.get_setting(db, "force_model") or None
|
||||||
|
model_name = force_model or os.getenv("ANTHROPIC_DEFAULT_MODEL") or body.get("model")
|
||||||
|
if not model_name:
|
||||||
|
raise HTTPException(status_code=422, detail="Field 'model' is required")
|
||||||
|
|
||||||
|
anthropic_msgs = body.get("messages", [])
|
||||||
|
system = body.get("system")
|
||||||
|
|
||||||
|
system_str = _anthropic_content_to_str(system) if system else ""
|
||||||
|
all_text = system_str + " ".join(_anthropic_content_to_str(m.get("content")) for m in anthropic_msgs)
|
||||||
|
prompt_tokens = crud.count_tokens(all_text)
|
||||||
|
|
||||||
|
if not crud.check_and_increment_quota(db, request.state.api_key_id, tokens=prompt_tokens, requests=1):
|
||||||
|
raise HTTPException(status_code=429, detail="Quota exceeded")
|
||||||
|
|
||||||
|
ollama_messages = _anthropic_messages_to_ollama(anthropic_msgs, system=system_str)
|
||||||
|
ollama_body: dict = {"model": model_name, "messages": ollama_messages, "stream": body.get("stream", False)}
|
||||||
|
if tools := body.get("tools"):
|
||||||
|
ollama_body["tools"] = _anthropic_tools_to_ollama(tools)
|
||||||
|
|
||||||
|
msg_id = secrets.token_hex(12)
|
||||||
|
target = f"{ollama_url}/api/chat"
|
||||||
|
|
||||||
|
usage_log.info('%s | /v1/messages | %s | ~%d tokens | "%s"',
|
||||||
|
request.state.api_key_name, model_name, prompt_tokens, _last_user_msg(ollama_messages))
|
||||||
|
start = time.monotonic()
|
||||||
|
|
||||||
|
if body.get("stream"):
|
||||||
|
# Backend wird immer non-streaming aufgerufen; der Dev-Proxy baut SSE selbst auf.
|
||||||
|
# Das ist nötig, weil vorgelagerte Proxys (z.B. Produktiv-Proxy) /api/chat
|
||||||
|
# nur non-streaming exponieren.
|
||||||
|
non_stream_body = {**ollama_body, "stream": False}
|
||||||
|
|
||||||
|
async def generate():
|
||||||
|
try:
|
||||||
|
response = await proxy_request(target, method="POST", json_data=non_stream_body)
|
||||||
|
ollama_resp = response.json()
|
||||||
|
except Exception as exc:
|
||||||
|
error_log.error("Stream error | %s | /v1/messages | %s | %s: %s",
|
||||||
|
request.state.api_key_name, model_name, type(exc).__name__, exc, exc_info=exc)
|
||||||
|
raise
|
||||||
|
|
||||||
|
msg = ollama_resp.get("message", {})
|
||||||
|
text = msg.get("content", "")
|
||||||
|
input_tokens = ollama_resp.get("prompt_eval_count", 0)
|
||||||
|
output_tokens = ollama_resp.get("eval_count", 0)
|
||||||
|
|
||||||
|
yield f"event: message_start\ndata: {json.dumps({'type': 'message_start', 'message': {'id': f'msg_{msg_id}', 'type': 'message', 'role': 'assistant', 'content': [], 'model': model_name, 'stop_reason': None, 'stop_sequence': None, 'usage': {'input_tokens': input_tokens, 'output_tokens': 0}}})}\n\n"
|
||||||
|
yield f"event: content_block_start\ndata: {json.dumps({'type': 'content_block_start', 'index': 0, 'content_block': {'type': 'text', 'text': ''}})}\n\n"
|
||||||
|
yield f"event: ping\ndata: {json.dumps({'type': 'ping'})}\n\n"
|
||||||
|
if text:
|
||||||
|
yield f"event: content_block_delta\ndata: {json.dumps({'type': 'content_block_delta', 'index': 0, 'delta': {'type': 'text_delta', 'text': text}})}\n\n"
|
||||||
|
yield f"event: content_block_stop\ndata: {json.dumps({'type': 'content_block_stop', 'index': 0})}\n\n"
|
||||||
|
yield f"event: message_delta\ndata: {json.dumps({'type': 'message_delta', 'delta': {'stop_reason': 'end_turn', 'stop_sequence': None}, 'usage': {'output_tokens': output_tokens}})}\n\n"
|
||||||
|
yield f"event: message_stop\ndata: {json.dumps({'type': 'message_stop'})}\n\n"
|
||||||
|
usage_log.info('%s | /v1/messages | %s | actual ↑%d ↓%d tokens | %.1fs',
|
||||||
|
request.state.api_key_name, model_name,
|
||||||
|
input_tokens, output_tokens,
|
||||||
|
time.monotonic() - start)
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
generate(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await proxy_request(target, method="POST", json_data=ollama_body)
|
||||||
|
result = _ollama_to_anthropic_response(response.json(), model_name, msg_id)
|
||||||
|
usage_log.info('%s | /v1/messages | %s | actual ↑%d ↓%d tokens | %.1fs',
|
||||||
|
request.state.api_key_name, model_name,
|
||||||
|
result["usage"]["input_tokens"], result["usage"]["output_tokens"],
|
||||||
|
time.monotonic() - start)
|
||||||
|
return JSONResponse(content=result, status_code=response.status_code)
|
||||||
|
except Exception as exc:
|
||||||
|
error_log.error("Proxy error | %s | /v1/messages | %s | %s: %s",
|
||||||
|
request.state.api_key_name, model_name, type(exc).__name__, exc, exc_info=exc)
|
||||||
|
raise
|
||||||
|
|
||||||
@app.get("/v1/models")
|
@app.get("/v1/models")
|
||||||
async def list_openai_models(db: Session = Depends(get_db)):
|
async def list_openai_models(db: Session = Depends(get_db)):
|
||||||
ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434"))
|
ollama_url = crud.get_setting(db, "ollama_url", os.getenv("OLLAMA_URL", "http://localhost:11434"))
|
||||||
@ -209,7 +425,7 @@ async def openai_chat_completions(request: Request, db: Session = Depends(get_db
|
|||||||
async def generate():
|
async def generate():
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||||
async with client.stream("POST", target, json=stream_body) as resp:
|
async with client.stream("POST", target, json=stream_body, headers=_backend_headers()) as resp:
|
||||||
async for chunk in resp.aiter_bytes():
|
async for chunk in resp.aiter_bytes():
|
||||||
try:
|
try:
|
||||||
for line in chunk.decode("utf-8", errors="ignore").splitlines():
|
for line in chunk.decode("utf-8", errors="ignore").splitlines():
|
||||||
|
|||||||
272
backend/tests/test_anthropic_messages.py
Normal file
272
backend/tests/test_anthropic_messages.py
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch, call
|
||||||
|
|
||||||
|
|
||||||
|
def _make_body(model="llama3", messages=None, stream=False, **kwargs):
|
||||||
|
body = {
|
||||||
|
"model": model,
|
||||||
|
"messages": messages or [{"role": "user", "content": "Hello"}],
|
||||||
|
"max_tokens": 100,
|
||||||
|
}
|
||||||
|
if stream:
|
||||||
|
body["stream"] = True
|
||||||
|
body.update(kwargs)
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _ollama_chat_response(content="Hi!", input_tokens=5, output_tokens=3):
|
||||||
|
return {
|
||||||
|
"model": "llama3",
|
||||||
|
"message": {"role": "assistant", "content": content},
|
||||||
|
"prompt_eval_count": input_tokens,
|
||||||
|
"eval_count": output_tokens,
|
||||||
|
"done": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Auth ---
|
||||||
|
|
||||||
|
def test_messages_missing_auth_returns_401(test_client):
|
||||||
|
response = test_client.post("/v1/messages", json=_make_body())
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_messages_invalid_key_returns_401(test_client):
|
||||||
|
response = test_client.post(
|
||||||
|
"/v1/messages",
|
||||||
|
headers={"x-api-key": "sk-invalid"},
|
||||||
|
json=_make_body(),
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@patch("main.proxy_request", new_callable=AsyncMock)
|
||||||
|
def test_messages_accepts_anthropic_auth_token_header(mock_proxy, test_client):
|
||||||
|
mock_proxy.return_value.status_code = 200
|
||||||
|
mock_proxy.return_value.json = lambda: _ollama_chat_response()
|
||||||
|
response = test_client.post(
|
||||||
|
"/v1/messages",
|
||||||
|
headers={"anthropic-auth-token": os.environ.get("TEST_API_KEY", "")},
|
||||||
|
json=_make_body(),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@patch("main.proxy_request", new_callable=AsyncMock)
|
||||||
|
def test_messages_accepts_x_api_key_header(mock_proxy, test_client):
|
||||||
|
mock_proxy.return_value.status_code = 200
|
||||||
|
mock_proxy.return_value.json = lambda: _ollama_chat_response()
|
||||||
|
response = test_client.post(
|
||||||
|
"/v1/messages",
|
||||||
|
headers={"x-api-key": os.environ.get("TEST_API_KEY", "")},
|
||||||
|
json=_make_body(),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# --- Validation ---
|
||||||
|
|
||||||
|
def test_messages_missing_model_returns_422(test_client):
|
||||||
|
env = {k: v for k, v in os.environ.items() if k != "ANTHROPIC_DEFAULT_MODEL"}
|
||||||
|
with patch.dict(os.environ, env, clear=True):
|
||||||
|
response = test_client.post(
|
||||||
|
"/v1/messages",
|
||||||
|
headers={"Authorization": f"Bearer {os.environ.get('TEST_API_KEY', '')}"},
|
||||||
|
json={"messages": [{"role": "user", "content": "Hi"}], "max_tokens": 100},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
@patch("main.proxy_request", new_callable=AsyncMock)
|
||||||
|
def test_messages_anthropic_default_model_used_when_no_model_in_request(mock_proxy, test_client):
|
||||||
|
mock_proxy.return_value.status_code = 200
|
||||||
|
mock_proxy.return_value.json = lambda: _ollama_chat_response()
|
||||||
|
with patch.dict(os.environ, {"ANTHROPIC_DEFAULT_MODEL": "qwen3-coder:q8_0"}):
|
||||||
|
test_client.post(
|
||||||
|
"/v1/messages",
|
||||||
|
headers={"Authorization": f"Bearer {os.environ.get('TEST_API_KEY', '')}"},
|
||||||
|
json={"messages": [{"role": "user", "content": "Hi"}], "max_tokens": 100},
|
||||||
|
)
|
||||||
|
sent_body = mock_proxy.call_args[1]["json_data"]
|
||||||
|
assert sent_body["model"] == "qwen3-coder:q8_0"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Quota ---
|
||||||
|
|
||||||
|
def test_messages_quota_exceeded_returns_429(test_client):
|
||||||
|
with patch("main.crud.check_and_increment_quota", return_value=False):
|
||||||
|
response = test_client.post(
|
||||||
|
"/v1/messages",
|
||||||
|
headers={"Authorization": f"Bearer {os.environ.get('TEST_API_KEY', '')}"},
|
||||||
|
json=_make_body(),
|
||||||
|
)
|
||||||
|
assert response.status_code == 429
|
||||||
|
|
||||||
|
|
||||||
|
# --- Response format ---
|
||||||
|
|
||||||
|
@patch("main.proxy_request", new_callable=AsyncMock)
|
||||||
|
def test_messages_returns_anthropic_format(mock_proxy, test_client):
|
||||||
|
mock_proxy.return_value.status_code = 200
|
||||||
|
mock_proxy.return_value.json = lambda: _ollama_chat_response("Hello!")
|
||||||
|
response = test_client.post(
|
||||||
|
"/v1/messages",
|
||||||
|
headers={"Authorization": f"Bearer {os.environ.get('TEST_API_KEY', '')}"},
|
||||||
|
json=_make_body(),
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["type"] == "message"
|
||||||
|
assert data["role"] == "assistant"
|
||||||
|
assert isinstance(data["content"], list)
|
||||||
|
assert data["content"][0]["type"] == "text"
|
||||||
|
assert data["content"][0]["text"] == "Hello!"
|
||||||
|
assert data["usage"]["input_tokens"] == 5
|
||||||
|
assert data["usage"]["output_tokens"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
# --- Request transformation ---
|
||||||
|
|
||||||
|
@patch("main.proxy_request", new_callable=AsyncMock)
|
||||||
|
def test_messages_system_prompt_becomes_first_system_message(mock_proxy, test_client):
|
||||||
|
mock_proxy.return_value.status_code = 200
|
||||||
|
mock_proxy.return_value.json = lambda: _ollama_chat_response()
|
||||||
|
test_client.post(
|
||||||
|
"/v1/messages",
|
||||||
|
headers={"Authorization": f"Bearer {os.environ.get('TEST_API_KEY', '')}"},
|
||||||
|
json=_make_body(system="You are helpful"),
|
||||||
|
)
|
||||||
|
sent_body = mock_proxy.call_args[1]["json_data"]
|
||||||
|
assert sent_body["messages"][0]["role"] == "system"
|
||||||
|
assert sent_body["messages"][0]["content"] == "You are helpful"
|
||||||
|
|
||||||
|
|
||||||
|
@patch("main.proxy_request", new_callable=AsyncMock)
|
||||||
|
def test_messages_tools_transformed_to_ollama_function_format(mock_proxy, test_client):
|
||||||
|
mock_proxy.return_value.status_code = 200
|
||||||
|
mock_proxy.return_value.json = lambda: _ollama_chat_response()
|
||||||
|
test_client.post(
|
||||||
|
"/v1/messages",
|
||||||
|
headers={"Authorization": f"Bearer {os.environ.get('TEST_API_KEY', '')}"},
|
||||||
|
json=_make_body(tools=[{
|
||||||
|
"name": "bash",
|
||||||
|
"description": "Run bash",
|
||||||
|
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}},
|
||||||
|
}]),
|
||||||
|
)
|
||||||
|
sent_body = mock_proxy.call_args[1]["json_data"]
|
||||||
|
assert sent_body["tools"][0]["type"] == "function"
|
||||||
|
assert sent_body["tools"][0]["function"]["name"] == "bash"
|
||||||
|
assert "parameters" in sent_body["tools"][0]["function"]
|
||||||
|
|
||||||
|
|
||||||
|
@patch("main.proxy_request", new_callable=AsyncMock)
|
||||||
|
def test_messages_tool_call_response_transformed_to_anthropic(mock_proxy, test_client):
|
||||||
|
mock_proxy.return_value.status_code = 200
|
||||||
|
mock_proxy.return_value.json = lambda: {
|
||||||
|
"model": "llama3",
|
||||||
|
"message": {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "",
|
||||||
|
"tool_calls": [{"function": {"name": "bash", "arguments": {"command": "ls"}}}],
|
||||||
|
},
|
||||||
|
"prompt_eval_count": 10,
|
||||||
|
"eval_count": 5,
|
||||||
|
"done": True,
|
||||||
|
}
|
||||||
|
response = test_client.post(
|
||||||
|
"/v1/messages",
|
||||||
|
headers={"Authorization": f"Bearer {os.environ.get('TEST_API_KEY', '')}"},
|
||||||
|
json=_make_body(),
|
||||||
|
)
|
||||||
|
data = response.json()
|
||||||
|
assert data["stop_reason"] == "tool_use"
|
||||||
|
tool_block = next(b for b in data["content"] if b["type"] == "tool_use")
|
||||||
|
assert tool_block["name"] == "bash"
|
||||||
|
assert tool_block["input"] == {"command": "ls"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- Streaming ---
|
||||||
|
|
||||||
|
@patch("main.proxy_request", new_callable=AsyncMock)
|
||||||
|
def test_messages_streaming_returns_anthropic_sse_events(mock_proxy, test_client):
|
||||||
|
mock_proxy.return_value.status_code = 200
|
||||||
|
mock_proxy.return_value.json = lambda: {
|
||||||
|
"model": "llama3",
|
||||||
|
"message": {"role": "assistant", "content": "Hi!"},
|
||||||
|
"prompt_eval_count": 5,
|
||||||
|
"eval_count": 3,
|
||||||
|
"done": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = test_client.post(
|
||||||
|
"/v1/messages",
|
||||||
|
headers={"Authorization": f"Bearer {os.environ.get('TEST_API_KEY', '')}"},
|
||||||
|
json=_make_body(stream=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
events = [
|
||||||
|
json.loads(line[6:])
|
||||||
|
for line in response.text.splitlines()
|
||||||
|
if line.startswith("data: ")
|
||||||
|
]
|
||||||
|
event_types = [e["type"] for e in events]
|
||||||
|
assert "message_start" in event_types
|
||||||
|
assert "content_block_start" in event_types
|
||||||
|
assert "content_block_delta" in event_types
|
||||||
|
assert "message_stop" in event_types
|
||||||
|
|
||||||
|
deltas = [e for e in events if e["type"] == "content_block_delta"]
|
||||||
|
text = "".join(d["delta"]["text"] for d in deltas)
|
||||||
|
assert text == "Hi!"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Backend-Auth (BACKEND_API_KEY) ---
|
||||||
|
|
||||||
|
def test_proxy_request_forwards_backend_api_key(test_client):
|
||||||
|
with patch("main.httpx.AsyncClient") as mock_cls:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = {"result": "ok"}
|
||||||
|
|
||||||
|
mock_instance = AsyncMock()
|
||||||
|
mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)
|
||||||
|
mock_instance.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
mock_instance.request = AsyncMock(return_value=mock_response)
|
||||||
|
mock_cls.return_value = mock_instance
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"BACKEND_API_KEY": "sk-backend-secret"}):
|
||||||
|
test_client.post(
|
||||||
|
"/api/generate",
|
||||||
|
headers={"Authorization": f"Bearer {os.environ.get('TEST_API_KEY', '')}"},
|
||||||
|
json={"model": "llama3", "prompt": "hi"},
|
||||||
|
)
|
||||||
|
|
||||||
|
_, kwargs = mock_instance.request.call_args
|
||||||
|
assert kwargs.get("headers", {}).get("Authorization") == "Bearer sk-backend-secret"
|
||||||
|
|
||||||
|
|
||||||
|
def test_proxy_request_omits_auth_header_when_no_backend_key(test_client):
|
||||||
|
with patch("main.httpx.AsyncClient") as mock_cls:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_response.json.return_value = {"result": "ok"}
|
||||||
|
|
||||||
|
mock_instance = AsyncMock()
|
||||||
|
mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)
|
||||||
|
mock_instance.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
mock_instance.request = AsyncMock(return_value=mock_response)
|
||||||
|
mock_cls.return_value = mock_instance
|
||||||
|
|
||||||
|
env_without_key = {k: v for k, v in os.environ.items() if k != "BACKEND_API_KEY"}
|
||||||
|
with patch.dict(os.environ, env_without_key, clear=True):
|
||||||
|
test_client.post(
|
||||||
|
"/api/generate",
|
||||||
|
headers={"Authorization": f"Bearer {os.environ.get('TEST_API_KEY', '')}"},
|
||||||
|
json={"model": "llama3", "prompt": "hi"},
|
||||||
|
)
|
||||||
|
|
||||||
|
_, kwargs = mock_instance.request.call_args
|
||||||
|
assert "Authorization" not in kwargs.get("headers", {})
|
||||||
14
start.sh
14
start.sh
@ -1,17 +1,19 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
# .env laden
|
# .env laden
|
||||||
if [ -f .env ]; then
|
if [ -f "$SCRIPT_DIR/.env" ]; then
|
||||||
set -a
|
set -a
|
||||||
source .env
|
source "$SCRIPT_DIR/.env"
|
||||||
set +a
|
set +a
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Virtuelle Umgebung aktivieren falls vorhanden
|
# Virtuelle Umgebung aktivieren falls vorhanden
|
||||||
if [ -f .venv/bin/activate ]; then
|
if [ -f "$SCRIPT_DIR/.venv/bin/activate" ]; then
|
||||||
source .venv/bin/activate
|
source "$SCRIPT_DIR/.venv/bin/activate"
|
||||||
elif [ -f venv/bin/activate ]; then
|
elif [ -f "$SCRIPT_DIR/venv/bin/activate" ]; then
|
||||||
source venv/bin/activate
|
source "$SCRIPT_DIR/venv/bin/activate"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "$ADMIN_PASSWORD" ]; then
|
if [ -z "$ADMIN_PASSWORD" ]; then
|
||||||
|
|||||||
33
start_claude.sh
Executable file
33
start_claude.sh
Executable file
@ -0,0 +1,33 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
# .env laden
|
||||||
|
if [ -f "$SCRIPT_DIR/.env" ]; then
|
||||||
|
set -a
|
||||||
|
source "$SCRIPT_DIR/.env"
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
# API-Key: erstes Argument hat Vorrang, sonst Umgebungsvariable PROXY_API_KEY
|
||||||
|
API_KEY="${1:-$PROXY_API_KEY}"
|
||||||
|
|
||||||
|
if [ -z "$API_KEY" ]; then
|
||||||
|
echo "Fehler: Kein API-Key angegeben."
|
||||||
|
echo "Verwendung: ./start_claude.sh sk-dein-key"
|
||||||
|
echo " oder: PROXY_API_KEY=sk-dein-key ./start_claude.sh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 0.0.0.0 ist eine Bind-Adresse, kein gültiger Client-Host
|
||||||
|
PROXY_HOST="${PROXY_HOST:-0.0.0.0}"
|
||||||
|
PROXY_PORT="${PROXY_PORT:-8000}"
|
||||||
|
if [ "$PROXY_HOST" = "0.0.0.0" ]; then
|
||||||
|
PROXY_HOST="localhost"
|
||||||
|
fi
|
||||||
|
|
||||||
|
export ANTHROPIC_BASE_URL="http://${PROXY_HOST}:${PROXY_PORT}"
|
||||||
|
export ANTHROPIC_AUTH_TOKEN="$API_KEY"
|
||||||
|
|
||||||
|
echo "Verbinde mit Proxy: $ANTHROPIC_BASE_URL"
|
||||||
|
exec claude
|
||||||
Loading…
x
Reference in New Issue
Block a user