feat: add generative proxy with usage metrics
This commit is contained in:
commit
1f43b2d065
10 files changed
+1185
No files matched your search
@@ -0,0 +1,9 @@
|
|||||||
|
ADMIN_SECRET=replace-with-a-long-random-secret
|
||||||
|
DB_PATH=./data/proxy.db
|
||||||
|
MAX_UPLOAD_BYTES=52428800
|
||||||
|
MAX_IMAGES=8
|
||||||
|
HTTP_TIMEOUT_SECONDS=300
|
||||||
|
CONNECT_TIMEOUT_SECONDS=15
|
||||||
|
ALLOWED_ORIGINS=
|
||||||
|
GOOGLE_MODEL_NANO_BANANA=gemini-2.5-flash-image
|
||||||
|
OPENROUTER_MODEL_NANO_BANANA=google/gemini-2.5-flash-image
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
|
||||||
|
WORKDIR /app
|
||||||
|
COPY pyproject.toml ./
|
||||||
|
COPY app ./app
|
||||||
|
RUN pip install --no-cache-dir .
|
||||||
|
RUN useradd --create-home --uid 10001 appuser && mkdir -p /data && chown appuser:appuser /data
|
||||||
|
USER appuser
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1", "--timeout-keep-alive", "300"]
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# Project Design Requirements: phproxy
|
||||||
|
|
||||||
|
Status: initial implementation
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Provide a small single-host service that routes image-generation requests from installation clients to configured Google Gemini or OpenRouter API keys. It must tolerate long upstream response times, multiple reference images, client disconnects in polling mode, and upstream key/provider failures.
|
||||||
|
|
||||||
|
## Users and operating model
|
||||||
|
|
||||||
|
- Photobooth/installation clients submit a prompt, model, optional parameters, and zero or more images.
|
||||||
|
- An administrator provisions clients and manages access.
|
||||||
|
- A client may manage its own upstream keys when it has a client secret.
|
||||||
|
- The initial service runs as one Uvicorn process against a local SQLite database on persistent disk.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Functional
|
||||||
|
|
||||||
|
1. Accept multipart image-generation requests at `POST /v1/generate`.
|
||||||
|
2. Support direct long-hold responses and `async_mode=true` polling jobs.
|
||||||
|
3. Support Google native Gemini and OpenRouter adapters, including multiple uploaded images and normalized image/text outputs.
|
||||||
|
4. Resolve named model aliases from configuration and pass through explicit model IDs.
|
||||||
|
5. Store multiple provider keys per client and try keys by priority, preferring the requested provider first.
|
||||||
|
6. Retry another configured key/provider for rate-limit, server, and connection/timeout failures. Do not retry malformed or rejected requests as if a provider were unavailable.
|
||||||
|
7. Persist clients, provider keys, jobs, and job results in SQLite WAL mode.
|
||||||
|
8. Provide a minimal browser UI and admin/client key-management API.
|
||||||
|
9. Return upstream-reported usage and per-generation USD cost when the provider supplies it.
|
||||||
|
10. Record request intake, upstream, and total execution time, provider/model selection, failures, image count/bytes, and reported cost. Show recent records and aggregates in the admin UI.
|
||||||
|
|
||||||
|
### Non-functional and security
|
||||||
|
|
||||||
|
- Use async HTTP for all upstream network I/O and a shared bounded connection pool.
|
||||||
|
- Bound aggregate uploaded bytes and image count. Allow only configured raster image MIME types.
|
||||||
|
- Require an explicit administrator secret at startup; never ship a default secret.
|
||||||
|
- Store client secrets hashed. Store provider keys only because they are needed for upstream requests; restrict database file access and redact keys from API responses.
|
||||||
|
- Scope job reads and client key operations to the authenticated owning client.
|
||||||
|
- Never return upstream response bodies or internal exception traces to callers.
|
||||||
|
- Keep polling job ownership in the database. A process restart must not leave a job forever marked as running.
|
||||||
|
- Use a single app worker with local SQLite in this release. Horizontal scaling and durable background execution are out of scope.
|
||||||
|
|
||||||
|
## API contracts
|
||||||
|
|
||||||
|
### Generate
|
||||||
|
|
||||||
|
`POST /v1/generate` multipart fields:
|
||||||
|
|
||||||
|
- `client_id` (required)
|
||||||
|
- `prompt` (optional, defaults empty)
|
||||||
|
- `model` (optional, defaults `nano-banana`)
|
||||||
|
- `provider` (optional: `google` or `openrouter`)
|
||||||
|
- `params` (optional JSON object string)
|
||||||
|
- `images` (repeated image upload fields)
|
||||||
|
- `x-client-secret` header when a secret is configured for that client
|
||||||
|
|
||||||
|
Success is `200` with `provider_used`, `model_used`, `images`, `text`, elapsed time, and failover attempt count. With `?async_mode=true`, success is `200` with `job_id`, status, and poll URL.
|
||||||
|
|
||||||
|
### Poll
|
||||||
|
|
||||||
|
`GET /v1/jobs/{job_id}?client_id={client_id}` plus the client's secret header. Returns status and, when complete, result; terminal failures include a sanitized error. Unknown and cross-client job IDs return `404`.
|
||||||
|
|
||||||
|
### Management
|
||||||
|
|
||||||
|
- Admin routes use `x-admin-secret`.
|
||||||
|
- Key create/delete uses `auth-secret`, accepted only for the administrator or the owning client.
|
||||||
|
- Admin state returns only a short key prefix and never a full key.
|
||||||
|
|
||||||
|
Validation errors use FastAPI's `detail` format; upstream failures return `502` with sanitized attempt metadata.
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
- `clients`: client ID primary key, name, optional client-secret hash, activation flag, creation timestamp.
|
||||||
|
- `provider_keys`: owned by a client with cascading deletion; provider constrained to `google`/`openrouter`; key, priority, activation and failure metadata.
|
||||||
|
- `jobs`: owned by a client with cascading deletion; status, serialized request/result/error and timestamps. On startup, interrupted jobs are marked failed.
|
||||||
|
|
||||||
|
SQLite connections enable foreign keys and busy timeout; WAL is initialized once the DB opens. Provider-route and job-owner lookups are indexed. The DB and backups contain upstream credentials and potentially image data in queued job payloads/results, so filesystem and backup permissions are part of deployment security.
|
||||||
|
|
||||||
|
## Architecture and flow
|
||||||
|
|
||||||
|
1. FastAPI validates the multipart request and client authentication.
|
||||||
|
2. Uploaded images are bounded and encoded for the provider's JSON API.
|
||||||
|
3. A route loads the active client's provider keys from SQLite.
|
||||||
|
4. The adapter translates the request to Google or OpenRouter format and sends through shared async `httpx`.
|
||||||
|
5. A retryable upstream failure advances to the next configured key; success is normalized into a provider-neutral response.
|
||||||
|
6. Polling mode persists a queued job before scheduling the single-process async worker; state and results are persisted.
|
||||||
|
|
||||||
|
## Operational plan
|
||||||
|
|
||||||
|
1. Install Python dependencies and set a unique `ADMIN_SECRET`.
|
||||||
|
2. Mount a persistent local volume at the configured database path with restrictive permissions.
|
||||||
|
3. Run one Uvicorn worker. Put TLS and request-size enforcement at Nginx; keep port 8000 private.
|
||||||
|
4. Create clients, add provider credentials, then call `/healthz` and verify one small generation using the intended account/model.
|
||||||
|
5. Back up the database securely. Treat backups and job records as secrets and apply retention appropriate to the installation.
|
||||||
|
|
||||||
|
## Delivery plan
|
||||||
|
|
||||||
|
- [x] Define requirements, API, data model, constraints, and operational plan.
|
||||||
|
- [x] Add runnable FastAPI service, SQLite persistence, adapters, and admin UI/API.
|
||||||
|
- [x] Add local startup documentation and Docker/Nginx deployment files.
|
||||||
|
- [x] Add per-generation execution and cost history to the admin API/UI.
|
||||||
|
- [ ] Run deployment smoke checks with actual provider credentials and confirm account/model availability before production.
|
||||||
|
|
||||||
|
## Out of scope / known limits
|
||||||
|
|
||||||
|
- Jobs are persisted but are executed in-process; a service restart marks active jobs failed rather than resuming them.
|
||||||
|
- SQLite WAL assumes local storage and one app process. Multi-host operation needs a different DB and durable queue.
|
||||||
|
- Upstream model availability and provider-specific parameter support vary by account and change over time. Configure and validate model IDs per provider.
|
||||||
|
- The initial admin portal has no user/session system; protect it behind network access controls and HTTPS.
|
||||||
|
- Memory use grows with uploaded image size and serialized base64/job results; the service is asynchronous, not zero-copy or constant-memory.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# phproxy
|
||||||
|
|
||||||
|
An asynchronous proxy for image generation through Google Gemini or OpenRouter. It supports multipart uploads, key rotation, direct long-running responses, and SQLite-backed polling jobs.
|
||||||
|
|
||||||
|
## Start locally
|
||||||
|
|
||||||
|
Requirements: Python 3.12 or newer.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python -m venv .venv
|
||||||
|
. .venv/bin/activate
|
||||||
|
pip install -e .
|
||||||
|
cp .env.example .env
|
||||||
|
# Set ADMIN_SECRET to a unique random value before starting.
|
||||||
|
set -a && . ./.env && set +a
|
||||||
|
uvicorn app.main:app --host 127.0.0.1 --port 8000 --workers 1
|
||||||
|
```
|
||||||
|
|
||||||
|
The service refuses to start without `ADMIN_SECRET`. It creates the SQLite database and data directory at startup. Open `http://127.0.0.1:8000/ui` for basic administration. The first client and provider key can be created through the UI or API.
|
||||||
|
|
||||||
|
## Configure a client
|
||||||
|
|
||||||
|
Create a client as an administrator:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST http://127.0.0.1:8000/api/admin/client \
|
||||||
|
-H "x-admin-secret: $ADMIN_SECRET" \
|
||||||
|
-F client_id=booth-01 -F name='Booth 01' -F client_secret='choose-a-client-secret'
|
||||||
|
```
|
||||||
|
|
||||||
|
Add an upstream key (the client secret may also add its own keys):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST http://127.0.0.1:8000/api/keys \
|
||||||
|
-H "auth-secret: $ADMIN_SECRET" \
|
||||||
|
-F client_id=booth-01 -F provider=google -F api_key="$GOOGLE_API_KEY" -F priority=1
|
||||||
|
```
|
||||||
|
|
||||||
|
`ADMIN_SECRET` and client secrets are stored only as SHA-256 hashes for client authentication. Upstream provider keys must be retained to make requests, so they are stored in SQLite. Protect the database file and its backups as secrets.
|
||||||
|
|
||||||
|
## Generate
|
||||||
|
|
||||||
|
Direct mode holds the connection open until generation completes:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -X POST http://127.0.0.1:8000/v1/generate \
|
||||||
|
-H 'x-client-secret: choose-a-client-secret' \
|
||||||
|
-F client_id=booth-01 -F model=nano-banana \
|
||||||
|
-F prompt='Restyle the subject using the second image as a visual reference' \
|
||||||
|
-F params='{"aspect_ratio":"9:16","image_size":"2K"}' \
|
||||||
|
-F 'images=@capture.jpg;type=image/jpeg' -F 'images=@style.png;type=image/png'
|
||||||
|
```
|
||||||
|
|
||||||
|
Polling mode accepts the same multipart fields with `?async_mode=true`. It returns a `job_id`; poll the returned URL with `client_id` as a query parameter and the same `x-client-secret` header. Jobs and their results are stored in SQLite so they survive a client disconnect. Jobs still processing when the proxy process restarts are marked failed because the in-process worker cannot resume safely.
|
||||||
|
|
||||||
|
The built-in `nano-banana` alias defaults to Gemini 2.5 Flash Image. Set `GOOGLE_MODEL_NANO_BANANA` and `OPENROUTER_MODEL_NANO_BANANA` to provider model IDs you have confirmed are available for your account. Other model IDs are passed through (with `google/` prefix normalization for the native provider).
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ADMIN_SECRET` | required | Protects administration endpoints. Use a long random value. |
|
||||||
|
| `DB_PATH` | `./data/proxy.db` | SQLite database location. |
|
||||||
|
| `MAX_UPLOAD_BYTES` | `52428800` | Aggregate raw image upload limit. |
|
||||||
|
| `MAX_IMAGES` | `8` | Maximum reference image count. |
|
||||||
|
| `HTTP_TIMEOUT_SECONDS` | `300` | Overall upstream request timeout. |
|
||||||
|
| `CONNECT_TIMEOUT_SECONDS` | `15` | Upstream connect timeout. |
|
||||||
|
| `GOOGLE_MODEL_NANO_BANANA` | `gemini-2.5-flash-image` | Native Google alias target. |
|
||||||
|
| `OPENROUTER_MODEL_NANO_BANANA` | `google/gemini-2.5-flash-image` | OpenRouter alias target. |
|
||||||
|
|
||||||
|
Only one Uvicorn worker is supported for this initial implementation. SQLite WAL is intended for a local persistent disk, not a shared network filesystem. Configure Nginx body and proxy timeouts to match your deployment limits; see [`deploy/nginx.conf`](deploy/nginx.conf).
|
||||||
|
|
||||||
|
## API outline
|
||||||
|
|
||||||
|
See the copyable endpoint guide in [`docs/API.md`](docs/API.md).
|
||||||
|
|
||||||
|
- `POST /v1/generate` — multipart `client_id`, `prompt`, `model`, optional `provider`, JSON `params`, repeated `images`; optional `?async_mode=true`.
|
||||||
|
- `GET /v1/jobs/{job_id}?client_id=...` — job status/result, tenant-scoped.
|
||||||
|
- `GET /api/admin/state` — client and redacted key state (`x-admin-secret`).
|
||||||
|
- `POST /api/admin/client` — create/update a client (`x-admin-secret`).
|
||||||
|
- `POST /api/keys` and `DELETE /api/keys/{id}` — manage keys with `auth-secret` (admin or owning client secret).
|
||||||
|
- `GET /healthz` and `GET /ui`.
|
||||||
|
|
||||||
|
Errors use FastAPI's `{"detail": ...}` envelope. Provider failures return HTTP 502 with provider names and status codes only; upstream response bodies and credentials are not returned.
|
||||||
|
|
||||||
|
## Deploy with Docker
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker build -t phproxy .
|
||||||
|
docker run --name phproxy --restart unless-stopped \
|
||||||
|
-p 127.0.0.1:8000:8000 \
|
||||||
|
-e ADMIN_SECRET="$ADMIN_SECRET" \
|
||||||
|
-e DB_PATH=/data/proxy.db \
|
||||||
|
-v phproxy-data:/data \
|
||||||
|
phproxy
|
||||||
|
```
|
||||||
|
|
||||||
|
Terminate TLS at a trusted reverse proxy and keep the application port private. The sample Nginx configuration is a location snippet; add your certificate and domain configuration separately.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
+728
@@ -0,0 +1,728 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
import httpx
|
||||||
|
from fastapi import FastAPI, File, Form, Header, HTTPException, Query, Request, UploadFile
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
|
LOG = logging.getLogger("phproxy")
|
||||||
|
DB_PATH = os.getenv("DB_PATH", "./data/proxy.db")
|
||||||
|
ADMIN_SECRET = os.getenv("ADMIN_SECRET", "")
|
||||||
|
MAX_UPLOAD_BYTES = int(os.getenv("MAX_UPLOAD_BYTES", str(50 * 1024 * 1024)))
|
||||||
|
MAX_IMAGES = int(os.getenv("MAX_IMAGES", "8"))
|
||||||
|
HTTP_TIMEOUT_SECONDS = float(os.getenv("HTTP_TIMEOUT_SECONDS", "300"))
|
||||||
|
CONNECT_TIMEOUT_SECONDS = float(os.getenv("CONNECT_TIMEOUT_SECONDS", "15"))
|
||||||
|
MODEL_MAP = {
|
||||||
|
"nano-banana": {
|
||||||
|
"google": os.getenv("GOOGLE_MODEL_NANO_BANANA", "gemini-2.5-flash-image"),
|
||||||
|
"openrouter": os.getenv("OPENROUTER_MODEL_NANO_BANANA", "google/gemini-2.5-flash-image"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
|
||||||
|
http_client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def secret_hash(value: str) -> str:
|
||||||
|
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def secret_matches(value: str | None, stored_hash: str | None) -> bool:
|
||||||
|
return bool(value and stored_hash and hmac.compare_digest(secret_hash(value), stored_hash))
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def db():
|
||||||
|
conn = await aiosqlite.connect(DB_PATH, timeout=10)
|
||||||
|
conn.row_factory = aiosqlite.Row
|
||||||
|
await conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
await conn.execute("PRAGMA busy_timeout=10000")
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
finally:
|
||||||
|
await conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db() -> None:
|
||||||
|
os.makedirs(os.path.dirname(os.path.abspath(DB_PATH)), exist_ok=True)
|
||||||
|
async with db() as conn:
|
||||||
|
await conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await conn.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS clients (
|
||||||
|
client_id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
secret_hash TEXT,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
|
||||||
|
created_at REAL NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS provider_keys (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
client_id TEXT NOT NULL REFERENCES clients(client_id) ON DELETE CASCADE,
|
||||||
|
provider TEXT NOT NULL CHECK (provider IN ('google', 'openrouter')),
|
||||||
|
api_key TEXT NOT NULL,
|
||||||
|
priority INTEGER NOT NULL DEFAULT 1,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
|
||||||
|
fail_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error_at REAL,
|
||||||
|
created_at REAL NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS provider_keys_route_idx
|
||||||
|
ON provider_keys(client_id, is_active, priority, id);
|
||||||
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
|
job_id TEXT PRIMARY KEY,
|
||||||
|
client_id TEXT NOT NULL REFERENCES clients(client_id) ON DELETE CASCADE,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('queued', 'processing', 'completed', 'failed')),
|
||||||
|
request_json TEXT NOT NULL,
|
||||||
|
result_json TEXT,
|
||||||
|
error_json TEXT,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
updated_at REAL NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS jobs_client_created_idx ON jobs(client_id, created_at);
|
||||||
|
CREATE TABLE IF NOT EXISTS generation_runs (
|
||||||
|
generation_id TEXT PRIMARY KEY,
|
||||||
|
client_id TEXT NOT NULL REFERENCES clients(client_id) ON DELETE CASCADE,
|
||||||
|
job_id TEXT,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('queued', 'processing', 'completed', 'failed')),
|
||||||
|
requested_provider TEXT,
|
||||||
|
provider_used TEXT,
|
||||||
|
requested_model TEXT NOT NULL,
|
||||||
|
model_used TEXT,
|
||||||
|
image_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
upload_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
request_intake_sec REAL,
|
||||||
|
upstream_sec REAL,
|
||||||
|
total_sec REAL,
|
||||||
|
failover_attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
cost_usd REAL,
|
||||||
|
cost_source TEXT,
|
||||||
|
usage_json TEXT,
|
||||||
|
error_json TEXT,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
started_at REAL,
|
||||||
|
completed_at REAL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS generation_runs_created_idx
|
||||||
|
ON generation_runs(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS generation_runs_client_created_idx
|
||||||
|
ON generation_runs(client_id, created_at DESC);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE jobs SET status='failed', error_json=?, updated_at=? WHERE status IN ('queued','processing')",
|
||||||
|
(json.dumps({"message": "Proxy restarted while this job was running"}), time.time()),
|
||||||
|
)
|
||||||
|
now = time.time()
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE generation_runs SET status='failed',error_json=?,completed_at=?,"
|
||||||
|
"total_sec=MAX(0,?-created_at) WHERE status IN ('queued','processing')",
|
||||||
|
(json.dumps({"message": "Proxy restarted while this generation was running"}), now, now),
|
||||||
|
)
|
||||||
|
# Preserve basic timing from jobs created before execution history was added.
|
||||||
|
cur = await conn.execute(
|
||||||
|
"SELECT j.job_id,j.client_id,j.status,j.request_json,j.result_json,j.error_json,"
|
||||||
|
"j.created_at,j.updated_at FROM jobs j LEFT JOIN generation_runs r "
|
||||||
|
"ON r.generation_id=j.job_id WHERE r.generation_id IS NULL"
|
||||||
|
)
|
||||||
|
for row in await cur.fetchall():
|
||||||
|
try:
|
||||||
|
request_data = json.loads(row["request_json"])
|
||||||
|
result = json.loads(row["result_json"]) if row["result_json"] else {}
|
||||||
|
images = request_data.get("images", [])
|
||||||
|
upload_bytes = sum((len(image.get("b64", "")) * 3) // 4 for image in images)
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO generation_runs(generation_id,client_id,job_id,status,"
|
||||||
|
"requested_provider,provider_used,requested_model,model_used,image_count,upload_bytes,"
|
||||||
|
"upstream_sec,total_sec,failover_attempts,cost_usd,cost_source,error_json,created_at,"
|
||||||
|
"started_at,completed_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
(row["job_id"], row["client_id"], row["job_id"], row["status"],
|
||||||
|
request_data.get("preferred"), result.get("provider_used"),
|
||||||
|
request_data.get("model", "unknown"), result.get("model_used"), len(images), upload_bytes,
|
||||||
|
result.get("elapsed_sec"), max(0, row["updated_at"] - row["created_at"]),
|
||||||
|
result.get("failover_attempts", 0), result.get("cost_usd"), result.get("cost_source"),
|
||||||
|
row["error_json"], row["created_at"], row["created_at"], row["updated_at"]),
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError, json.JSONDecodeError):
|
||||||
|
LOG.exception("Could not backfill generation history for job %s", row["job_id"])
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_: FastAPI):
|
||||||
|
global http_client
|
||||||
|
if not ADMIN_SECRET:
|
||||||
|
raise RuntimeError("ADMIN_SECRET must be set before starting the service")
|
||||||
|
await init_db()
|
||||||
|
http_client = httpx.AsyncClient(
|
||||||
|
timeout=httpx.Timeout(HTTP_TIMEOUT_SECONDS, connect=CONNECT_TIMEOUT_SECONDS),
|
||||||
|
limits=httpx.Limits(max_keepalive_connections=50, max_connections=200),
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
yield
|
||||||
|
await http_client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Generative AI Proxy", version="0.1.0", lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def content_length_limit(request: Request, call_next):
|
||||||
|
request.state.received_monotonic = time.monotonic()
|
||||||
|
request.state.received_at = time.time()
|
||||||
|
content_length = request.headers.get("content-length")
|
||||||
|
if content_length:
|
||||||
|
try:
|
||||||
|
if int(content_length) > MAX_UPLOAD_BYTES + 1024 * 1024:
|
||||||
|
return JSONResponse(status_code=413, content={"detail": "Request body too large"})
|
||||||
|
except ValueError:
|
||||||
|
return JSONResponse(status_code=400, content={"detail": "Invalid Content-Length"})
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
|
||||||
|
from fastapi.responses import JSONResponse # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_model(model: str, provider: str) -> str:
|
||||||
|
if model in MODEL_MAP:
|
||||||
|
return MODEL_MAP[model][provider]
|
||||||
|
if provider == "google" and model.startswith("google/"):
|
||||||
|
return model.removeprefix("google/")
|
||||||
|
if provider == "openrouter" and "/" not in model:
|
||||||
|
return f"google/{model}"
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
def auth_admin(value: str | None) -> None:
|
||||||
|
if not value or not hmac.compare_digest(value, ADMIN_SECRET):
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
|
||||||
|
|
||||||
|
async def authorize_client(client_id: str, secret: str | None) -> None:
|
||||||
|
async with db() as conn:
|
||||||
|
cur = await conn.execute(
|
||||||
|
"SELECT secret_hash, is_active FROM clients WHERE client_id=?", (client_id,)
|
||||||
|
)
|
||||||
|
row = await cur.fetchone()
|
||||||
|
if not row or not row["is_active"]:
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid or inactive client")
|
||||||
|
if row["secret_hash"] and not secret_matches(secret, row["secret_hash"]):
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid client secret")
|
||||||
|
|
||||||
|
|
||||||
|
async def read_images(images: list[UploadFile]) -> tuple[list[dict[str, str]], int]:
|
||||||
|
if len(images) > MAX_IMAGES:
|
||||||
|
raise HTTPException(status_code=413, detail=f"At most {MAX_IMAGES} images are allowed")
|
||||||
|
total = 0
|
||||||
|
encoded: list[dict[str, str]] = []
|
||||||
|
for image in images:
|
||||||
|
mime = (image.content_type or "").lower()
|
||||||
|
if mime not in ALLOWED_IMAGE_TYPES:
|
||||||
|
raise HTTPException(status_code=415, detail=f"Unsupported image type: {mime or 'unknown'}")
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
while chunk := await image.read(1024 * 1024):
|
||||||
|
total += len(chunk)
|
||||||
|
if total > MAX_UPLOAD_BYTES:
|
||||||
|
raise HTTPException(status_code=413, detail="Uploaded images exceed configured limit")
|
||||||
|
chunks.append(chunk)
|
||||||
|
raw = b"".join(chunks)
|
||||||
|
if raw:
|
||||||
|
encoded.append({"mime_type": mime, "b64": base64.b64encode(raw).decode("ascii")})
|
||||||
|
await image.close()
|
||||||
|
return encoded, total
|
||||||
|
|
||||||
|
|
||||||
|
class UpstreamError(Exception):
|
||||||
|
def __init__(self, provider: str, status_code: int | None, retryable: bool):
|
||||||
|
self.provider = provider
|
||||||
|
self.status_code = status_code
|
||||||
|
self.retryable = retryable
|
||||||
|
|
||||||
|
|
||||||
|
async def post_upstream(provider: str, api_key: str, model: str, prompt: str,
|
||||||
|
images: list[dict[str, str]], params: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
assert http_client is not None
|
||||||
|
resolved = resolve_model(model, provider)
|
||||||
|
try:
|
||||||
|
if provider == "google":
|
||||||
|
parts: list[dict[str, Any]] = [
|
||||||
|
{"inlineData": {"mimeType": image["mime_type"], "data": image["b64"]}}
|
||||||
|
for image in images
|
||||||
|
]
|
||||||
|
if prompt:
|
||||||
|
parts.append({"text": prompt})
|
||||||
|
config = dict(params.get("generationConfig", {}))
|
||||||
|
config.setdefault("responseModalities", params.get("responseModalities", ["TEXT", "IMAGE"]))
|
||||||
|
for source, target in (("temperature", "temperature"), ("topP", "topP"), ("seed", "seed")):
|
||||||
|
if source in params:
|
||||||
|
config[target] = params[source]
|
||||||
|
image_config = {}
|
||||||
|
if "aspect_ratio" in params:
|
||||||
|
image_config["aspectRatio"] = params["aspect_ratio"]
|
||||||
|
if "image_size" in params:
|
||||||
|
image_config["imageSize"] = params["image_size"]
|
||||||
|
if image_config:
|
||||||
|
config["imageConfig"] = image_config
|
||||||
|
if isinstance(params.get("thinking"), dict):
|
||||||
|
thinking = params["thinking"]
|
||||||
|
config["thinkingConfig"] = {
|
||||||
|
k: v for source, k in (("budget_tokens", "thinkingBudget"),
|
||||||
|
("include_thoughts", "includeThoughts"))
|
||||||
|
if (v := thinking.get(source)) is not None
|
||||||
|
}
|
||||||
|
payload: dict[str, Any] = {"contents": [{"role": "user", "parts": parts}],
|
||||||
|
"generationConfig": config}
|
||||||
|
if "safetySettings" in params:
|
||||||
|
payload["safetySettings"] = params["safetySettings"]
|
||||||
|
response = await http_client.post(
|
||||||
|
f"https://generativelanguage.googleapis.com/v1beta/models/{resolved}:generateContent",
|
||||||
|
params={"key": api_key}, json=payload,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
blocks: list[dict[str, Any]] = []
|
||||||
|
if prompt:
|
||||||
|
blocks.append({"type": "text", "text": prompt})
|
||||||
|
for image in images:
|
||||||
|
blocks.append({"type": "image_url", "image_url": {
|
||||||
|
"url": f"data:{image['mime_type']};base64,{image['b64']}"}})
|
||||||
|
payload = {"model": resolved, "messages": [{"role": "user", "content": blocks}],
|
||||||
|
"modalities": params.get("modalities", ["image", "text"])}
|
||||||
|
for key in ("temperature", "seed"):
|
||||||
|
if key in params:
|
||||||
|
payload[key] = params[key]
|
||||||
|
reasoning = params.get("reasoning")
|
||||||
|
if isinstance(reasoning, dict):
|
||||||
|
# OpenRouter expects an object here; boolean flags are invalid values.
|
||||||
|
payload["reasoning"] = reasoning
|
||||||
|
image_config = {k: params[source] for source, k in
|
||||||
|
(("aspect_ratio", "aspect_ratio"), ("image_size", "image_size"))
|
||||||
|
if source in params}
|
||||||
|
if image_config:
|
||||||
|
payload["image_config"] = image_config
|
||||||
|
if isinstance(params.get("extra_body"), dict):
|
||||||
|
payload.update(params["extra_body"])
|
||||||
|
# OpenRouter returns its billed amount in usage.cost when usage is requested.
|
||||||
|
payload.setdefault("usage", {"include": True})
|
||||||
|
response = await http_client.post(
|
||||||
|
"https://openrouter.ai/api/v1/chat/completions",
|
||||||
|
headers={"Authorization": f"Bearer {api_key}",
|
||||||
|
"X-OpenRouter-Metadata": "enabled"}, json=payload,
|
||||||
|
)
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
raise UpstreamError(provider, None, True) from exc
|
||||||
|
except httpx.RequestError as exc:
|
||||||
|
raise UpstreamError(provider, None, True) from exc
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise UpstreamError(provider, response.status_code,
|
||||||
|
response.status_code == 429 or response.status_code >= 500)
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
result_images: list[dict[str, str]] = []
|
||||||
|
texts: list[str] = []
|
||||||
|
if provider == "google":
|
||||||
|
for candidate in data.get("candidates", []):
|
||||||
|
for part in candidate.get("content", {}).get("parts", []):
|
||||||
|
if "inlineData" in part:
|
||||||
|
inline = part["inlineData"]
|
||||||
|
result_images.append({"mime_type": inline.get("mimeType", "image/png"),
|
||||||
|
"b64_json": inline["data"]})
|
||||||
|
elif part.get("text") and not part.get("thought"):
|
||||||
|
texts.append(part["text"])
|
||||||
|
else:
|
||||||
|
message = data.get("choices", [{}])[0].get("message", {})
|
||||||
|
content = message.get("content") or ""
|
||||||
|
texts.append(content if isinstance(content, str) else "")
|
||||||
|
for item in message.get("images", []):
|
||||||
|
url = item.get("image_url", {}).get("url", "")
|
||||||
|
if url.startswith("data:"):
|
||||||
|
header, b64_data = url.split(",", 1)
|
||||||
|
result_images.append({"mime_type": header[5:].split(";")[0],
|
||||||
|
"b64_json": b64_data})
|
||||||
|
elif url:
|
||||||
|
result_images.append({"mime_type": "image/png", "url": url})
|
||||||
|
text = "\n".join(texts)
|
||||||
|
if not result_images and not text:
|
||||||
|
raise ValueError("empty content")
|
||||||
|
if provider == "openrouter":
|
||||||
|
usage = data.get("usage")
|
||||||
|
raw_cost = usage.get("cost") if isinstance(usage, dict) else None
|
||||||
|
cost_usd = (float(raw_cost) if isinstance(raw_cost, (int, float))
|
||||||
|
and not isinstance(raw_cost, bool) else None)
|
||||||
|
cost_source = "openrouter_usage" if cost_usd is not None else "unavailable"
|
||||||
|
upstream_request_id = data.get("id")
|
||||||
|
else:
|
||||||
|
usage = data.get("usageMetadata")
|
||||||
|
cost_usd = None
|
||||||
|
cost_source = "unavailable"
|
||||||
|
upstream_request_id = None
|
||||||
|
result = {"provider_used": provider, "model_used": data.get("model", resolved),
|
||||||
|
"images": result_images, "text": text,
|
||||||
|
"cost_usd": cost_usd, "cost_source": cost_source}
|
||||||
|
if isinstance(usage, dict):
|
||||||
|
result["usage"] = usage
|
||||||
|
if upstream_request_id:
|
||||||
|
result["upstream_request_id"] = upstream_request_id
|
||||||
|
if provider == "openrouter" and isinstance(data.get("openrouter_metadata"), dict):
|
||||||
|
result["upstream_metadata"] = data["openrouter_metadata"]
|
||||||
|
return result
|
||||||
|
except (ValueError, KeyError, IndexError, TypeError) as exc:
|
||||||
|
raise UpstreamError(provider, 502, False) from exc
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_with_failover(client_id: str, preferred: str | None, model: str,
|
||||||
|
prompt: str, images: list[dict[str, str]],
|
||||||
|
params: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
async with db() as conn:
|
||||||
|
cur = await conn.execute("SELECT is_active FROM clients WHERE client_id=?", (client_id,))
|
||||||
|
client = await cur.fetchone()
|
||||||
|
if not client or not client["is_active"]:
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid or inactive client")
|
||||||
|
cur = await conn.execute(
|
||||||
|
"SELECT id, provider, api_key FROM provider_keys WHERE client_id=? AND is_active=1 "
|
||||||
|
"ORDER BY priority, id", (client_id,))
|
||||||
|
keys = await cur.fetchall()
|
||||||
|
if not keys:
|
||||||
|
raise HTTPException(status_code=400, detail="No active provider keys configured")
|
||||||
|
keys = sorted(keys, key=lambda key: (0 if preferred and key["provider"] == preferred else 1))
|
||||||
|
errors = []
|
||||||
|
for key in keys:
|
||||||
|
started = time.monotonic()
|
||||||
|
try:
|
||||||
|
result = await post_upstream(key["provider"], key["api_key"], model, prompt, images, params)
|
||||||
|
result["elapsed_sec"] = round(time.monotonic() - started, 2)
|
||||||
|
result["failover_attempts"] = len(errors)
|
||||||
|
return result
|
||||||
|
except UpstreamError as exc:
|
||||||
|
errors.append({"provider": key["provider"], "status_code": exc.status_code})
|
||||||
|
async with db() as conn:
|
||||||
|
await conn.execute("UPDATE provider_keys SET fail_count=fail_count+1,last_error_at=? WHERE id=?",
|
||||||
|
(time.time(), key["id"]))
|
||||||
|
await conn.commit()
|
||||||
|
if not exc.retryable:
|
||||||
|
break
|
||||||
|
raise HTTPException(status_code=502, detail={"message": "Upstream generation failed", "attempts": errors})
|
||||||
|
|
||||||
|
|
||||||
|
async def create_generation_run(generation_id: str, client_id: str, job_id: str | None,
|
||||||
|
request_data: dict[str, Any], status: str, image_count: int,
|
||||||
|
upload_bytes: int, request_intake_sec: float,
|
||||||
|
received_at: float) -> None:
|
||||||
|
async with db() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO generation_runs(generation_id,client_id,job_id,status,requested_provider,"
|
||||||
|
"requested_model,image_count,upload_bytes,request_intake_sec,created_at,started_at) "
|
||||||
|
"VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
(generation_id, client_id, job_id, status, request_data.get("preferred"),
|
||||||
|
request_data["model"], image_count, upload_bytes, request_intake_sec,
|
||||||
|
received_at, time.time() if status == "processing" else None),
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def finish_generation_run(generation_id: str, status: str, completed_at: float,
|
||||||
|
total_sec: float, result: dict[str, Any] | None = None,
|
||||||
|
error: Any = None) -> None:
|
||||||
|
result = result or {}
|
||||||
|
async with db() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"UPDATE generation_runs SET status=?,provider_used=?,model_used=?,upstream_sec=?,"
|
||||||
|
"total_sec=?,failover_attempts=?,cost_usd=?,cost_source=?,usage_json=?,error_json=?,"
|
||||||
|
"completed_at=? WHERE generation_id=?",
|
||||||
|
(status, result.get("provider_used"), result.get("model_used"), result.get("elapsed_sec"),
|
||||||
|
total_sec, result.get("failover_attempts", 0), result.get("cost_usd"),
|
||||||
|
result.get("cost_source"), json.dumps(result.get("usage")) if result.get("usage") is not None else None,
|
||||||
|
json.dumps(error) if error is not None else None, completed_at, generation_id),
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_job(job_id: str, client_id: str, request_data: dict[str, Any],
|
||||||
|
run_metadata: dict[str, Any]) -> None:
|
||||||
|
started_at = time.time()
|
||||||
|
async with db() as conn:
|
||||||
|
await conn.execute("UPDATE jobs SET status='processing',updated_at=? WHERE job_id=?",
|
||||||
|
(started_at, job_id))
|
||||||
|
await conn.execute("UPDATE generation_runs SET status='processing',started_at=? WHERE generation_id=?",
|
||||||
|
(started_at, job_id))
|
||||||
|
await conn.commit()
|
||||||
|
try:
|
||||||
|
result = await execute_with_failover(**request_data)
|
||||||
|
completed_at = time.time()
|
||||||
|
result.update({"generation_id": job_id,
|
||||||
|
"request_intake_sec": run_metadata["request_intake_sec"],
|
||||||
|
"total_sec": round(completed_at - run_metadata["received_at"], 2)})
|
||||||
|
async with db() as conn:
|
||||||
|
await conn.execute("UPDATE jobs SET status='completed',result_json=?,updated_at=? WHERE job_id=?",
|
||||||
|
(json.dumps(result), completed_at, job_id))
|
||||||
|
await conn.commit()
|
||||||
|
await finish_generation_run(job_id, "completed", completed_at,
|
||||||
|
result["total_sec"], result=result)
|
||||||
|
except HTTPException as exc:
|
||||||
|
error = exc.detail
|
||||||
|
completed_at = time.time()
|
||||||
|
async with db() as conn:
|
||||||
|
await conn.execute("UPDATE jobs SET status='failed',error_json=?,updated_at=? WHERE job_id=?",
|
||||||
|
(json.dumps(error), completed_at, job_id))
|
||||||
|
await conn.commit()
|
||||||
|
await finish_generation_run(job_id, "failed", completed_at,
|
||||||
|
round(completed_at - run_metadata["received_at"], 2), error=error)
|
||||||
|
except Exception:
|
||||||
|
LOG.exception("Unexpected async job failure: %s", job_id)
|
||||||
|
completed_at = time.time()
|
||||||
|
error = {"message": "Internal generation error"}
|
||||||
|
async with db() as conn:
|
||||||
|
await conn.execute("UPDATE jobs SET status='failed',error_json=?,updated_at=? WHERE job_id=?",
|
||||||
|
(json.dumps(error), completed_at, job_id))
|
||||||
|
await conn.commit()
|
||||||
|
await finish_generation_run(job_id, "failed", completed_at,
|
||||||
|
round(completed_at - run_metadata["received_at"], 2), error=error)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v1/generate")
|
||||||
|
async def generate(request: Request, client_id: str = Form(...), prompt: str = Form(""),
|
||||||
|
model: str = Form("nano-banana"), provider: str | None = Form(None),
|
||||||
|
params: str = Form("{}"), async_mode: bool = Query(False),
|
||||||
|
images: list[UploadFile] = File(default=[]),
|
||||||
|
x_client_secret: str | None = Header(None)):
|
||||||
|
await authorize_client(client_id, x_client_secret)
|
||||||
|
if provider and provider.lower() not in {"google", "openrouter"}:
|
||||||
|
raise HTTPException(status_code=400, detail="provider must be google or openrouter")
|
||||||
|
try:
|
||||||
|
parsed_params = json.loads(params or "{}")
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="params must be valid JSON") from exc
|
||||||
|
if not isinstance(parsed_params, dict):
|
||||||
|
raise HTTPException(status_code=400, detail="params must be a JSON object")
|
||||||
|
encoded, upload_bytes = await read_images(images)
|
||||||
|
request_data = {"client_id": client_id, "preferred": provider.lower() if provider else None,
|
||||||
|
"model": model, "prompt": prompt, "images": encoded, "params": parsed_params}
|
||||||
|
generation_id = str(uuid.uuid4())
|
||||||
|
received_at = request.state.received_at
|
||||||
|
request_intake_sec = round(time.monotonic() - request.state.received_monotonic, 2)
|
||||||
|
if not async_mode:
|
||||||
|
await create_generation_run(generation_id, client_id, None, request_data, "processing",
|
||||||
|
len(encoded), upload_bytes, request_intake_sec, received_at)
|
||||||
|
try:
|
||||||
|
result = await execute_with_failover(**request_data)
|
||||||
|
completed_at = time.time()
|
||||||
|
result.update({"generation_id": generation_id,
|
||||||
|
"request_intake_sec": request_intake_sec,
|
||||||
|
"total_sec": round(completed_at - received_at, 2)})
|
||||||
|
await finish_generation_run(generation_id, "completed", completed_at,
|
||||||
|
result["total_sec"], result=result)
|
||||||
|
return result
|
||||||
|
except HTTPException as exc:
|
||||||
|
completed_at = time.time()
|
||||||
|
await finish_generation_run(generation_id, "failed", completed_at,
|
||||||
|
round(completed_at - received_at, 2), error=exc.detail)
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
LOG.exception("Unexpected generation failure: %s", generation_id)
|
||||||
|
completed_at = time.time()
|
||||||
|
error = {"message": "Internal generation error"}
|
||||||
|
await finish_generation_run(generation_id, "failed", completed_at,
|
||||||
|
round(completed_at - received_at, 2), error=error)
|
||||||
|
raise HTTPException(status_code=500, detail=error)
|
||||||
|
job_id = generation_id
|
||||||
|
now = time.time()
|
||||||
|
async with db() as conn:
|
||||||
|
await conn.execute("INSERT INTO jobs(job_id,client_id,status,request_json,created_at,updated_at) "
|
||||||
|
"VALUES(?,?, 'queued', ?, ?, ?)",
|
||||||
|
(job_id, client_id, json.dumps(request_data), now, now))
|
||||||
|
await conn.execute(
|
||||||
|
"INSERT INTO generation_runs(generation_id,client_id,job_id,status,requested_provider,"
|
||||||
|
"requested_model,image_count,upload_bytes,request_intake_sec,created_at) "
|
||||||
|
"VALUES(?,?,?,'queued',?,?,?,?,?,?)",
|
||||||
|
(job_id, client_id, job_id, request_data.get("preferred"), model, len(encoded),
|
||||||
|
upload_bytes, request_intake_sec, received_at),
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
asyncio.create_task(run_job(job_id, client_id, request_data,
|
||||||
|
{"received_at": received_at,
|
||||||
|
"request_intake_sec": request_intake_sec}))
|
||||||
|
return {"job_id": job_id, "status": "queued", "poll_url": f"/v1/jobs/{job_id}"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v1/jobs/{job_id}")
|
||||||
|
async def get_job(job_id: str, client_id: str = Query(...),
|
||||||
|
x_client_secret: str | None = Header(None)):
|
||||||
|
await authorize_client(client_id, x_client_secret)
|
||||||
|
async with db() as conn:
|
||||||
|
cur = await conn.execute("SELECT * FROM jobs WHERE job_id=? AND client_id=?", (job_id, client_id))
|
||||||
|
row = await cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Job not found")
|
||||||
|
response: dict[str, Any] = {"job_id": job_id, "status": row["status"],
|
||||||
|
"created_at": row["created_at"], "updated_at": row["updated_at"]}
|
||||||
|
if row["result_json"]:
|
||||||
|
response["result"] = json.loads(row["result_json"])
|
||||||
|
if row["error_json"]:
|
||||||
|
response["error"] = json.loads(row["error_json"])
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/admin/state")
|
||||||
|
async def admin_state(x_admin_secret: str | None = Header(None)):
|
||||||
|
auth_admin(x_admin_secret)
|
||||||
|
async with db() as conn:
|
||||||
|
cur = await conn.execute("SELECT client_id,name,is_active,created_at FROM clients ORDER BY client_id")
|
||||||
|
clients = [dict(row) for row in await cur.fetchall()]
|
||||||
|
cur = await conn.execute("SELECT id,client_id,provider,substr(api_key,1,7)||'...' key_preview,"
|
||||||
|
"priority,is_active,fail_count FROM provider_keys ORDER BY client_id,priority,id")
|
||||||
|
keys = [dict(row) for row in await cur.fetchall()]
|
||||||
|
return {"clients": clients, "keys": keys}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/admin/generations")
|
||||||
|
async def admin_generations(limit: int = Query(50, ge=1, le=200),
|
||||||
|
x_admin_secret: str | None = Header(None)):
|
||||||
|
auth_admin(x_admin_secret)
|
||||||
|
async with db() as conn:
|
||||||
|
cur = await conn.execute(
|
||||||
|
"SELECT COUNT(*) AS total,"
|
||||||
|
"SUM(CASE WHEN status='completed' THEN 1 ELSE 0 END) AS completed,"
|
||||||
|
"SUM(CASE WHEN status='failed' THEN 1 ELSE 0 END) AS failed,"
|
||||||
|
"COUNT(cost_usd) FILTER (WHERE status='completed') AS priced,"
|
||||||
|
"COALESCE(SUM(cost_usd) FILTER (WHERE status='completed'),0) AS total_cost_usd,"
|
||||||
|
"AVG(total_sec) FILTER (WHERE status='completed') AS avg_total_sec,"
|
||||||
|
"AVG(upstream_sec) FILTER (WHERE status='completed') AS avg_upstream_sec "
|
||||||
|
"FROM generation_runs"
|
||||||
|
)
|
||||||
|
summary = dict(await cur.fetchone())
|
||||||
|
cur = await conn.execute(
|
||||||
|
"SELECT client_id,COUNT(*) AS total,"
|
||||||
|
"SUM(CASE WHEN status='completed' THEN 1 ELSE 0 END) AS completed,"
|
||||||
|
"SUM(CASE WHEN status='failed' THEN 1 ELSE 0 END) AS failed,"
|
||||||
|
"COUNT(cost_usd) FILTER (WHERE status='completed') AS priced,"
|
||||||
|
"COALESCE(SUM(cost_usd) FILTER (WHERE status='completed'),0) AS total_cost_usd,"
|
||||||
|
"AVG(total_sec) FILTER (WHERE status='completed') AS avg_total_sec "
|
||||||
|
"FROM generation_runs GROUP BY client_id ORDER BY total_cost_usd DESC,client_id"
|
||||||
|
)
|
||||||
|
by_client = [dict(row) for row in await cur.fetchall()]
|
||||||
|
cur = await conn.execute(
|
||||||
|
"SELECT generation_id,client_id,job_id,status,requested_provider,provider_used,"
|
||||||
|
"requested_model,model_used,image_count,upload_bytes,request_intake_sec,upstream_sec,"
|
||||||
|
"total_sec,failover_attempts,cost_usd,cost_source,error_json,created_at,completed_at "
|
||||||
|
"FROM generation_runs ORDER BY created_at DESC LIMIT ?", (limit,)
|
||||||
|
)
|
||||||
|
generations = []
|
||||||
|
for row in await cur.fetchall():
|
||||||
|
generation = dict(row)
|
||||||
|
if generation["error_json"]:
|
||||||
|
try:
|
||||||
|
generation["error"] = json.loads(generation.pop("error_json"))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
generation["error"] = {"message": "Generation failed"}
|
||||||
|
generation.pop("error_json", None)
|
||||||
|
else:
|
||||||
|
generation.pop("error_json", None)
|
||||||
|
generations.append(generation)
|
||||||
|
return {"summary": summary, "by_client": by_client, "generations": generations}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/admin/client")
|
||||||
|
async def add_client(client_id: str = Form(...), name: str = Form(...),
|
||||||
|
client_secret: str = Form(""), x_admin_secret: str | None = Header(None)):
|
||||||
|
auth_admin(x_admin_secret)
|
||||||
|
if not client_id.strip() or len(client_id) > 100 or not name.strip():
|
||||||
|
raise HTTPException(status_code=400, detail="client_id and name are required")
|
||||||
|
hashed = secret_hash(client_secret) if client_secret else None
|
||||||
|
async with db() as conn:
|
||||||
|
await conn.execute("INSERT INTO clients(client_id,name,secret_hash,created_at) VALUES(?,?,?,?) "
|
||||||
|
"ON CONFLICT(client_id) DO UPDATE SET name=excluded.name,secret_hash=excluded.secret_hash",
|
||||||
|
(client_id.strip(), name.strip(), hashed, time.time()))
|
||||||
|
await conn.commit()
|
||||||
|
return {"status": "ok", "client_id": client_id.strip()}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/keys")
|
||||||
|
async def add_key(client_id: str = Form(...), provider: str = Form(...), api_key: str = Form(...),
|
||||||
|
priority: int = Form(1), auth_secret: str | None = Header(None)):
|
||||||
|
provider = provider.strip().lower()
|
||||||
|
if provider not in {"google", "openrouter"} or not api_key.strip():
|
||||||
|
raise HTTPException(status_code=400, detail="A supported provider and API key are required")
|
||||||
|
if priority < 1 or priority > 1000:
|
||||||
|
raise HTTPException(status_code=400, detail="priority must be between 1 and 1000")
|
||||||
|
auth_admin(auth_secret) if auth_secret and hmac.compare_digest(auth_secret, ADMIN_SECRET) else None
|
||||||
|
async with db() as conn:
|
||||||
|
cur = await conn.execute("SELECT secret_hash FROM clients WHERE client_id=?", (client_id,))
|
||||||
|
client = await cur.fetchone()
|
||||||
|
if not client:
|
||||||
|
raise HTTPException(status_code=404, detail="Client not found")
|
||||||
|
if not (auth_secret and hmac.compare_digest(auth_secret, ADMIN_SECRET)) and not secret_matches(auth_secret, client["secret_hash"]):
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
await conn.execute("INSERT INTO provider_keys(client_id,provider,api_key,priority,created_at) VALUES(?,?,?,?,?)",
|
||||||
|
(client_id, provider, api_key.strip(), priority, time.time()))
|
||||||
|
await conn.commit()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/keys/{key_id}")
|
||||||
|
async def delete_key(key_id: int, auth_secret: str | None = Header(None)):
|
||||||
|
async with db() as conn:
|
||||||
|
cur = await conn.execute("SELECT k.client_id,c.secret_hash FROM provider_keys k JOIN clients c "
|
||||||
|
"ON c.client_id=k.client_id WHERE k.id=?", (key_id,))
|
||||||
|
row = await cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail="Key not found")
|
||||||
|
if not (auth_secret and hmac.compare_digest(auth_secret, ADMIN_SECRET)) and not secret_matches(auth_secret, row["secret_hash"]):
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
await conn.execute("DELETE FROM provider_keys WHERE id=?", (key_id,))
|
||||||
|
await conn.commit()
|
||||||
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/healthz")
|
||||||
|
async def healthz():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/ui", response_class=HTMLResponse)
|
||||||
|
async def portal_ui():
|
||||||
|
return """<!doctype html><html><head><meta charset=utf-8><meta name=viewport content='width=device-width'>
|
||||||
|
<title>Proxy administration</title><style>
|
||||||
|
body{font:15px system-ui;max-width:1280px;margin:2rem auto;padding:0 1rem;background:#111;color:#eee}
|
||||||
|
input,select,button{padding:.6rem;margin:.25rem;background:#222;color:#eee;border:1px solid #555;border-radius:4px}
|
||||||
|
button{cursor:pointer;background:#2563eb}form{margin:1rem 0}.panel{background:#1b1b1b;border:1px solid #333;border-radius:8px;padding:1rem;margin:1rem 0}
|
||||||
|
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:.75rem}.card{background:#222;padding:.8rem;border-radius:6px}
|
||||||
|
.value{font-size:1.3rem;font-weight:700}.muted{color:#aaa;font-size:.85rem}.table-wrap{overflow:auto}table{border-collapse:collapse;width:100%;font-size:.88rem}
|
||||||
|
th,td{text-align:left;padding:.55rem;border-bottom:1px solid #393939;vertical-align:top}th{position:sticky;top:0;background:#202020}
|
||||||
|
.ok{color:#70d89b}.bad{color:#ff8585}pre{white-space:pre-wrap;overflow-wrap:anywhere}
|
||||||
|
</style></head><body>
|
||||||
|
<h1>Generative proxy</h1><p class=muted>Admin secret is sent only to same-origin management APIs.</p>
|
||||||
|
<section class=panel><label>Admin secret <input id=s type=password autocomplete=current-password></label><button onclick=load()>Load / refresh</button></section>
|
||||||
|
<section class=panel><h2>Execution overview</h2><div id=summary class=cards></div><h3>By client</h3><div id=by-client></div></section>
|
||||||
|
<section class=panel><h2>Recent generations</h2><div class=table-wrap id=history></div></section>
|
||||||
|
<section class=panel><h2>Clients and provider keys</h2><pre id=o></pre></section>
|
||||||
|
<section class=panel><h2>Create or update client</h2><form id=c><input name=client_id placeholder='Client ID' required><input name=name placeholder=Name required><input name=client_secret placeholder='Optional client secret'><button>Create client</button></form>
|
||||||
|
<h2>Add provider key</h2><form id=k><input name=client_id placeholder='Client ID' required><select name=provider><option>google</option><option>openrouter</option></select><input name=api_key placeholder='Provider API key' required><input name=priority type=number value=1 min=1><button>Add key</button></form></section>
|
||||||
|
<script>
|
||||||
|
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
const sec=v=>v==null?'—':Number(v).toFixed(2)+' s';
|
||||||
|
const money=v=>v==null?'—':'$'+Number(v).toFixed(6);
|
||||||
|
async function api(url,opts={}){opts.headers={...(opts.headers||{}),'x-admin-secret':document.querySelector('#s').value,'auth-secret':document.querySelector('#s').value};return fetch(url,opts)}
|
||||||
|
function table(headers,rows){return '<table><thead><tr>'+headers.map(x=>'<th>'+esc(x)+'</th>').join('')+'</tr></thead><tbody>'+rows.join('')+'</tbody></table>'}
|
||||||
|
async function load(){
|
||||||
|
const [sr,gr]=await Promise.all([api('/api/admin/state'),api('/api/admin/generations?limit=100')]);
|
||||||
|
const state=await sr.json();const stats=await gr.json();
|
||||||
|
if(!sr.ok||!gr.ok){document.querySelector('#o').textContent=JSON.stringify(state.detail||stats.detail||state||stats);return}
|
||||||
|
document.querySelector('#o').textContent=JSON.stringify(state,null,2);
|
||||||
|
const s=stats.summary;
|
||||||
|
document.querySelector('#summary').innerHTML=[['Generations',s.total],['Completed',s.completed||0],['Failed',s.failed||0],['Successful cost',money(s.total_cost_usd)],['Priced successes',s.priced],['Avg total (success)',sec(s.avg_total_sec)],['Avg upstream (success)',sec(s.avg_upstream_sec)]].map(([k,v])=>'<div class=card><div class=muted>'+esc(k)+'</div><div class=value>'+esc(v)+'</div></div>').join('');
|
||||||
|
document.querySelector('#by-client').innerHTML=table(['Client','Runs','Completed','Failed','Successful cost','Priced successes','Avg total (success)'],stats.by_client.map(x=>'<tr><td>'+esc(x.client_id)+'</td><td>'+esc(x.total)+'</td><td>'+esc(x.completed||0)+'</td><td>'+esc(x.failed||0)+'</td><td>'+esc(money(x.total_cost_usd))+'</td><td>'+esc(x.priced)+'</td><td>'+esc(sec(x.avg_total_sec))+'</td></tr>'));
|
||||||
|
document.querySelector('#history').innerHTML=table(['Time','Client','Status','Provider (requested → used)','Model','Images / upload','Intake','Upstream','Total','Failovers','Cost','Error'],stats.generations.map(x=>'<tr><td>'+esc(new Date(x.created_at*1000).toLocaleString())+'</td><td>'+esc(x.client_id)+'</td><td class="'+(x.status==='completed'?'ok':x.status==='failed'?'bad':'')+'">'+esc(x.status)+'</td><td>'+esc(x.requested_provider||'auto')+' → '+esc(x.provider_used||'—')+'</td><td>'+esc(x.model_used||x.requested_model)+'</td><td>'+esc(x.image_count)+' / '+esc((x.upload_bytes/1048576).toFixed(2))+' MiB</td><td>'+esc(sec(x.request_intake_sec))+'</td><td>'+esc(sec(x.upstream_sec))+'</td><td>'+esc(sec(x.total_sec))+'</td><td>'+esc(x.failover_attempts)+'</td><td>'+esc(money(x.cost_usd))+'<div class=muted>'+esc(x.cost_source||'')+'</div></td><td>'+esc(x.error?JSON.stringify(x.error):'')+'</td></tr>'));
|
||||||
|
}
|
||||||
|
document.querySelector('#c').onsubmit=async e=>{e.preventDefault();let r=await api('/api/admin/client',{method:'POST',body:new FormData(e.target)});document.querySelector('#o').textContent=await r.text();load()};
|
||||||
|
document.querySelector('#k').onsubmit=async e=>{e.preventDefault();let r=await api('/api/keys',{method:'POST',body:new FormData(e.target)});document.querySelector('#o').textContent=await r.text();load()};
|
||||||
|
</script></body></html>"""
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Include these directives in the HTTPS server block. Configure TLS separately.
|
||||||
|
client_max_body_size 52m;
|
||||||
|
proxy_connect_timeout 300s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
send_timeout 300s;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
+179
@@ -0,0 +1,179 @@
|
|||||||
|
# phproxy API
|
||||||
|
|
||||||
|
Base URL on this machine's Tailscale network: `http://100.67.41.125:8080`
|
||||||
|
|
||||||
|
The service accepts `multipart/form-data` for generation. Management routes also accept form data. Responses are JSON unless noted. The examples use `curl`; an agent can translate them to Python `requests` or `httpx`.
|
||||||
|
|
||||||
|
## Credentials and first-time setup
|
||||||
|
|
||||||
|
There are two kinds of credentials:
|
||||||
|
|
||||||
|
- **Admin secret**: configured by the service operator. Send it as `x-admin-secret` to administrator endpoints, or `auth-secret` to provider-key endpoints.
|
||||||
|
- **Client secret**: configured when creating a client. Send it as `x-client-secret` when generating or polling. It can also be used as `auth-secret` for that client's provider-key endpoints.
|
||||||
|
|
||||||
|
For the current local development server, the temporary admin secret is `local-development-admin-secret`. Replace it before using the service with real API keys or outside a trusted development tailnet.
|
||||||
|
|
||||||
|
Create a client:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
BASE_URL=http://100.67.41.125:8080
|
||||||
|
ADMIN_SECRET=local-development-admin-secret
|
||||||
|
|
||||||
|
curl -sS -X POST "$BASE_URL/api/admin/client" \
|
||||||
|
-H "x-admin-secret: $ADMIN_SECRET" \
|
||||||
|
-F 'client_id=booth-01' \
|
||||||
|
-F 'name=Booth 01' \
|
||||||
|
-F 'client_secret=replace-with-a-client-secret'
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"status":"ok","client_id":"booth-01"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add an upstream API key. Use `google` or `openrouter`; lower `priority` values are tried first within the selected provider ordering.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -sS -X POST "$BASE_URL/api/keys" \
|
||||||
|
-H "auth-secret: $ADMIN_SECRET" \
|
||||||
|
-F 'client_id=booth-01' \
|
||||||
|
-F 'provider=google' \
|
||||||
|
-F 'api_key=YOUR_GOOGLE_API_KEY' \
|
||||||
|
-F 'priority=1'
|
||||||
|
```
|
||||||
|
|
||||||
|
The same endpoint may be authorized with the owning client's secret instead of the admin secret. Add more provider keys at priorities 2, 3, and so on for fallback.
|
||||||
|
|
||||||
|
## Generate synchronously
|
||||||
|
|
||||||
|
`POST /v1/generate` holds the HTTP connection until the provider returns. Form fields:
|
||||||
|
|
||||||
|
| Field | Required | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `client_id` | Yes | Provisioned client identifier. |
|
||||||
|
| `prompt` | No | Text prompt. Defaults to an empty string. |
|
||||||
|
| `model` | No | Provider model ID or alias. Defaults to `nano-banana`. |
|
||||||
|
| `provider` | No | Preferred provider: `google` or `openrouter`. If omitted, configured key order is used. |
|
||||||
|
| `params` | No | JSON object encoded as a string. Defaults to `{}`. |
|
||||||
|
| `images` | No | Repeat this multipart field for each reference image. |
|
||||||
|
|
||||||
|
Send `x-client-secret` if the client has a secret configured. The service accepts JPEG, PNG, WebP, and GIF images, up to 8 files and 50 MiB aggregate by default. Limits can be changed by the operator.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -sS -X POST "$BASE_URL/v1/generate" \
|
||||||
|
-H 'x-client-secret: replace-with-a-client-secret' \
|
||||||
|
-F 'client_id=booth-01' \
|
||||||
|
-F 'prompt=Restyle the person using the second image as a style reference' \
|
||||||
|
-F 'model=nano-banana' \
|
||||||
|
-F 'provider=google' \
|
||||||
|
-F 'params={"aspect_ratio":"9:16","image_size":"2K"}' \
|
||||||
|
-F 'images=@capture.jpg;type=image/jpeg' \
|
||||||
|
-F 'images=@style.png;type=image/png'
|
||||||
|
```
|
||||||
|
|
||||||
|
Success (`200`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"provider_used": "openrouter",
|
||||||
|
"model_used": "google/gemini-2.5-flash-image",
|
||||||
|
"images": [{"mime_type": "image/png", "b64_json": "..."}],
|
||||||
|
"text": "",
|
||||||
|
"cost_usd": 0.0412,
|
||||||
|
"cost_source": "openrouter_usage",
|
||||||
|
"usage": {"prompt_tokens": 1200, "completion_tokens": 1290, "cost": 0.0412},
|
||||||
|
"elapsed_sec": 12.34,
|
||||||
|
"failover_attempts": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`cost_usd` is the amount reported by OpenRouter for that generation. The proxy requests OpenRouter usage metadata and returns it under `usage`; it also includes OpenRouter's request ID and routing/timing metadata when available. For providers that do not return a billed amount (currently Google native), `cost_usd` is `null` and `cost_source` is `unavailable`. Completed async jobs expose these fields inside `result`.
|
||||||
|
|
||||||
|
Generation responses also include `generation_id`, `request_intake_sec`, and `total_sec`. `request_intake_sec` covers the request reaching and being parsed by the proxy before the generation is queued; `elapsed_sec` is the upstream call time; `total_sec` runs from request receipt through completion. These measurements help separate slow client uploads from slow model calls.
|
||||||
|
|
||||||
|
An image can be returned as `b64_json`, or as a URL when the upstream returns a hosted image. Decode `b64_json` as standard Base64 before saving it. Avoid printing or logging generated image data in an agent script.
|
||||||
|
|
||||||
|
## Generate asynchronously and poll
|
||||||
|
|
||||||
|
Add the query parameter `async_mode=true` to the same multipart request. The response returns as soon as the job is queued:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -sS -X POST "$BASE_URL/v1/generate?async_mode=true" \
|
||||||
|
-H 'x-client-secret: replace-with-a-client-secret' \
|
||||||
|
-F 'client_id=booth-01' \
|
||||||
|
-F 'prompt=Make a cinematic portrait' \
|
||||||
|
-F 'model=nano-banana' \
|
||||||
|
-F 'images=@capture.jpg;type=image/jpeg'
|
||||||
|
```
|
||||||
|
|
||||||
|
Queued response (`200`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"job_id":"<uuid>","status":"queued","poll_url":"/v1/jobs/<uuid>"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Poll every couple of seconds. Include both the `client_id` query parameter and `x-client-secret` header:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -sS "$BASE_URL/v1/jobs/<uuid>?client_id=booth-01" \
|
||||||
|
-H 'x-client-secret: replace-with-a-client-secret'
|
||||||
|
```
|
||||||
|
|
||||||
|
While running:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"job_id":"<uuid>","status":"processing","created_at":1234567890.0,"updated_at":1234567891.0}
|
||||||
|
```
|
||||||
|
|
||||||
|
On success, `status` is `completed` and a `result` field contains the synchronous response object. On failure, `status` is `failed` and `error` contains a sanitized message. Jobs are scoped to the owning `client_id`; an unknown job ID and a job belonging to another client both return `404`.
|
||||||
|
|
||||||
|
## Models and parameters
|
||||||
|
|
||||||
|
The built-in `nano-banana` alias maps to `gemini-2.5-flash-image` for Google and `google/gemini-2.5-flash-image` for OpenRouter by default. Operators can override these IDs with `GOOGLE_MODEL_NANO_BANANA` and `OPENROUTER_MODEL_NANO_BANANA`. Other model IDs pass through, with provider prefix normalization.
|
||||||
|
|
||||||
|
Recognized `params` include:
|
||||||
|
|
||||||
|
- `aspect_ratio`, `image_size`
|
||||||
|
- Google: `temperature`, `topP`, `seed`, `responseModalities`, `safetySettings`, `thinking`, `generationConfig`
|
||||||
|
- OpenRouter: `temperature`, `seed`, `reasoning` (object), `modalities`, `extra_body`
|
||||||
|
|
||||||
|
For OpenRouter, `reasoning` must be an object (for example `{"effort":"medium"}`); boolean values are omitted because the upstream API expects an object. Reasoning controls apply only to models that support them.
|
||||||
|
|
||||||
|
Provider APIs may reject unsupported parameters or model IDs. Configure exact IDs supported by the provider account. For a requested provider, fallback tries other active keys in configured priority order after a rate limit, server error, or connection/timeout failure. A non-retryable provider response stops the attempt sequence.
|
||||||
|
|
||||||
|
## Management endpoints
|
||||||
|
|
||||||
|
| Method and path | Auth | Behavior |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET /healthz` | None | Liveness response `{"status":"ok"}`. |
|
||||||
|
| `GET /ui` | Network access control | Basic HTML administration page. |
|
||||||
|
| `GET /api/admin/state` | `x-admin-secret` | Lists clients and keys with only a short key preview. |
|
||||||
|
| `GET /api/admin/generations?limit=100` | `x-admin-secret` | Returns aggregate totals, per-client totals, and recent generation timing/cost records (limit 1–200). |
|
||||||
|
| `POST /api/admin/client` | `x-admin-secret` | Form fields: `client_id`, `name`, optional `client_secret`; creates or updates a client. |
|
||||||
|
| `POST /api/keys` | `auth-secret` | Form fields: `client_id`, `provider`, `api_key`, optional `priority`. Admin or owning client may add keys. |
|
||||||
|
| `DELETE /api/keys/{key_id}` | `auth-secret` | Deletes a key owned by the authenticated admin/client. |
|
||||||
|
|
||||||
|
## HTTP errors
|
||||||
|
|
||||||
|
Validation and authorization failures use FastAPI's `{"detail":"..."}` response shape. Common statuses:
|
||||||
|
|
||||||
|
| Status | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `400` | Invalid parameters, provider, or missing provider keys. |
|
||||||
|
| `401` | Missing or invalid admin/client secret. |
|
||||||
|
| `403` | Client is unknown or inactive. |
|
||||||
|
| `404` | Client/key/job was not found, or the job belongs to another client. |
|
||||||
|
| `413` | Request or image upload exceeds configured limits. |
|
||||||
|
| `415` | Unsupported image MIME type. |
|
||||||
|
| `502` | Upstream failure after applicable failover attempts. Response contains status metadata, not upstream response bodies. |
|
||||||
|
|
||||||
|
## Notes for test scripts
|
||||||
|
|
||||||
|
The admin generations endpoint shows total, completed, and failed runs; sum of known costs; per-client cost and timing; and recent per-generation request intake, upstream, and total seconds. Prompts, images, and full provider keys are not included in this history.
|
||||||
|
|
||||||
|
- Aggregate cost and average timing fields include completed generations only. Failed runs remain visible in counts and recent history, but do not affect those totals or averages.
|
||||||
|
- Use a dedicated test client and avoid real production provider keys.
|
||||||
|
- A request with no configured provider key is useful for checking async job behavior: it should be accepted, then finish as `failed` with `No active provider keys configured`.
|
||||||
|
- A real image generation test requires an active provider key and can incur provider charges.
|
||||||
|
- The service currently has one in-process worker. Polling jobs survive client disconnects and completed results survive process restarts, but an active job is marked failed if the proxy restarts.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=69"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "phproxy"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "A small async image generation proxy with SQLite-backed jobs and provider keys."
|
||||||
|
requires-python = ">=3.12"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115,<1",
|
||||||
|
"httpx>=0.27,<1",
|
||||||
|
"aiosqlite>=0.20,<1",
|
||||||
|
"uvicorn[standard]>=0.30,<1",
|
||||||
|
"python-multipart>=0.0.9,<1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["app*"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py312"
|
||||||
Reference in new issue
Block a user