Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ CODEBUDDY2API_ADMIN_CSRF=true
# CODEBUDDY2API_BACKUP_INTERVAL_HOURS=24
# CODEBUDDY2API_BACKUP_KEEP=7

# Local media storage for generated images and videos. The base URL prefixes public asset
# links; when empty images return b64 only and saved videos expose no link.
# 生成图片与视频的本地存储。资源访问地址用于拼接公开资源链接;留空时图片只返回
# b64,已保存的视频不回传链接。不设置时可在「媒体生成」页修改;显式设置会锁定。
# CODEBUDDY2API_MEDIA_BASE_URL=https://media.example.com
# CODEBUDDY2API_MEDIA_MAX_TOTAL_MB=1024
# CODEBUDDY2API_MEDIA_TTL_HOURS=72

# Unset leaves tool metadata configurable in the WebUI; an explicit Boolean locks it.
# 不设置时工具元数据可在 WebUI 修改;显式布尔值会锁定。
# CODEBUDDY2API_KEEP_TOOL_METADATA=true
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ The template binds to localhost only. Configure HTTPS and restrict network acces
| Anthropic Messages | `http://127.0.0.1:8787` |
| MCP (web_search + web_fetch) | `http://127.0.0.1:8787/v1/mcp` |
| REST web search / fetch | `POST http://127.0.0.1:8787/v1/search` / `POST http://127.0.0.1:8787/v1/fetch` |
| Image & video generation | `POST http://127.0.0.1:8787/v1/images/generations` / `POST /v1/images/edits` / `POST /v1/videos` |

- Media generation is billed per upstream credit and only routes to verified account products; see [Media generation](docs/clients.md#media-generation).

- **API key:** the same key used to sign in to the WebUI.
- **Model:** a public ID from the WebUI or `GET /v1/models`.
Expand Down
3 changes: 3 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ Compose 默认拉取 `latest`(`CODEBUDDY2API_IMAGE=ghcr.io/maiphucgiang/codebu
| Anthropic Messages | `http://127.0.0.1:8787` |
| MCP(web_search + web_fetch 工具) | `http://127.0.0.1:8787/v1/mcp` |
| REST 联网搜索 / 抓取 | `POST http://127.0.0.1:8787/v1/search` / `POST http://127.0.0.1:8787/v1/fetch` |
| 图像 / 视频生成 | `POST http://127.0.0.1:8787/v1/images/generations` / `POST /v1/images/edits` / `POST /v1/videos` |

- 媒体生成按上游积分计费,只路由到已实测验证的账号产品;见[媒体生成](docs/clients.zh-CN.md#媒体生成)。

- **API Key**:与 WebUI 登录使用同一个密钥。
- **模型**:使用 WebUI 中的对外 ID,或查询 `GET /v1/models`。
Expand Down
146 changes: 146 additions & 0 deletions app/admin_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

from . import auth_oauth
from . import perf_metrics
from . import media_store as media_store_module
from .admin_auth import AdminAuth, AdminMiddleware, COOKIE_NAME, SESSION_TTL, error_response, same_origin
from .control_store import ConflictError, validate_model
from .credential_io import CredentialFileError, MAX_CREDENTIAL_BYTES, _valid_name, read_import_file
Expand Down Expand Up @@ -743,6 +744,151 @@ async def logs_clear(request):
return error_response(503, "日志清理未完成,请检查审计存储状态")
return JSONResponse(result)

def media_store():
store = config.get("media_store")
if store is None:
raise _MediaUnavailable()
return store

class _MediaUnavailable(Exception):
pass

def media_route(method, path):
"""Register one media handler; a missing store answers 503, never 400 or 500."""
def decorate(function):
async def guarded(request: Request):
try:
return await function(request)
except _MediaUnavailable:
return error_response(503, "媒体存储不可用,请检查数据目录权限或重启网关")
except HTTPException as exc:
detail = exc.detail.get("error", {}).get("message") if isinstance(exc.detail, dict) else None
return error_response(exc.status_code, detail or "媒体操作不符合当前状态,请刷新后重试")
except ValueError:
return error_response(400, "媒体请求参数无效,请检查 kind/status/limit/cursor")
except Exception:
return error_response(500, "管理操作失败,请检查存储状态后重试")
guarded.__name__ = function.__name__
app.add_api_route(path, guarded, methods=[method])
return function
return decorate

def media_page_params(request, *, status=False):
params = request.query_params
try:
limit = int(params.get("limit", "50"))
except ValueError:
raise ValueError("limit 必须为整数") from None
if not 1 <= limit <= 200:
raise ValueError("limit 必须为 1 至 200")
cursor = params.get("cursor", "")
if len(cursor) > 2048:
raise ValueError("cursor 过长")
filter_value = params.get("status" if status else "kind", "")
if status and filter_value not in ("", "active", "completed", "failed"):
raise ValueError("status 必须是 active、completed 或 failed")
if not status and filter_value not in ("", "image", "video"):
raise ValueError("kind 必须是 image 或 video")
return filter_value or None, cursor or None, limit

@route("GET", "/admin/media")
async def media_summary(request):
def build():
store = config.get("media_store")
resolved = {item["key"]: item for item in resolve_settings(config)}
base = str(config.get("media_public_base_url") or "")
return JSONResponse({
"configured": bool(base), "base_url": base,
"locked": bool(resolved.get("media_public_base_url", {}).get("locked")),
"usage": store.usage(ttl_hours=gateway._media_ttl_hours()) if store else {
"total_bytes": 0, "images": {"count": 0, "bytes": 0},
"videos": {"count": 0, "bytes": 0}},
"limit_bytes": gateway._media_max_bytes(),
"ttl_hours": gateway._media_ttl_hours(),
"tasks": store.task_counts() if store else {"active": 0, "failed": 0},
"available": store is not None,
})
return await run_in_threadpool(build)

def media_asset_row(item, base):
entry = dict(item)
entry["created_at"] = item["created"]
entry["expires_at"] = item["created"] + gateway._media_ttl_hours() * 3600
if base:
entry["public_url"] = f"{base}/v1/media/{'videos' if item['kind'] == 'video' else 'images'}/{item['id']}"
return entry

def live_asset(asset_id):
"""Return an asset still inside the current TTL; expired ones vanish before the sweep."""
meta = media_store().get_asset(asset_id)
if meta is not None and media_store_module.is_expired(meta, gateway._media_ttl_hours()):
return None
return meta

@media_route("GET", "/admin/media/assets")
async def media_assets(request):
kind, cursor, limit = media_page_params(request)

def build():
page = media_store().list_assets(kind, cursor, limit, ttl_hours=gateway._media_ttl_hours())
base = str(config.get("media_public_base_url") or "")
items = [media_asset_row(item, base) for item in page["items"]]
return JSONResponse({**page, "items": items})
return await run_in_threadpool(build)

@media_route("GET", "/admin/media/assets/{asset_id}")
async def media_asset_meta(request):
def build():
meta = live_asset(request.path_params["asset_id"])
if meta is None:
return error_response(404, "媒体资源不存在或已清理")
return JSONResponse(media_asset_row(meta, str(config.get("media_public_base_url") or "")))
return await run_in_threadpool(build)

@media_route("GET", "/admin/media/assets/{asset_id}/content")
async def media_asset_content(request):
def build():
meta = live_asset(request.path_params["asset_id"])
file = media_store().asset_file(meta) if meta else None
if file is None:
return error_response(404, "媒体资源不存在或已清理")
headers = media_store_module.security_headers(meta)
if request.query_params.get("download") == "1":
headers["Content-Disposition"] = f'attachment; filename="{file.name}"'
return FileResponse(file, media_type=meta["mime"], headers=headers)
return await run_in_threadpool(build)

@media_route("DELETE", "/admin/media/assets/{asset_id}")
async def media_asset_delete(request):
def remove():
asset_id = request.path_params["asset_id"]
if not media_store().delete_asset(asset_id):
return error_response(404, "媒体资源不存在或已清理")
event("media.asset_deleted", {"asset": asset_id})
return JSONResponse({"deleted": asset_id})
return await run_in_threadpool(remove)

@media_route("GET", "/admin/media/tasks")
async def media_tasks(request):
status, cursor, limit = media_page_params(request, status=True)

def build():
page = media_store().list_tasks(status, cursor, limit)
items = [{**item, "created_at": item["created"], "updated_at": item["updated"]}
for item in page["items"]]
return JSONResponse({**page, "items": items})
return await run_in_threadpool(build)

@media_route("POST", "/admin/media/tasks/{task_id}/refresh")
async def media_task_refresh(request):
task_id = request.path_params["task_id"]
task = await gateway._media_video_refresh(task_id, gateway._request_id())
event("media.task_refreshed", {"task": task_id})
meta = None
if task["status"] == "completed" and task["media_id"]:
meta = await run_in_threadpool(media_store().get_asset, task["media_id"])
return JSONResponse(gateway._media_video_response(task, meta))

@route("GET", "/admin/storage")
async def storage_get(request):
# A degraded store still reports its own health; a 503 here would hide
Expand Down
9 changes: 9 additions & 0 deletions app/catalog_index.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Read-only indexes over published account catalogs, built once per catalog revision."""
from __future__ import annotations

from .media_generation import catalog_kinds
from .model_catalog_view import INTERNATIONAL, share_models


Expand Down Expand Up @@ -31,6 +32,7 @@ def __init__(self, accounts, *, revision=0, policy_revision=0, is_enabled=None,
self.policy_revision = policy_revision
self._enabled = is_enabled or (lambda _identity: True)
self._account: dict[tuple[int, str], list[dict] | None] = {}
self.media_kinds: dict[str, tuple] = {} # model id → media kinds from serves tags.
self._profile: dict[str, dict[str, tuple[dict, ...]]] = {}
self.profiles: tuple[str, ...] = tuple(dict.fromkeys(
account.get("profile") for account in self.accounts.values() if account.get("profile")))
Expand All @@ -44,6 +46,13 @@ def __init__(self, accounts, *, revision=0, policy_revision=0, is_enabled=None,
items = self._effective(account, scope, sources)
if items is None:
continue
if scope == "serves":
# Media membership comes from the root table; keep it O(1) per model.
for item in items:
kinds = catalog_kinds(item)
if kinds and isinstance(item.get("id"), str):
known = self.media_kinds.setdefault(item["id"], ())
self.media_kinds[item["id"]] = tuple(dict.fromkeys(known + kinds))
merged.setdefault(profile, []).extend(items)
self._profile[scope] = {name: tuple(items) for name, items in merged.items()}

Expand Down
11 changes: 9 additions & 2 deletions app/gateway_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,9 @@ def admin_model_inventory(self):
facts.setdefault(row["id"], row)
for source in model_policy.snapshot(self.CONFIG)["models"]:
facts.setdefault(source, {"id": source, "credits": None, "credits_by_profile": {}})
for media in self.gateway.media_model_details():
row = facts.get(media["id"])
facts[media["id"]] = {**media, **(row or {})}
result = []
for source, row in facts.items():
rule = model_policy.rule_for(self.CONFIG, source)
Expand Down Expand Up @@ -390,12 +393,16 @@ def admin_model_preview(self, source, rule):
accepted, rejected = [], []
if pool is None:
return {"candidates": [], "excluded": []}
media = bool(self.gateway._media_all_kinds(upstream))
policy = model_policy.view(self.CONFIG)
with pool._lock:
for entry in pool.entries():
identity = entry.get("account_key")
profile = entry.get("profile")
reason = None
if not model_policy.credential_enabled(self.CONFIG, entry):
if media:
reason = self.gateway._media_account_reason(entry, upstream, rule=rule, policy=policy)
elif not model_policy.credential_enabled(self.CONFIG, entry):
reason = "人工停用"
elif not rule.get("enabled", True):
reason = "模型已停用"
Expand Down Expand Up @@ -439,7 +446,7 @@ def admin_apply_settings(self, values):
def install_pages(app, directory):
"""Fallback only applies to pages; management/client APIs never serve HTML."""
root = Path(directory).resolve()
pages = {"", "models", "credentials", "logs", "storage", "settings", "updates", "login"}
pages = {"", "models", "credentials", "logs", "media", "storage", "settings", "updates", "login"}
# Release detail pages carry the tag, whose dots would otherwise read as a file suffix.
release_page = re.compile(r"updates/v\d+\.\d+\.\d+").fullmatch
assets = StaticFiles(directory=root / "assets", check_dir=False)
Expand Down
Loading
Loading