feat(vision): /api/vision endpoint for Garden Buddy Buddy Check (v0.2.0)

- New POST /api/vision: multipart contract (image/image2/image3 + prompt,
  system_prompt, backend, model) matching gardenbuddy-buddy-check v1.2.x
- Anthropic Haiku 4.5 vision via direct /v1/messages with the existing
  Claude Code OAuth token (anthropic-beta: oauth-2025-04-20)
- Ollama backend honoured only when the model is installed, otherwise
  falls back to Anthropic (qwen2.5vl retired from this GPU 2026-04-19)
- Bearer auth via CLAUDE_PROXY_VISION_TOKEN (matches gbc_proxy_token)
- Also commits the earlier uncommitted vision-aware /api/chat work
- Version 0.1.0 -> 0.2.0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 06:17:39 +10:00
parent a3be103232
commit fba8f5f4f3

326
app.py
View File

@@ -12,30 +12,55 @@ Endpoints:
GET /api/tags list "models" (so clients can validate) GET /api/tags list "models" (so clients can validate)
POST /api/show model details stub POST /api/show model details stub
POST /api/generate single-shot prompt -> response POST /api/generate single-shot prompt -> response
POST /api/chat multi-message conversation -> response POST /api/chat multi-message conversation -> response (vision-aware)
POST /api/vision multipart image diagnosis endpoint (Garden Buddy
Buddy Check contract: image/image2/image3 files +
prompt/system_prompt/backend/model fields)
Both /api/generate and /api/chat honour the `stream` flag in the request Both /api/generate and /api/chat honour the `stream` flag in the request
body (Ollama default is True). When true, responses are emitted as body (Ollama default is True). When true, responses are emitted as
NDJSON chunks; when false, a single JSON object is returned. NDJSON chunks; when false, a single JSON object is returned.
Vision support: when /api/chat messages contain an `images` list (Ollama
vision format), each base64 blob is written to a temp file under
CLAUDE_PROXY_IMG_DIR and the file path is appended to the prompt so that
claude's Read tool can load the image as a vision input.
Environment variables: Environment variables:
CLAUDE_BIN path to claude CLI (default: "claude") CLAUDE_BIN path to claude CLI (default: "claude")
CLAUDE_PROXY_CONCURRENCY max concurrent claude subprocesses (default: 3) CLAUDE_PROXY_CONCURRENCY max concurrent claude subprocesses (default: 3)
CLAUDE_PROXY_MODEL name advertised in /api/tags (default: "claude-code") CLAUDE_PROXY_MODEL name advertised in /api/tags (default: "claude-code")
CLAUDE_PROXY_TIMEOUT per-request timeout in seconds (default: 300) CLAUDE_PROXY_TIMEOUT per-request timeout in seconds (default: 300)
CLAUDE_CODE_OAUTH_TOKEN long-lived auth token, inherited by claude subprocess CLAUDE_PROXY_IMG_DIR temp dir for vision images (default: /tmp/claude_proxy_imgs)
CLAUDE_CODE_OAUTH_TOKEN long-lived auth token; used by the claude
subprocess AND as Bearer auth for direct
/v1/messages calls on the /api/vision route
(anthropic-beta: oauth-2025-04-20)
CLAUDE_PROXY_VISION_TOKEN shared secret for /api/vision Bearer auth
(matches gardenbuddy.au gbc_proxy_token);
if unset, /api/vision auth is not enforced
OLLAMA_URL local Ollama base URL for the ollama backend
(default: http://127.0.0.1:11434)
Version: 0.2.0
Updated: 2026-06-12
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import base64
import json import json
import logging import logging
import os import os
import tempfile
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path
from typing import Any, AsyncIterator from typing import Any, AsyncIterator
import httpx
from anthropic import AsyncAnthropic
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, StreamingResponse from fastapi.responses import JSONResponse, StreamingResponse
@@ -45,6 +70,19 @@ CLAUDE_BIN = os.environ.get("CLAUDE_BIN", "claude")
CONCURRENCY = int(os.environ.get("CLAUDE_PROXY_CONCURRENCY", "3")) CONCURRENCY = int(os.environ.get("CLAUDE_PROXY_CONCURRENCY", "3"))
DEFAULT_MODEL = os.environ.get("CLAUDE_PROXY_MODEL", "claude-code") DEFAULT_MODEL = os.environ.get("CLAUDE_PROXY_MODEL", "claude-code")
TIMEOUT_SECONDS = int(os.environ.get("CLAUDE_PROXY_TIMEOUT", "300")) TIMEOUT_SECONDS = int(os.environ.get("CLAUDE_PROXY_TIMEOUT", "300"))
IMG_DIR = Path(os.environ.get("CLAUDE_PROXY_IMG_DIR", "/tmp/claude_proxy_imgs"))
OAUTH_TOKEN = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN", "")
VISION_TOKEN = os.environ.get("CLAUDE_PROXY_VISION_TOKEN", "")
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434").rstrip("/")
VISION_DEFAULT_MODEL = "claude-haiku-4-5"
# Claude Haiku 4.5 list pricing (USD per million tokens). Used only to fill the
# cost_usd tracking field. Actual marginal cost is $0 because the OAuth token
# bills against the Claude subscription, not pay-per-token.
HAIKU_IN_PER_MTOK = 1.00
HAIKU_OUT_PER_MTOK = 5.00
HAIKU_CACHE_READ_PER_MTOK = 0.10
HAIKU_CACHE_WRITE_PER_MTOK = 1.25
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("claude-proxy") log = logging.getLogger("claude-proxy")
@@ -52,6 +90,8 @@ log = logging.getLogger("claude-proxy")
app = FastAPI(title="claude-code-proxy") app = FastAPI(title="claude-code-proxy")
_semaphore = asyncio.Semaphore(CONCURRENCY) _semaphore = asyncio.Semaphore(CONCURRENCY)
IMG_DIR.mkdir(parents=True, exist_ok=True)
# --- Helpers ---------------------------------------------------------------- # --- Helpers ----------------------------------------------------------------
@@ -59,12 +99,55 @@ def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
async def _run_claude(prompt: str) -> str: def _save_images(images: list[str]) -> list[Path]:
"""Run `claude -p <prompt>` and return stdout as a string.""" """Write base64-encoded image blobs to temp files; return their paths."""
paths: list[Path] = []
for b64 in images:
# Strip data-URI prefix if present (data:image/png;base64,...)
if "," in b64:
b64 = b64.split(",", 1)[1]
try:
data = base64.b64decode(b64)
except Exception:
log.warning("skipping malformed base64 image blob")
continue
# Detect format from magic bytes
ext = ".jpg"
if data[:8] == b"\x89PNG\r\n\x1a\n":
ext = ".png"
elif data[:4] in (b"RIFF", b"WEBP"):
ext = ".webp"
elif data[:4] == b"GIF8":
ext = ".gif"
tmp = tempfile.NamedTemporaryFile(
suffix=ext, prefix="img_", dir=IMG_DIR, delete=False
)
tmp.write(data)
tmp.close()
paths.append(Path(tmp.name))
return paths
async def _run_claude(prompt: str, image_paths: list[Path] | None = None) -> str:
"""Run `claude -p <prompt>` and return stdout as a string.
When image_paths is provided the prompt already contains [image: path]
references and we pass --allowedTools to permit Read on those specific files.
"""
async with _semaphore: async with _semaphore:
log.info("claude -p invoked (prompt %d chars)", len(prompt)) log.info(
"claude -p invoked (prompt %d chars, images %d) | preview: %s",
len(prompt),
len(image_paths) if image_paths else 0,
prompt[:120].replace("\n", " "),
)
cmd = [CLAUDE_BIN, "-p", prompt]
if image_paths:
cmd += ["--allowedTools", "Read"]
proc = await asyncio.create_subprocess_exec( proc = await asyncio.create_subprocess_exec(
CLAUDE_BIN, "-p", prompt, *cmd,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
env=os.environ.copy(), env=os.environ.copy(),
@@ -77,29 +160,60 @@ async def _run_claude(prompt: str) -> str:
proc.kill() proc.kill()
await proc.wait() await proc.wait()
raise RuntimeError(f"claude -p timed out after {TIMEOUT_SECONDS}s") raise RuntimeError(f"claude -p timed out after {TIMEOUT_SECONDS}s")
finally:
# Always clean up temp image files.
if image_paths:
for p in image_paths:
try:
p.unlink(missing_ok=True)
except Exception:
pass
if proc.returncode != 0: if proc.returncode != 0:
err = stderr.decode("utf-8", errors="replace")[:1000] err = stderr.decode("utf-8", errors="replace")[:1000]
raise RuntimeError(f"claude -p exited {proc.returncode}: {err}") raise RuntimeError(f"claude -p exited {proc.returncode}: {err}")
return stdout.decode("utf-8", errors="replace") result = stdout.decode("utf-8", errors="replace")
log.info("claude -p response: %d chars | preview: %s", len(result), result[:120].replace("\n", " "))
return result
def _build_prompt_from_messages(messages: list[dict]) -> str: def _build_prompt_from_messages(messages: list[dict]) -> tuple[str, list[Path]]:
"""Flatten OpenAI/Ollama-style messages into a single prompt string.""" """Flatten Ollama-style messages into a prompt string and extract any images.
Returns (prompt_string, list_of_temp_image_paths).
Image references are appended to the user turn that contained them so
claude's Read tool knows to load them as vision inputs.
"""
system_parts = [m["content"] for m in messages if m.get("role") == "system"] system_parts = [m["content"] for m in messages if m.get("role") == "system"]
convo: list[str] = [] convo: list[str] = []
all_image_paths: list[Path] = []
for m in messages: for m in messages:
role = m.get("role", "user") role = m.get("role", "user")
if role == "system": if role == "system":
continue continue
prefix = "User" if role == "user" else "Assistant" prefix = "User" if role == "user" else "Assistant"
convo.append(f"{prefix}: {m.get('content', '')}") content = m.get("content", "")
# Handle vision: save images and embed file references in the turn.
images_in_msg = m.get("images", [])
if images_in_msg:
paths = _save_images(images_in_msg)
all_image_paths.extend(paths)
refs = "\n".join(f"[image: {p}]" for p in paths)
content = f"{content}\n{refs}" if content else refs
convo.append(f"{prefix}: {content}")
convo.append("Assistant:") convo.append("Assistant:")
body = "\n\n".join(convo) body = "\n\n".join(convo)
if system_parts: if system_parts:
return "[System]\n" + "\n\n".join(system_parts) + "\n\n" + body prompt = "[System]\n" + "\n\n".join(system_parts) + "\n\n" + body
return body else:
prompt = body
return prompt, all_image_paths
# --- Streaming generators --------------------------------------------------- # --- Streaming generators ---------------------------------------------------
@@ -153,7 +267,7 @@ async def root() -> dict:
@app.get("/api/version") @app.get("/api/version")
async def version() -> dict: async def version() -> dict:
return {"version": "0.1.0-claude-proxy"} return {"version": "0.2.0-claude-proxy"}
@app.get("/api/tags") @app.get("/api/tags")
@@ -234,11 +348,11 @@ async def chat(req: Request) -> Any:
messages = body.get("messages", []) messages = body.get("messages", [])
stream = bool(body.get("stream", True)) stream = bool(body.get("stream", True))
prompt = _build_prompt_from_messages(messages) prompt, image_paths = _build_prompt_from_messages(messages)
started = time.time() started = time.time()
try: try:
text = await _run_claude(prompt) text = await _run_claude(prompt, image_paths or None)
except Exception as e: except Exception as e:
log.exception("claude invocation failed") log.exception("claude invocation failed")
return JSONResponse({"error": str(e)}, status_code=500) return JSONResponse({"error": str(e)}, status_code=500)
@@ -256,3 +370,185 @@ async def chat(req: Request) -> Any:
"done_reason": "stop", "done_reason": "stop",
"total_duration": int((time.time() - started) * 1e9), "total_duration": int((time.time() - started) * 1e9),
} }
# --- Vision (Garden Buddy Buddy Check) ---------------------------------------
def _media_type(data: bytes) -> str:
"""Detect image media type from magic bytes (default JPEG)."""
if data[:8] == b"\x89PNG\r\n\x1a\n":
return "image/png"
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "image/webp"
if data[:4] == b"GIF8":
return "image/gif"
return "image/jpeg"
def _haiku_cost_usd(usage: Any) -> float:
"""API-equivalent Haiku 4.5 cost. Informational only (subscription-billed)."""
return round(
getattr(usage, "input_tokens", 0) * HAIKU_IN_PER_MTOK / 1e6
+ getattr(usage, "output_tokens", 0) * HAIKU_OUT_PER_MTOK / 1e6
+ (getattr(usage, "cache_read_input_tokens", 0) or 0) * HAIKU_CACHE_READ_PER_MTOK / 1e6
+ (getattr(usage, "cache_creation_input_tokens", 0) or 0) * HAIKU_CACHE_WRITE_PER_MTOK / 1e6,
6,
)
async def _ollama_model_available(model: str) -> bool:
try:
async with httpx.AsyncClient(timeout=5) as cx:
r = await cx.get(f"{OLLAMA_URL}/api/tags")
r.raise_for_status()
names = [m.get("name", "") for m in r.json().get("models", [])]
return any(n == model or n.split(":")[0] == model.split(":")[0] for n in names)
except Exception:
return False
async def _vision_via_anthropic(
images: list[bytes], system_prompt: str, user_prompt: str, model: str
) -> dict:
"""Direct /v1/messages call using the Claude Code OAuth token."""
content: list[dict] = [
{
"type": "image",
"source": {
"type": "base64",
"media_type": _media_type(img),
"data": base64.standard_b64encode(img).decode("ascii"),
},
}
for img in images
]
content.append({"type": "text", "text": user_prompt})
client = AsyncAnthropic(
auth_token=OAUTH_TOKEN,
default_headers={"anthropic-beta": "oauth-2025-04-20"},
)
started = time.time()
try:
msg = await client.messages.create(
model=model,
max_tokens=4096,
system=system_prompt or "",
messages=[{"role": "user", "content": content}],
)
finally:
await client.close()
text = "".join(b.text for b in msg.content if b.type == "text")
return {
"content": text,
"backend": "anthropic",
"model": msg.model,
"duration_ms": int((time.time() - started) * 1000),
"prompt_eval_count": msg.usage.input_tokens,
"eval_count": msg.usage.output_tokens,
"cache_read_tokens": msg.usage.cache_read_input_tokens or 0,
"cache_creation_tokens": msg.usage.cache_creation_input_tokens or 0,
"cost_usd": _haiku_cost_usd(msg.usage),
}
async def _vision_via_ollama(
images: list[bytes], system_prompt: str, user_prompt: str, model: str
) -> dict:
"""Local Ollama vision call (only used when the model is installed)."""
payload = {
"model": model,
"stream": False,
"messages": [
{"role": "system", "content": system_prompt or ""},
{
"role": "user",
"content": user_prompt,
"images": [base64.standard_b64encode(i).decode("ascii") for i in images],
},
],
}
started = time.time()
async with httpx.AsyncClient(timeout=TIMEOUT_SECONDS) as cx:
r = await cx.post(f"{OLLAMA_URL}/api/chat", json=payload)
r.raise_for_status()
data = r.json()
return {
"content": data.get("message", {}).get("content", ""),
"backend": "ollama",
"model": data.get("model", model),
"duration_ms": int((time.time() - started) * 1000),
"prompt_eval_count": data.get("prompt_eval_count", 0),
"eval_count": data.get("eval_count", 0),
"cache_read_tokens": 0,
"cache_creation_tokens": 0,
"cost_usd": 0.0,
}
@app.post("/api/vision")
async def vision(req: Request) -> Any:
"""Photo diagnosis endpoint matching the gardenbuddy-buddy-check contract.
Request: multipart/form-data with file fields image, image2, image3 and
text fields prompt, system_prompt, backend (anthropic|ollama),
model. Bearer auth required when CLAUDE_PROXY_VISION_TOKEN set.
Response: 200 JSON {content, backend, model, duration_ms,
prompt_eval_count, eval_count, cache_read_tokens,
cache_creation_tokens, cost_usd}.
"""
if VISION_TOKEN:
auth = req.headers.get("authorization", "")
if auth != f"Bearer {VISION_TOKEN}":
return JSONResponse({"error": "unauthorized"}, status_code=401)
form = await req.form()
user_prompt = str(form.get("prompt", "") or "")
system_prompt = str(form.get("system_prompt", "") or "")
backend = str(form.get("backend", "anthropic") or "anthropic").lower()
model = str(form.get("model", "") or "")
images: list[bytes] = []
for key, value in form.multi_items():
if key.startswith("image") and hasattr(value, "read"):
data = await value.read()
if data:
images.append(data)
if not images:
return JSONResponse({"error": "at least one image file is required"}, status_code=400)
if len(images) > 3:
return JSONResponse({"error": "up to 3 images allowed"}, status_code=400)
if not user_prompt:
return JSONResponse({"error": "prompt is required"}, status_code=400)
# Ollama is only honoured when the requested model is actually installed;
# otherwise we fall back to Anthropic Haiku so a diagnosis never 404s on a
# missing local model. (qwen2.5vl was retired from this GPU on 2026-04-19.)
if backend == "ollama":
ollama_model = model or "qwen2.5vl:7b"
if await _ollama_model_available(ollama_model):
try:
async with _semaphore:
return await _vision_via_ollama(images, system_prompt, user_prompt, ollama_model)
except Exception:
log.exception("ollama vision failed, falling back to anthropic")
else:
log.warning("ollama model %s not installed; falling back to anthropic", ollama_model)
model = "" # discard the ollama model name for the fallback
anthropic_model = model if model.startswith("claude") else VISION_DEFAULT_MODEL
try:
async with _semaphore:
result = await _vision_via_anthropic(images, system_prompt, user_prompt, anthropic_model)
except Exception as e:
log.exception("anthropic vision call failed")
return JSONResponse({"error": f"vision backend error: {e}"}, status_code=502)
log.info(
"vision ok: backend=%s model=%s images=%d in=%d out=%d %dms",
result["backend"], result["model"], len(images),
result["prompt_eval_count"], result["eval_count"], result["duration_ms"],
)
return result