Initial project import

This commit is contained in:
XM
2026-09-18 03:46:58 +08:00
commit b600da4b76
209 changed files with 45904 additions and 0 deletions
View File
File diff suppressed because it is too large Load Diff
+253
View File
@@ -0,0 +1,253 @@
from __future__ import annotations
import hashlib
import hmac
import secrets
import uuid
from datetime import datetime, timezone
from threading import Lock
from typing import Literal
from services.config import config
from services.storage.base import StorageBackend
AuthRole = Literal["admin", "user"]
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _hash_key(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
class AuthService:
def __init__(self, storage: StorageBackend):
self.storage = storage
self._lock = Lock()
self._items = self._load()
self._last_used_flush_at: dict[str, datetime] = {}
@staticmethod
def _clean(value: object) -> str:
return str(value or "").strip()
@staticmethod
def _default_name(role: object) -> str:
return "管理员密钥" if str(role or "").strip().lower() == "admin" else "普通用户"
def _normalize_item(self, raw: object) -> dict[str, object] | None:
if not isinstance(raw, dict):
return None
role = self._clean(raw.get("role")).lower()
if role not in {"admin", "user"}:
return None
key_hash = self._clean(raw.get("key_hash"))
if not key_hash:
return None
item_id = self._clean(raw.get("id")) or uuid.uuid4().hex[:12]
name = self._clean(raw.get("name")) or self._default_name(role)
created_at = self._clean(raw.get("created_at")) or _now_iso()
last_used_at = self._clean(raw.get("last_used_at")) or None
return {
"id": item_id,
"name": name,
"role": role,
"key_hash": key_hash,
"enabled": bool(raw.get("enabled", True)),
"created_at": created_at,
"last_used_at": last_used_at,
}
def _load(self) -> list[dict[str, object]]:
try:
items = self.storage.load_auth_keys()
except Exception:
return []
if not isinstance(items, list):
return []
return [normalized for item in items if (normalized := self._normalize_item(item)) is not None]
def _save(self) -> None:
self.storage.save_auth_keys(self._items)
def _reload_locked(self) -> None:
self._items = self._load()
@staticmethod
def _public_item(item: dict[str, object]) -> dict[str, object]:
return {
"id": item.get("id"),
"name": item.get("name"),
"role": item.get("role"),
"enabled": bool(item.get("enabled", True)),
"created_at": item.get("created_at"),
"last_used_at": item.get("last_used_at"),
}
def list_keys(self, role: AuthRole | None = None) -> list[dict[str, object]]:
with self._lock:
self._reload_locked()
items = [item for item in self._items if role is None or item.get("role") == role]
return [self._public_item(item) for item in items]
def _has_key_hash_locked(self, key_hash: str, *, exclude_id: str = "") -> bool:
for item in self._items:
item_id = self._clean(item.get("id"))
if exclude_id and item_id == exclude_id:
continue
stored_hash = self._clean(item.get("key_hash"))
if stored_hash and hmac.compare_digest(stored_hash, key_hash):
return True
return False
def _build_key_hash_locked(self, raw_key: str, *, exclude_id: str = "") -> str:
candidate = self._clean(raw_key)
if not candidate:
raise ValueError("请输入新的专用密钥")
admin_key = self._clean(config.auth_key)
if admin_key and hmac.compare_digest(candidate, admin_key):
raise ValueError("这个密钥和管理员密钥冲突了,请换一个新的密钥")
key_hash = _hash_key(candidate)
if self._has_key_hash_locked(key_hash, exclude_id=exclude_id):
raise ValueError("这个专用密钥已经存在,请换一个新的密钥")
return key_hash
def _has_name_locked(self, name: str, *, role: AuthRole | None = None, exclude_id: str = "") -> bool:
candidate = self._clean(name)
if not candidate:
return False
for item in self._items:
item_id = self._clean(item.get("id"))
if exclude_id and item_id == exclude_id:
continue
if role is not None and item.get("role") != role:
continue
if self._clean(item.get("name")) == candidate:
return True
return False
def _build_default_name_locked(self, role: AuthRole, *, exclude_id: str = "") -> str:
base_name = self._default_name(role)
if not self._has_name_locked(base_name, role=role, exclude_id=exclude_id):
return base_name
suffix = 2
while True:
candidate = f"{base_name} {suffix}"
if not self._has_name_locked(candidate, role=role, exclude_id=exclude_id):
return candidate
suffix += 1
def _build_name_locked(self, name: str, *, role: AuthRole, exclude_id: str = "") -> str:
candidate = self._clean(name)
if not candidate:
return self._build_default_name_locked(role, exclude_id=exclude_id)
if self._has_name_locked(candidate, role=role, exclude_id=exclude_id):
raise ValueError("这个名称已经在使用中了,换一个更容易区分的名称吧")
return candidate
def create_key(self, *, role: AuthRole, name: str = "") -> tuple[dict[str, object], str]:
with self._lock:
self._reload_locked()
normalized_name = self._build_name_locked(name, role=role)
while True:
raw_key = f"sk-{secrets.token_urlsafe(24)}"
try:
key_hash = self._build_key_hash_locked(raw_key)
break
except ValueError:
continue
item = {
"id": uuid.uuid4().hex[:12],
"name": normalized_name,
"role": role,
"key_hash": key_hash,
"enabled": True,
"created_at": _now_iso(),
"last_used_at": None,
}
self._items.append(item)
self._save()
return self._public_item(item), raw_key
def update_key(
self,
key_id: str,
updates: dict[str, object],
*,
role: AuthRole | None = None,
) -> dict[str, object] | None:
normalized_id = self._clean(key_id)
if not normalized_id:
return None
with self._lock:
self._reload_locked()
for index, item in enumerate(self._items):
if item.get("id") != normalized_id:
continue
if role is not None and item.get("role") != role:
return None
next_item = dict(item)
next_role = "admin" if str(next_item.get("role") or "").strip().lower() == "admin" else "user"
if "name" in updates and updates.get("name") is not None:
next_item["name"] = self._build_name_locked(
str(updates.get("name") or ""),
role=next_role,
exclude_id=normalized_id,
)
if "enabled" in updates and updates.get("enabled") is not None:
next_item["enabled"] = bool(updates.get("enabled"))
if "key" in updates and updates.get("key") is not None:
next_item["key_hash"] = self._build_key_hash_locked(str(updates.get("key") or ""), exclude_id=normalized_id)
self._items[index] = next_item
self._save()
return self._public_item(next_item)
return None
def delete_key(self, key_id: str, *, role: AuthRole | None = None) -> bool:
normalized_id = self._clean(key_id)
if not normalized_id:
return False
with self._lock:
self._reload_locked()
before = len(self._items)
self._items = [
item
for item in self._items
if not (item.get("id") == normalized_id and (role is None or item.get("role") == role))
]
if len(self._items) == before:
return False
self._save()
return True
def authenticate(self, raw_key: str) -> dict[str, object] | None:
candidate = self._clean(raw_key)
if not candidate:
return None
candidate_hash = _hash_key(candidate)
with self._lock:
for index, item in enumerate(self._items):
if not bool(item.get("enabled", True)):
continue
stored_hash = self._clean(item.get("key_hash"))
if not stored_hash or not hmac.compare_digest(stored_hash, candidate_hash):
continue
next_item = dict(item)
now = datetime.now(timezone.utc)
next_item["last_used_at"] = now.isoformat()
self._items[index] = next_item
item_id = self._clean(next_item.get("id"))
last_flush_at = self._last_used_flush_at.get(item_id)
if last_flush_at is None or (now - last_flush_at).total_seconds() >= 60:
try:
self._save()
self._last_used_flush_at[item_id] = now
except Exception:
pass
return self._public_item(next_item)
return None
auth_service = AuthService(config.get_storage_backend())
+671
View File
@@ -0,0 +1,671 @@
from __future__ import annotations
import hashlib
import hmac
import io
import json
import os
import random
import subprocess
import tarfile
import threading
from datetime import UTC, datetime
from pathlib import Path
from urllib.parse import quote, urlencode
from curl_cffi import requests
from services.config import BASE_DIR, CONFIG_FILE, DATA_DIR, config, load_backup_state, save_backup_state
from services.image_storage_service import IMAGE_INDEX_FILE
from services.image_tags_service import TAGS_FILE
def _utc_now() -> datetime:
return datetime.now(UTC)
def _iso_now() -> str:
return _utc_now().replace(microsecond=0).isoformat().replace("+00:00", "Z")
def _clean(value: object) -> str:
return str(value or "").strip()
def _sha256_hex(value: bytes) -> str:
return hashlib.sha256(value).hexdigest()
def _is_backup_object(key: object) -> bool:
name = _clean(key).rsplit("/", 1)[-1]
return name.startswith("backup-") and (name.endswith(".tar.gz") or name.endswith(".tar.gz.enc"))
def _hmac_sha256(key: bytes, message: str) -> bytes:
return hmac.new(key, message.encode("utf-8"), hashlib.sha256).digest()
def _openssl_encrypt(data: bytes, passphrase: str) -> bytes:
env = dict(os.environ)
env["CHATGPT2API_BACKUP_PASSPHRASE"] = passphrase
try:
result = subprocess.run(
[
"openssl",
"enc",
"-aes-256-cbc",
"-pbkdf2",
"-salt",
"-md",
"sha256",
"-pass",
"env:CHATGPT2API_BACKUP_PASSPHRASE",
],
input=data,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
env=env,
)
except FileNotFoundError as exc:
raise BackupError("当前环境缺少 openssl,无法执行加密备份") from exc
except subprocess.CalledProcessError as exc:
detail = (exc.stderr or b"").decode("utf-8", errors="replace").strip()
raise BackupError(f"加密备份失败:{detail or 'openssl 执行失败'}") from exc
return result.stdout
def _openssl_decrypt(data: bytes, passphrase: str) -> bytes:
env = dict(os.environ)
env["CHATGPT2API_BACKUP_PASSPHRASE"] = passphrase
try:
result = subprocess.run(
[
"openssl",
"enc",
"-d",
"-aes-256-cbc",
"-pbkdf2",
"-md",
"sha256",
"-pass",
"env:CHATGPT2API_BACKUP_PASSPHRASE",
],
input=data,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
env=env,
)
except FileNotFoundError as exc:
raise BackupError("当前环境缺少 openssl,无法解密备份内容") from exc
except subprocess.CalledProcessError as exc:
detail = (exc.stderr or b"").decode("utf-8", errors="replace").strip()
raise BackupError(f"解密备份失败:{detail or 'openssl 执行失败'}") from exc
return result.stdout
def _guess_content_type(name: str) -> str:
if name.endswith(".json"):
return "application/json"
if name.endswith(".jsonl"):
return "application/x-ndjson"
if name.endswith(".tar.gz"):
return "application/gzip"
if name.endswith(".gz"):
return "application/gzip"
return "application/octet-stream"
def _json_bytes(value: object) -> bytes:
return json.dumps(value, ensure_ascii=False, indent=2).encode("utf-8")
def _count_items(value: object) -> int:
if isinstance(value, list):
return len(value)
if isinstance(value, dict):
return len(value)
return 0
class BackupError(RuntimeError):
pass
class CloudflareR2Client:
def __init__(self, settings: dict[str, object]) -> None:
self.account_id = _clean(settings.get("account_id"))
self.access_key_id = _clean(settings.get("access_key_id"))
self.secret_access_key = _clean(settings.get("secret_access_key"))
self.bucket = _clean(settings.get("bucket"))
self.prefix = _clean(settings.get("prefix")) or "backups"
self.session = requests.Session(impersonate="chrome", verify=True)
def validate(self) -> None:
missing = []
if not self.account_id:
missing.append("Account ID")
if not self.access_key_id:
missing.append("Access Key ID")
if not self.secret_access_key:
missing.append("Secret Access Key")
if not self.bucket:
missing.append("Bucket")
if missing:
raise BackupError(f"R2 配置不完整:缺少 {'、'.join(missing)}")
@property
def endpoint(self) -> str:
return f"https://{self.account_id}.r2.cloudflarestorage.com"
def _aws_v4_headers(
self,
method: str,
path: str,
*,
query: dict[str, str] | None = None,
body: bytes = b"",
extra_headers: dict[str, str] | None = None,
) -> tuple[str, dict[str, str]]:
now = _utc_now()
amz_date = now.strftime("%Y%m%dT%H%M%SZ")
date_stamp = now.strftime("%Y%m%d")
encoded_query = urlencode(sorted((query or {}).items()))
payload_hash = _sha256_hex(body)
host = f"{self.account_id}.r2.cloudflarestorage.com"
headers = {
"host": host,
"x-amz-content-sha256": payload_hash,
"x-amz-date": amz_date,
}
if extra_headers:
for key, value in extra_headers.items():
headers[key.lower()] = value.strip()
sorted_items = sorted((key.lower(), " ".join(str(value).strip().split())) for key, value in headers.items())
canonical_headers = "".join(f"{key}:{value}\n" for key, value in sorted_items)
signed_headers = ";".join(key for key, _ in sorted_items)
canonical_request = "\n".join([
method.upper(),
path,
encoded_query,
canonical_headers,
signed_headers,
payload_hash,
])
credential_scope = f"{date_stamp}/auto/s3/aws4_request"
string_to_sign = "\n".join([
"AWS4-HMAC-SHA256",
amz_date,
credential_scope,
_sha256_hex(canonical_request.encode("utf-8")),
])
k_date = _hmac_sha256(("AWS4" + self.secret_access_key).encode("utf-8"), date_stamp)
k_region = hmac.new(k_date, b"auto", hashlib.sha256).digest()
k_service = hmac.new(k_region, b"s3", hashlib.sha256).digest()
k_signing = hmac.new(k_service, b"aws4_request", hashlib.sha256).digest()
signature = hmac.new(k_signing, string_to_sign.encode("utf-8"), hashlib.sha256).hexdigest()
authorization = (
"AWS4-HMAC-SHA256 "
f"Credential={self.access_key_id}/{credential_scope}, "
f"SignedHeaders={signed_headers}, "
f"Signature={signature}"
)
request_headers = {key: value for key, value in headers.items()}
request_headers["authorization"] = authorization
return encoded_query, request_headers
def _request(
self,
method: str,
key: str = "",
*,
query: dict[str, str] | None = None,
body: bytes = b"",
extra_headers: dict[str, str] | None = None,
timeout: float = 60.0,
):
object_path = f"/{self.bucket}"
if key:
object_path += f"/{quote(key.lstrip('/'), safe='/')}"
encoded_query, headers = self._aws_v4_headers(method, object_path, query=query, body=body, extra_headers=extra_headers)
url = f"{self.endpoint}{object_path}"
if encoded_query:
url += f"?{encoded_query}"
response = self.session.request(method.upper(), url, headers=headers, data=body, timeout=timeout)
return response
def test_connection(self) -> dict[str, object]:
self.validate()
response = self._request("GET", query={"list-type": "2", "max-keys": "1"}, timeout=30.0)
if response.status_code >= 400:
raise BackupError(f"连接 R2 失败:HTTP {response.status_code}")
return {"ok": True, "status": int(response.status_code)}
def upload_bytes(self, key: str, payload: bytes, *, content_type: str, metadata: dict[str, str] | None = None) -> dict[str, object]:
headers = {"content-type": content_type}
if metadata:
for item_key, item_value in metadata.items():
headers[f"x-amz-meta-{item_key}"] = str(item_value)
response = self._request("PUT", key, body=payload, extra_headers=headers)
if response.status_code >= 400:
raise BackupError(f"上传备份失败:HTTP {response.status_code}")
return {"key": key, "etag": str(response.headers.get("etag") or "").strip('"')}
def delete_object(self, key: str) -> None:
response = self._request("DELETE", key, timeout=30.0)
if response.status_code >= 400 and response.status_code != 404:
raise BackupError(f"删除备份失败:HTTP {response.status_code}")
def download_bytes(self, key: str) -> bytes:
response = self._request("GET", key, timeout=60.0)
if response.status_code >= 400:
raise BackupError(f"读取备份失败:HTTP {response.status_code}")
return bytes(response.content or b"")
def list_objects(self) -> list[dict[str, object]]:
items: list[dict[str, object]] = []
continuation = ""
while True:
query = {"list-type": "2", "prefix": f"{self.prefix.rstrip('/')}/", "max-keys": "1000"}
if continuation:
query["continuation-token"] = continuation
response = self._request("GET", query=query, timeout=30.0)
if response.status_code >= 400:
raise BackupError(f"获取备份列表失败:HTTP {response.status_code}")
text = response.text
for block in text.split("<Contents>")[1:]:
key = _clean(block.split("<Key>", 1)[1].split("</Key>", 1)[0]) if "<Key>" in block else ""
if not key:
continue
size_text = _clean(block.split("<Size>", 1)[1].split("</Size>", 1)[0]) if "<Size>" in block else "0"
updated = _clean(block.split("<LastModified>", 1)[1].split("</LastModified>", 1)[0]) if "<LastModified>" in block else ""
items.append({
"key": key,
"size": int(size_text or 0),
"updated_at": updated,
})
truncated = "<IsTruncated>true</IsTruncated>" in text
if not truncated:
break
if "<NextContinuationToken>" not in text:
break
continuation = _clean(text.split("<NextContinuationToken>", 1)[1].split("</NextContinuationToken>", 1)[0])
if not continuation:
break
items.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True)
return items
def close(self) -> None:
self.session.close()
class BackupService:
def __init__(self) -> None:
self._lock = threading.RLock()
self._stop_event = threading.Event()
self._thread: threading.Thread | None = None
self._running = False
def start(self) -> None:
with self._lock:
if self._thread and self._thread.is_alive():
return
self._stop_event.clear()
self._thread = threading.Thread(target=self._run, daemon=True, name="r2-backup-scheduler")
self._thread.start()
def stop(self) -> None:
with self._lock:
self._stop_event.set()
thread = self._thread
self._thread = None
if thread and thread.is_alive():
thread.join(timeout=2)
def _run(self) -> None:
while not self._stop_event.is_set():
try:
self.run_scheduled_backup_if_needed()
except Exception:
pass
self._stop_event.wait(30)
def run_scheduled_backup_if_needed(self) -> None:
settings = config.get_backup_settings()
if not settings.get("enabled"):
return
state = self.get_status()
if state.get("running"):
return
interval_minutes = int(settings.get("interval_minutes") or 360)
last_finished_raw = _clean(state.get("last_finished_at"))
if last_finished_raw:
try:
last_finished = datetime.fromisoformat(last_finished_raw.replace("Z", "+00:00"))
elapsed = (_utc_now() - last_finished.astimezone(UTC)).total_seconds()
if elapsed < interval_minutes * 60:
return
except Exception:
pass
self.run_backup(trigger="schedule")
def get_status(self) -> dict[str, object]:
return {
**load_backup_state(),
"running": self._running,
}
def is_configured(self) -> bool:
settings = config.get_backup_settings()
return all([
_clean(settings.get("account_id")),
_clean(settings.get("access_key_id")),
_clean(settings.get("secret_access_key")),
_clean(settings.get("bucket")),
])
def get_settings(self) -> dict[str, object]:
settings = dict(config.get_backup_settings())
settings["secret_access_key"] = "********" if _clean(settings.get("secret_access_key")) else ""
settings["passphrase"] = "********" if _clean(settings.get("passphrase")) else ""
return settings
def update_settings(self, payload: dict[str, object]) -> dict[str, object]:
current = config.get_backup_settings()
merged = dict(current)
merged.update(dict(payload or {}))
if "include" in payload and isinstance(payload.get("include"), dict):
include = dict(current.get("include") or {})
include.update(payload.get("include") or {})
merged["include"] = include
if payload.get("secret_access_key") == "********":
merged["secret_access_key"] = current.get("secret_access_key")
if payload.get("passphrase") == "********":
merged["passphrase"] = current.get("passphrase")
updated = config.update({"backup": merged})
return dict(updated.get("backup") or {})
def test_connection(self) -> dict[str, object]:
client = CloudflareR2Client(config.get_backup_settings())
try:
return client.test_connection()
finally:
client.close()
def list_backups(self) -> list[dict[str, object]]:
if not self.is_configured():
return []
client = CloudflareR2Client(config.get_backup_settings())
try:
items = client.list_objects()
finally:
client.close()
parsed: list[dict[str, object]] = []
for item in items:
key = _clean(item.get("key"))
name = key.rsplit("/", 1)[-1]
encrypted = name.endswith(".enc")
parsed.append({
"key": key,
"name": name,
"size": int(item.get("size") or 0),
"updated_at": item.get("updated_at"),
"encrypted": encrypted,
})
return parsed
def delete_backup(self, key: str) -> None:
candidate = _clean(key)
if not candidate:
raise BackupError("备份对象 key 不能为空")
client = CloudflareR2Client(config.get_backup_settings())
try:
client.delete_object(candidate)
finally:
client.close()
def download_backup(self, key: str) -> dict[str, object]:
candidate = _clean(key)
if not candidate:
raise BackupError("备份对象 key 不能为空")
client = CloudflareR2Client(config.get_backup_settings())
try:
payload = client.download_bytes(candidate)
finally:
client.close()
name = candidate.rsplit("/", 1)[-1] or "backup.bin"
if candidate.endswith(".enc"):
passphrase = _clean(config.get_backup_settings().get("passphrase"))
if not passphrase:
raise BackupError("当前未配置加密口令,无法下载并解密已加密备份")
payload = _openssl_decrypt(payload, passphrase)
if name.endswith(".enc"):
name = name[:-4] or "backup.tar.gz"
return {
"key": candidate,
"name": name,
"content_type": _guess_content_type(name),
"payload": payload,
"size": len(payload),
}
def get_backup_detail(self, key: str) -> dict[str, object]:
candidate = _clean(key)
if not candidate:
raise BackupError("备份对象 key 不能为空")
client = CloudflareR2Client(config.get_backup_settings())
try:
payload = client.download_bytes(candidate)
finally:
client.close()
detail = self._decode_backup_payload(candidate, payload)
detail["key"] = candidate
detail["name"] = candidate.rsplit("/", 1)[-1]
detail["encrypted"] = candidate.endswith(".enc")
return detail
def run_backup(self, *, trigger: str = "manual") -> dict[str, object]:
with self._lock:
current = self.get_status()
if self._running:
raise BackupError("当前已有备份任务正在执行")
started_at = _iso_now()
self._running = True
save_backup_state({
"last_started_at": started_at,
"last_finished_at": current.get("last_finished_at"),
"last_status": "idle",
"last_error": None,
"last_object_key": current.get("last_object_key"),
})
try:
result = self._run_backup_once(trigger=trigger)
save_backup_state({
"last_started_at": started_at,
"last_finished_at": _iso_now(),
"last_status": "success",
"last_error": None,
"last_object_key": result["key"],
})
return result
except Exception as exc:
save_backup_state({
"last_started_at": started_at,
"last_finished_at": _iso_now(),
"last_status": "error",
"last_error": str(exc) or exc.__class__.__name__,
"last_object_key": current.get("last_object_key"),
})
raise
finally:
self._running = False
def _run_backup_once(self, *, trigger: str) -> dict[str, object]:
settings = config.get_backup_settings()
client = CloudflareR2Client(settings)
client.validate()
payload_raw = self._build_backup_archive(settings, trigger=trigger)
encrypted = bool(settings.get("encrypt"))
if encrypted:
passphrase = _clean(settings.get("passphrase"))
if not passphrase:
raise BackupError("已启用备份加密,但未设置加密口令")
payload = _openssl_encrypt(payload_raw, passphrase)
suffix = ".tar.gz.enc"
else:
payload = payload_raw
suffix = ".tar.gz"
timestamp = _utc_now().strftime("%Y%m%dT%H%M%SZ")
random_tag = f"{random.randint(0, 0xFFFF):04x}"
object_key = f"{client.prefix.rstrip('/')}/backup-{timestamp}-{random_tag}{suffix}"
metadata = {
"created-at": _iso_now(),
"encrypted": "true" if encrypted else "false",
"trigger": trigger,
}
try:
result = client.upload_bytes(object_key, payload, content_type="application/octet-stream", metadata=metadata)
self._apply_rotation(client, int(settings.get("rotation_keep") or 0))
return {
"key": result["key"],
"size": len(payload),
"encrypted": encrypted,
}
finally:
client.close()
def _decode_backup_payload(self, key: str, payload: bytes) -> dict[str, object]:
decoded = payload
if key.endswith(".enc"):
passphrase = _clean(config.get_backup_settings().get("passphrase"))
if not passphrase:
raise BackupError("当前未配置加密口令,无法查看已加密备份")
decoded = _openssl_decrypt(decoded, passphrase)
return self._decode_archive_detail(decoded)
def _apply_rotation(self, client: CloudflareR2Client, keep: int) -> None:
if keep <= 0:
return
items = [item for item in client.list_objects() if _is_backup_object(item.get("key"))]
if len(items) <= keep:
return
for item in items[keep:]:
key = _clean(item.get("key"))
if key:
client.delete_object(key)
def _decode_archive_detail(self, payload: bytes) -> dict[str, object]:
files: list[dict[str, object]] = []
snapshots: list[dict[str, object]] = []
metadata: dict[str, object] = {}
try:
with tarfile.open(fileobj=io.BytesIO(payload), mode="r:gz") as archive:
members = [member for member in archive.getmembers() if member.isfile()]
for member in members:
extracted = archive.extractfile(member)
if extracted is None:
continue
raw = extracted.read()
name = member.name
if name == "backup-metadata.json":
try:
parsed = json.loads(raw.decode("utf-8"))
if isinstance(parsed, dict):
metadata = parsed
except Exception:
metadata = {}
continue
if name.startswith("snapshots/") and name.endswith(".json"):
count = 0
try:
parsed_snapshot = json.loads(raw.decode("utf-8"))
count = _count_items(parsed_snapshot)
except Exception:
count = 0
snapshots.append({
"name": name.removeprefix("snapshots/").removesuffix(".json"),
"count": count,
})
continue
files.append({
"name": name,
"exists": True,
"content_type": _guess_content_type(name),
"size": len(raw),
"sha256": _sha256_hex(raw),
})
except tarfile.TarError as exc:
raise BackupError("解析备份压缩包失败,备份可能已损坏") from exc
files.sort(key=lambda item: str(item.get("name") or ""))
snapshots.sort(key=lambda item: str(item.get("name") or ""))
return {
"created_at": metadata.get("created_at"),
"trigger": metadata.get("trigger"),
"app_version": metadata.get("app_version"),
"storage_backend": metadata.get("storage_backend"),
"files": files,
"snapshots": snapshots,
}
def _build_backup_archive(self, settings: dict[str, object], *, trigger: str) -> bytes:
include = settings.get("include") if isinstance(settings.get("include"), dict) else {}
metadata = {
"version": 2,
"created_at": _iso_now(),
"trigger": trigger,
"app_version": config.app_version,
"storage_backend": config.get_storage_backend().get_backend_info(),
}
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode="w:gz") as archive:
self._add_bytes_to_archive(archive, "backup-metadata.json", _json_bytes(metadata))
if include.get("config"):
self._add_file_to_archive(archive, CONFIG_FILE, "config.json")
if include.get("cpa"):
self._add_file_to_archive(archive, DATA_DIR / "cpa_config.json", "data/cpa_config.json")
if include.get("sub2api"):
self._add_file_to_archive(archive, DATA_DIR / "sub2api_config.json", "data/sub2api_config.json")
if include.get("logs"):
self._add_file_to_archive(archive, DATA_DIR / "logs.jsonl", "data/logs.jsonl")
if include.get("image_tasks"):
self._add_file_to_archive(archive, DATA_DIR / "image_tasks.json", "data/image_tasks.json")
self._add_file_to_archive(archive, IMAGE_INDEX_FILE, "data/image_index.json")
if include.get("accounts_snapshot"):
self._add_bytes_to_archive(
archive,
"snapshots/accounts.json",
_json_bytes(config.get_storage_backend().load_accounts()),
)
if include.get("auth_keys_snapshot"):
self._add_bytes_to_archive(
archive,
"snapshots/auth_keys.json",
_json_bytes(config.get_storage_backend().load_auth_keys()),
)
if include.get("images"):
self._add_file_to_archive(archive, TAGS_FILE, "data/image_tags.json")
self._add_directory_to_archive(archive, config.images_dir, "data/images")
return buffer.getvalue()
def _add_bytes_to_archive(self, archive: tarfile.TarFile, name: str, payload: bytes) -> None:
info = tarfile.TarInfo(name=name)
info.size = len(payload)
info.mtime = int(_utc_now().timestamp())
archive.addfile(info, io.BytesIO(payload))
def _add_file_to_archive(self, archive: tarfile.TarFile, source: Path, arcname: str) -> None:
if not source.exists() or not source.is_file():
return
archive.add(source, arcname=arcname)
def _add_directory_to_archive(self, archive: tarfile.TarFile, source_dir: Path, arcname_root: str) -> None:
if not source_dir.exists() or not source_dir.is_dir():
return
for path in sorted(source_dir.rglob("*")):
if path.is_file():
relative = path.relative_to(source_dir).as_posix()
archive.add(path, arcname=f"{arcname_root}/{relative}")
backup_service = BackupService()
+667
View File
@@ -0,0 +1,667 @@
from __future__ import annotations
import copy
from dataclasses import dataclass
import json
import os
import sys
from pathlib import Path
import time
from services.storage.base import StorageBackend
BASE_DIR = Path(__file__).resolve().parents[1]
DATA_DIR = BASE_DIR / "data"
CONFIG_FILE = BASE_DIR / "config.json"
VERSION_FILE = BASE_DIR / "VERSION"
BACKUP_STATE_FILE = DATA_DIR / "backup_state.json"
DEFAULT_BACKUP_INCLUDE = {
"config": True,
"cpa": True,
"sub2api": True,
"logs": True,
"image_tasks": True,
"accounts_snapshot": True,
"auth_keys_snapshot": True,
"images": False,
}
DEFAULT_IMAGE_STORAGE = {
"enabled": False,
"mode": "local",
"webdav_url": "",
"webdav_username": "",
"webdav_password": "",
"webdav_root_path": "chatgpt2api/images",
"public_base_url": "",
}
DEFAULT_CHAT_COMPLETION_CACHE = {
"enabled": True,
"ttl_seconds": 60,
"max_entries": 256,
"dedupe_inflight": True,
"stream_cache": True,
"normalize_messages": True,
"drop_adjacent_duplicates": True,
"drop_assistant_history": False,
}
DEFAULT_PROXY_RUNTIME_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/145.0.0.0 Safari/537.36"
)
DEFAULT_PROXY_RUNTIME = {
"enabled": False,
"egress_mode": "direct",
"proxy_url": "",
"resource_proxy_url": "",
"skip_ssl_verify": False,
"reset_session_status_codes": [403],
"clearance": {
"enabled": False,
"mode": "none",
"cf_cookies": "",
"cf_clearance": "",
"user_agent": DEFAULT_PROXY_RUNTIME_USER_AGENT,
"browser": "chrome",
"flaresolverr_url": "",
"timeout_sec": 60,
"refresh_interval": 3600,
"warm_up_on_start": False,
},
}
DEFAULT_THIRD_PARTY_APPS = {
"infinite_canvas": {
"enabled": False,
"url": "https://canvas.best",
},
}
def _normalize_bool(value: object, default: bool = False) -> bool:
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in {"1", "true", "yes", "on"}:
return True
if lowered in {"0", "false", "no", "off"}:
return False
return default
if value is None:
return default
return bool(value)
def _normalize_positive_int(value: object, default: int, minimum: int = 0) -> int:
try:
normalized = int(value)
except (OverflowError, TypeError, ValueError):
normalized = default
return max(minimum, normalized)
def _normalize_backup_include(value: object) -> dict[str, bool]:
source = value if isinstance(value, dict) else {}
normalized = dict(DEFAULT_BACKUP_INCLUDE)
for key in normalized:
normalized[key] = _normalize_bool(source.get(key), normalized[key])
return normalized
def _normalize_backup_settings(value: object) -> dict[str, object]:
source = value if isinstance(value, dict) else {}
return {
"enabled": _normalize_bool(source.get("enabled"), False),
"provider": "cloudflare_r2",
"account_id": str(source.get("account_id") or "").strip(),
"access_key_id": str(source.get("access_key_id") or "").strip(),
"secret_access_key": str(source.get("secret_access_key") or "").strip(),
"bucket": str(source.get("bucket") or "").strip(),
"prefix": str(source.get("prefix") or "backups").strip().strip("/") or "backups",
"interval_minutes": _normalize_positive_int(source.get("interval_minutes"), 360, 1),
"rotation_keep": _normalize_positive_int(source.get("rotation_keep"), 10, 0),
"encrypt": _normalize_bool(source.get("encrypt"), False),
"passphrase": str(source.get("passphrase") or "").strip(),
"include": _normalize_backup_include(source.get("include")),
}
def _normalize_backup_state(value: object) -> dict[str, object]:
source = value if isinstance(value, dict) else {}
return {
"last_started_at": str(source.get("last_started_at") or "").strip() or None,
"last_finished_at": str(source.get("last_finished_at") or "").strip() or None,
"last_status": str(source.get("last_status") or "idle").strip() or "idle",
"last_error": str(source.get("last_error") or "").strip() or None,
"last_object_key": str(source.get("last_object_key") or "").strip() or None,
}
def _normalize_image_storage_settings(value: object) -> dict[str, object]:
source = value if isinstance(value, dict) else {}
mode = str(source.get("mode") or "local").strip().lower()
if mode not in {"local", "webdav", "both"}:
mode = "local"
enabled = _normalize_bool(source.get("enabled"), False)
if not enabled:
mode = "local"
root_path = str(source.get("webdav_root_path") or DEFAULT_IMAGE_STORAGE["webdav_root_path"]).strip().strip("/")
return {
"enabled": enabled,
"mode": mode,
"webdav_url": str(source.get("webdav_url") or "").strip().rstrip("/"),
"webdav_username": str(source.get("webdav_username") or "").strip(),
"webdav_password": str(source.get("webdav_password") or "").strip(),
"webdav_root_path": root_path or str(DEFAULT_IMAGE_STORAGE["webdav_root_path"]),
"public_base_url": str(source.get("public_base_url") or "").strip().rstrip("/"),
}
def _normalize_chat_completion_cache_settings(value: object) -> dict[str, object]:
source = value if isinstance(value, dict) else {}
return {
"enabled": _normalize_bool(source.get("enabled"), DEFAULT_CHAT_COMPLETION_CACHE["enabled"]),
"ttl_seconds": _normalize_positive_int(
source.get("ttl_seconds"),
int(DEFAULT_CHAT_COMPLETION_CACHE["ttl_seconds"]),
0,
),
"max_entries": _normalize_positive_int(
source.get("max_entries"),
int(DEFAULT_CHAT_COMPLETION_CACHE["max_entries"]),
1,
),
"dedupe_inflight": _normalize_bool(
source.get("dedupe_inflight"),
bool(DEFAULT_CHAT_COMPLETION_CACHE["dedupe_inflight"]),
),
"stream_cache": _normalize_bool(
source.get("stream_cache"),
bool(DEFAULT_CHAT_COMPLETION_CACHE["stream_cache"]),
),
"normalize_messages": _normalize_bool(
source.get("normalize_messages"),
bool(DEFAULT_CHAT_COMPLETION_CACHE["normalize_messages"]),
),
"drop_adjacent_duplicates": _normalize_bool(
source.get("drop_adjacent_duplicates"),
bool(DEFAULT_CHAT_COMPLETION_CACHE["drop_adjacent_duplicates"]),
),
"drop_assistant_history": _normalize_bool(
source.get("drop_assistant_history"),
bool(DEFAULT_CHAT_COMPLETION_CACHE["drop_assistant_history"]),
),
}
def _normalize_status_codes(value: object) -> list[int]:
items = value if isinstance(value, list) else DEFAULT_PROXY_RUNTIME["reset_session_status_codes"]
normalized: list[int] = []
for item in items:
if isinstance(item, bool):
continue
try:
status = int(item)
except (OverflowError, TypeError, ValueError):
continue
if 100 <= status <= 599 and status not in normalized:
normalized.append(status)
if not normalized:
return list(DEFAULT_PROXY_RUNTIME["reset_session_status_codes"])
return normalized
def _normalize_proxy_runtime_settings(value: object) -> dict[str, object]:
source = value if isinstance(value, dict) else {}
default_clearance = DEFAULT_PROXY_RUNTIME["clearance"]
clearance_source = source.get("clearance") if isinstance(source.get("clearance"), dict) else {}
egress_mode = str(source.get("egress_mode") or DEFAULT_PROXY_RUNTIME["egress_mode"]).strip().lower()
if egress_mode not in {"direct", "single_proxy"}:
egress_mode = str(DEFAULT_PROXY_RUNTIME["egress_mode"])
clearance_mode = str(clearance_source.get("mode") or default_clearance["mode"]).strip().lower()
if clearance_mode not in {"none", "manual", "flaresolverr"}:
clearance_mode = str(default_clearance["mode"])
user_agent = str(clearance_source.get("user_agent") or default_clearance["user_agent"]).strip()
browser = str(clearance_source.get("browser") or default_clearance["browser"]).strip()
existing_clearance_cookies = str(source.get("_existing_cf_cookies") or "").strip()
existing_cf_clearance = str(source.get("_existing_cf_clearance") or "").strip()
cf_cookies = str(clearance_source.get("cf_cookies") or "").strip()
cf_clearance = str(clearance_source.get("cf_clearance") or "").strip()
if not cf_cookies and _normalize_bool(clearance_source.get("has_cf_cookies"), False):
cf_cookies = existing_clearance_cookies
if not cf_clearance and _normalize_bool(clearance_source.get("has_cf_clearance"), False):
cf_clearance = existing_cf_clearance
return {
"enabled": _normalize_bool(source.get("enabled"), bool(DEFAULT_PROXY_RUNTIME["enabled"])),
"egress_mode": egress_mode,
"proxy_url": str(source.get("proxy_url") or "").strip(),
"resource_proxy_url": str(source.get("resource_proxy_url") or "").strip(),
"skip_ssl_verify": _normalize_bool(
source.get("skip_ssl_verify"),
bool(DEFAULT_PROXY_RUNTIME["skip_ssl_verify"]),
),
"reset_session_status_codes": _normalize_status_codes(source.get("reset_session_status_codes")),
"clearance": {
"enabled": _normalize_bool(clearance_source.get("enabled"), bool(default_clearance["enabled"])),
"mode": clearance_mode,
"cf_cookies": cf_cookies,
"cf_clearance": cf_clearance,
"user_agent": user_agent or str(default_clearance["user_agent"]),
"browser": browser or str(default_clearance["browser"]),
"flaresolverr_url": str(clearance_source.get("flaresolverr_url") or "").strip(),
"timeout_sec": _normalize_positive_int(
clearance_source.get("timeout_sec"),
int(default_clearance["timeout_sec"]),
1,
),
"refresh_interval": _normalize_positive_int(
clearance_source.get("refresh_interval"),
int(default_clearance["refresh_interval"]),
60,
),
"warm_up_on_start": _normalize_bool(
clearance_source.get("warm_up_on_start"),
bool(default_clearance["warm_up_on_start"]),
),
},
}
def _normalize_third_party_apps_settings(value: object) -> dict[str, object]:
source = value if isinstance(value, dict) else {}
canvas_source = source.get("infinite_canvas") if isinstance(source.get("infinite_canvas"), dict) else {}
return {
"infinite_canvas": {
"enabled": _normalize_bool(canvas_source.get("enabled"), False),
"url": str(canvas_source.get("url") or DEFAULT_THIRD_PARTY_APPS["infinite_canvas"]["url"]).strip(),
},
}
def _validate_image_storage_settings(settings: dict[str, object]) -> None:
if not _normalize_bool(settings.get("enabled"), False):
return
if not str(settings.get("webdav_url") or "").strip():
raise ValueError("启用 WebDAV 图片存储后必须填写 WebDAV URL")
if not str(settings.get("webdav_password") or "").strip():
raise ValueError("启用 WebDAV 图片存储后必须填写 WebDAV 密码")
@dataclass(frozen=True)
class LoadedSettings:
auth_key: str
refresh_account_interval_minute: int
def _normalize_auth_key(value: object) -> str:
return str(value or "").strip()
def _is_invalid_auth_key(value: object) -> bool:
return _normalize_auth_key(value) == ""
def _read_json_object(path: Path, *, name: str) -> dict[str, object]:
if not path.exists():
return {}
if path.is_dir():
print(
f"Warning: {name} at '{path}' is a directory, ignoring it and falling back to other configuration sources.",
file=sys.stderr,
)
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
return data if isinstance(data, dict) else {}
def _load_settings() -> LoadedSettings:
DATA_DIR.mkdir(parents=True, exist_ok=True)
raw_config = _read_json_object(CONFIG_FILE, name="config.json")
auth_key = _normalize_auth_key(os.getenv("CHATGPT2API_AUTH_KEY") or raw_config.get("auth-key"))
if _is_invalid_auth_key(auth_key):
raise ValueError(
"❌ auth-key 未设置!\n"
"请在环境变量 CHATGPT2API_AUTH_KEY 中设置,或者在 config.json 中填写 auth-key。"
)
try:
refresh_interval = int(raw_config.get("refresh_account_interval_minute", 5))
except (TypeError, ValueError):
refresh_interval = 5
return LoadedSettings(
auth_key=auth_key,
refresh_account_interval_minute=refresh_interval,
)
class ConfigStore:
def __init__(self, path: Path):
self.path = path
DATA_DIR.mkdir(parents=True, exist_ok=True)
self.data = self._load()
self._storage_backend: StorageBackend | None = None
if _is_invalid_auth_key(self.auth_key):
raise ValueError(
"❌ auth-key 未设置!\n"
"请按以下任意一种方式解决:\n"
"1. 在 Render 的 Environment 变量中添加:\n"
" CHATGPT2API_AUTH_KEY = your_real_auth_key\n"
"2. 或者在 config.json 中填写:\n"
' "auth-key": "your_real_auth_key"'
)
def _load(self) -> dict[str, object]:
return _read_json_object(self.path, name="config.json")
def _save(self) -> None:
self.path.write_text(json.dumps(self.data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
@property
def auth_key(self) -> str:
return _normalize_auth_key(os.getenv("CHATGPT2API_AUTH_KEY") or self.data.get("auth-key"))
@property
def accounts_file(self) -> Path:
return DATA_DIR / "accounts.json"
@property
def refresh_account_interval_minute(self) -> int:
try:
return int(self.data.get("refresh_account_interval_minute", 5))
except (TypeError, ValueError):
return 5
@property
def image_retention_days(self) -> int:
try:
return max(1, int(self.data.get("image_retention_days", 30)))
except (TypeError, ValueError):
return 30
@property
def image_poll_timeout_secs(self) -> int:
try:
return max(1, int(self.data.get("image_poll_timeout_secs", 120)))
except (TypeError, ValueError):
return 120
@property
def image_poll_interval_secs(self) -> float:
try:
return max(0.5, float(self.data.get("image_poll_interval_secs", 10.0)))
except (TypeError, ValueError):
return 10.0
@property
def image_poll_initial_wait_secs(self) -> float:
"""Image generation upstream takes ~30s; polling immediately wastes requests
and trips a transient 429. Default 10s gives the conversation document time
to commit before the first poll."""
try:
return max(0.0, float(self.data.get("image_poll_initial_wait_secs", 10.0)))
except (TypeError, ValueError):
return 10.0
@property
def image_account_concurrency(self) -> int:
try:
return max(1, int(self.data.get("image_account_concurrency", 3)))
except (TypeError, ValueError):
return 3
@property
def image_parallel_generation(self) -> bool:
value = self.data.get("image_parallel_generation", True)
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
@property
def image_settle_enabled(self) -> bool:
"""图片二次确认机制:找到 file_ids 后等待一段时间再次确认。"""
value = self.data.get("image_settle_enabled", True)
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
@property
def image_check_before_hit_enabled(self) -> bool:
"""先check再hit:通过轮询确认 file_ids 存在后再返回,而非仅依赖 SSE 事件。"""
value = self.data.get("image_check_before_hit_enabled", True)
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
@property
def image_remove_conversation_after_result(self) -> bool:
"""出图成功后异步隐藏 ChatGPT 本地对话记录。"""
value = self.data.get("image_remove_conversation_after_result", False)
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
@property
def image_remove_conversation_always(self) -> bool:
"""无论是否出图,画图请求结束后都异步隐藏 ChatGPT 本地对话记录。"""
return _normalize_bool(self.data.get("image_remove_conversation_always"), False)
@property
def image_settle_secs(self) -> float:
"""二次确认等待时间(秒)。"""
try:
return max(0.5, float(self.data.get("image_settle_secs", 2.0)))
except (TypeError, ValueError):
return 2.0
@property
def auto_remove_invalid_accounts(self) -> bool:
value = self.data.get("auto_remove_invalid_accounts", False)
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
@property
def auto_remove_rate_limited_accounts(self) -> bool:
value = self.data.get("auto_remove_rate_limited_accounts", False)
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
@property
def auto_relogin_after_refresh(self) -> bool:
value = self.data.get("auto_relogin_after_refresh", False)
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
@property
def log_levels(self) -> list[str]:
levels = self.data.get("log_levels")
if not isinstance(levels, list):
return []
allowed = {"debug", "info", "warning", "error"}
return [level for item in levels if (level := str(item or "").strip().lower()) in allowed]
@property
def sensitive_words(self) -> list[str]:
words = self.data.get("sensitive_words")
return [word for item in words if (word := str(item or "").strip())] if isinstance(words, list) else []
@property
def ai_review(self) -> dict[str, object]:
value = self.data.get("ai_review")
return value if isinstance(value, dict) else {}
@property
def global_system_prompt(self) -> str:
return str(self.data.get("global_system_prompt") or "").strip()
@property
def default_upstream_model_name(self) -> str:
return str(self.data.get("default_upstream_model_name") or "gpt-5-5").strip()
@property
def default_thinking_effort(self) -> str:
value = str(self.data.get("default_thinking_effort") or "auto").strip().lower()
return value if value in {"auto", "standard", "extended", "max"} else "auto"
@property
def images_dir(self) -> Path:
path = DATA_DIR / "images"
path.mkdir(parents=True, exist_ok=True)
return path
@property
def image_thumbnails_dir(self) -> Path:
path = DATA_DIR / "image_thumbnails"
path.mkdir(parents=True, exist_ok=True)
return path
def cleanup_old_images(self) -> int:
cutoff = time.time() - self.image_retention_days * 86400
removed = 0
for path in self.images_dir.rglob("*"):
if path.is_file() and path.stat().st_mtime < cutoff:
path.unlink()
removed += 1
for path in sorted((p for p in self.images_dir.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True):
try:
path.rmdir()
except OSError:
pass
return removed
@property
def base_url(self) -> str:
return str(
os.getenv("CHATGPT2API_BASE_URL")
or self.data.get("base_url")
or ""
).strip().rstrip("/")
@property
def app_version(self) -> str:
try:
value = VERSION_FILE.read_text(encoding="utf-8").strip()
except FileNotFoundError:
return "0.0.0"
return value or "0.0.0"
def get(self) -> dict[str, object]:
data = dict(self.data)
data["refresh_account_interval_minute"] = self.refresh_account_interval_minute
data["image_retention_days"] = self.image_retention_days
data["image_poll_timeout_secs"] = self.image_poll_timeout_secs
data["image_poll_interval_secs"] = self.image_poll_interval_secs
data["image_poll_initial_wait_secs"] = self.image_poll_initial_wait_secs
data["image_account_concurrency"] = self.image_account_concurrency
data["image_parallel_generation"] = self.image_parallel_generation
data["image_remove_conversation_after_result"] = self.image_remove_conversation_after_result
data["image_remove_conversation_always"] = self.image_remove_conversation_always
data["auto_remove_invalid_accounts"] = self.auto_remove_invalid_accounts
data["auto_remove_rate_limited_accounts"] = self.auto_remove_rate_limited_accounts
data["auto_relogin_after_refresh"] = self.auto_relogin_after_refresh
data["log_levels"] = self.log_levels
data["sensitive_words"] = self.sensitive_words
data["ai_review"] = self.ai_review
data["global_system_prompt"] = self.global_system_prompt
data["default_upstream_model_name"] = self.default_upstream_model_name
data["default_thinking_effort"] = self.default_thinking_effort
data["backup"] = self.get_backup_settings()
data["image_storage"] = self.get_image_storage_settings()
data["chat_completion_cache"] = self.get_chat_completion_cache_settings()
data["proxy_runtime"] = self.get_public_proxy_runtime_settings()
data["third_party_apps"] = self.get_third_party_apps_settings()
data.pop("auth-key", None)
return data
def get_proxy_settings(self) -> str:
return str(self.data.get("proxy") or "").strip()
def get_proxy_runtime_settings(self) -> dict[str, object]:
return _normalize_proxy_runtime_settings(self.data.get("proxy_runtime"))
def get_public_proxy_runtime_settings(self) -> dict[str, object]:
runtime = copy.deepcopy(self.get_proxy_runtime_settings())
clearance = runtime.get("clearance") if isinstance(runtime.get("clearance"), dict) else {}
if isinstance(clearance, dict):
cf_cookies = str(clearance.get("cf_cookies") or "").strip()
cf_clearance = str(clearance.get("cf_clearance") or "").strip()
clearance["cf_cookies"] = ""
clearance["cf_clearance"] = ""
clearance["has_cf_cookies"] = bool(cf_cookies)
clearance["has_cf_clearance"] = bool(cf_clearance)
return runtime
def get_third_party_apps_settings(self) -> dict[str, object]:
return _normalize_third_party_apps_settings(self.data.get("third_party_apps"))
def update(self, data: dict[str, object]) -> dict[str, object]:
next_data = dict(self.data)
next_data.update(dict(data or {}))
if "backup" in next_data:
next_data["backup"] = _normalize_backup_settings(next_data.get("backup"))
if "image_storage" in next_data:
next_data["image_storage"] = _normalize_image_storage_settings(next_data.get("image_storage"))
_validate_image_storage_settings(next_data["image_storage"])
if "chat_completion_cache" in next_data:
next_data["chat_completion_cache"] = _normalize_chat_completion_cache_settings(
next_data.get("chat_completion_cache")
)
if "third_party_apps" in next_data:
next_data["third_party_apps"] = _normalize_third_party_apps_settings(next_data.get("third_party_apps"))
if "proxy_runtime" in next_data:
incoming_runtime = next_data.get("proxy_runtime")
if isinstance(incoming_runtime, dict):
previous_clearance = self.get_proxy_runtime_settings().get("clearance")
if isinstance(previous_clearance, dict):
incoming_runtime = dict(incoming_runtime)
incoming_runtime["_existing_cf_cookies"] = previous_clearance.get("cf_cookies")
incoming_runtime["_existing_cf_clearance"] = previous_clearance.get("cf_clearance")
next_data["proxy_runtime"] = _normalize_proxy_runtime_settings(incoming_runtime)
next_data.pop("backup_state", None)
self.data = next_data
self._save()
return self.get()
def get_backup_settings(self) -> dict[str, object]:
return _normalize_backup_settings(self.data.get("backup"))
def get_image_storage_settings(self) -> dict[str, object]:
return _normalize_image_storage_settings(self.data.get("image_storage"))
def get_chat_completion_cache_settings(self) -> dict[str, object]:
return _normalize_chat_completion_cache_settings(self.data.get("chat_completion_cache"))
def get_storage_backend(self) -> StorageBackend:
"""获取存储后端实例(单例)"""
if self._storage_backend is None:
from services.storage.factory import create_storage_backend
self._storage_backend = create_storage_backend(DATA_DIR)
return self._storage_backend
def load_backup_state() -> dict[str, object]:
return _normalize_backup_state(_read_json_object(BACKUP_STATE_FILE, name="backup_state.json"))
def save_backup_state(state: dict[str, object]) -> dict[str, object]:
normalized = _normalize_backup_state(state)
BACKUP_STATE_FILE.write_text(json.dumps(normalized, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return normalized
config = ConfigStore(CONFIG_FILE)
+242
View File
@@ -0,0 +1,242 @@
from __future__ import annotations
import re
from curl_cffi import requests
from fastapi import HTTPException
from services.config import config
from services.proxy_service import proxy_settings
from utils.log import logger
DEFAULT_REVIEW_PROMPT = "判断用户请求是否允许。只回答 ALLOW 或 REJECT。"
# Strip base64 image data URIs before review: a text-only review model can't
# analyze image bytes, and a single inlined image easily blows past the token
# budget of the upstream review service.
_BASE64_DATA_URI = re.compile(r"data:[\w/.+;-]+;base64,[A-Za-z0-9+/=]+")
# Cap aligned to the upstream review service's max context. If text still
# exceeds the cap after base64 stripping, keep equal head/tail halves so both
# the system prompt and the most recent user message survive.
_MAX_REVIEW_TEXT_LEN = 100_000
_TRUNCATION_MARKER = "\n…[truncated]…\n"
def _text(value: object) -> str:
if isinstance(value, str):
return value
if isinstance(value, list):
return "\n".join(_text(item) for item in value)
if isinstance(value, dict):
return "\n".join(_text(value.get(key)) for key in ("text", "input_text", "content", "input", "instructions", "system", "prompt"))
return ""
def request_text(*values: object) -> str:
return "\n".join(part for value in values if (part := _text(value).strip()))
def request_shape(*values: object) -> dict[str, int]:
"""Return a safe structural summary without logging prompts or image bytes."""
stats = {
"response_message_items": 0,
"input_image_parts": 0,
"image_url_parts": 0,
"image_parts": 0,
"data_url_images": 0,
"remote_image_urls": 0,
"literal_image_placeholders": 0,
}
def walk(value: object, key: str = "") -> None:
if isinstance(value, str):
text = value.strip()
lower = text.lower()
if "<image>" in lower:
stats["literal_image_placeholders"] += 1
if lower.startswith("data:image/"):
stats["data_url_images"] += 1
elif key in {"image_url", "url"} and lower.startswith(("http://", "https://")):
stats["remote_image_urls"] += 1
return
if isinstance(value, list):
for item in value:
walk(item, key)
return
if not isinstance(value, dict):
return
item_type = str(value.get("type") or "").strip()
if item_type == "message":
stats["response_message_items"] += 1
elif item_type == "input_image":
stats["input_image_parts"] += 1
elif item_type == "image_url":
stats["image_url_parts"] += 1
elif item_type == "image":
stats["image_parts"] += 1
for child_key, child in value.items():
walk(child, str(child_key))
for value in values:
walk(value)
return {key: value for key, value in stats.items() if value}
def _sanitize_for_review(text: str) -> tuple[str, dict[str, int]]:
"""Strip base64 data URIs and truncate to the review-service context limit.
Returns (sanitized_text, stats) where stats carries base64_blocks_stripped
and truncated_chars so callers can emit structured logs.
"""
sanitized, base64_blocks_stripped = _BASE64_DATA_URI.subn("[image]", text)
truncated_chars = 0
if len(sanitized) > _MAX_REVIEW_TEXT_LEN:
# Reserve marker space so the result stays within the cap.
half = (_MAX_REVIEW_TEXT_LEN - len(_TRUNCATION_MARKER)) // 2
truncated_chars = len(sanitized) - 2 * half
sanitized = sanitized[:half] + _TRUNCATION_MARKER + sanitized[-half:]
stats = {
"base64_blocks_stripped": base64_blocks_stripped,
"truncated_chars": truncated_chars,
}
return sanitized, stats
def _extract_review_decision(data: object) -> str | None:
"""Defensively pull the decision text out of the review service response.
Returns None when the response shape doesn't match the OpenAI chat-completion
contract (e.g. {"error": ...} with no choices). The caller treats None as
"undecided" and applies the configured fail-open policy.
"""
if not isinstance(data, dict):
return None
choices = data.get("choices")
if not isinstance(choices, list) or not choices:
return None
first = choices[0]
if not isinstance(first, dict):
return None
message = first.get("message")
if not isinstance(message, dict):
return None
content = message.get("content")
if content is None:
return None
return str(content).strip().lower()
def _is_allow_decision(decision: str) -> bool:
return decision.startswith(("allow", "pass", "true", "yes", "通过", "允许", "安全"))
def _is_reject_decision(decision: str) -> bool:
return decision.startswith(("reject", "deny", "block", "false", "no", "拒绝", "不允许", "违规", "禁止"))
def _resolve_fail_open(review: dict) -> bool:
"""Resolve fail_open from review config. Defaults to True."""
value = review.get("fail_open")
if value is None:
return True
if isinstance(value, bool):
return value
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
def check_request(text: str) -> None:
text = str(text or "")
if not text.strip():
return
# Local sensitive-word match runs on the raw text (cheap, no network).
for word in config.sensitive_words:
if word in text:
raise HTTPException(status_code=400, detail={"error": "检测到敏感词,拒绝本次任务"})
review = config.ai_review
if not review.get("enabled"):
return
base_url = str(review.get("base_url") or "").strip().rstrip("/")
api_key = str(review.get("api_key") or "").strip()
model = str(review.get("model") or "").strip()
if not base_url or not api_key or not model:
raise HTTPException(status_code=400, detail={"error": "ai review config is incomplete"})
fail_open = _resolve_fail_open(review)
review_text, sanitize_stats = _sanitize_for_review(text)
if sanitize_stats["base64_blocks_stripped"] or sanitize_stats["truncated_chars"]:
logger.info({
"event": "ai_review_text_sanitized",
"original_text_len": len(text),
"review_text_len": len(review_text),
**sanitize_stats,
})
prompt = str(review.get("prompt") or DEFAULT_REVIEW_PROMPT).strip()
content = f"{prompt}\n\n用户请求:\n{review_text}\n\n只回答 ALLOW 或 REJECT。"
# fail_open=True (default): on upstream failure or ambiguous reply, let the
# request through. The review is a soft safety net; one missed review is
# preferable to a 5xx storm when the review service is flaky. Set
# config.ai_review.fail_open=false for strict-compliance deployments.
def _on_failure(event_payload: dict) -> None:
logger.warning(event_payload)
if not fail_open:
raise HTTPException(
status_code=503,
detail={"error": "AI 审核服务暂时不可用,请稍后重试"},
)
try:
response = requests.post(
f"{base_url}/v1/chat/completions",
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
json={"model": model, "messages": [{"role": "user", "content": content}], "temperature": 0},
timeout=60,
**proxy_settings.build_session_kwargs(),
)
except Exception as exc:
_on_failure({
"event": "ai_review_request_failed",
"error": str(exc),
"error_type": exc.__class__.__name__,
"review_text_len": len(review_text),
"original_text_len": len(text),
})
return
try:
data = response.json()
except Exception as exc:
_on_failure({
"event": "ai_review_response_not_json",
"status_code": response.status_code,
"body_preview": str(response.text or "")[:200],
"error": str(exc),
})
return
decision = _extract_review_decision(data)
if decision is None:
_on_failure({
"event": "ai_review_malformed_response",
"status_code": response.status_code,
"body_preview": str(data)[:300],
"review_text_len": len(review_text),
"original_text_len": len(text),
})
return
if _is_allow_decision(decision):
return
if _is_reject_decision(decision):
raise HTTPException(status_code=400, detail={"error": "AI 审核未通过,拒绝本次任务"})
# Ambiguous decisions (e.g. "MAYBE", empty content) fall back to fail-open policy.
_on_failure({
"event": "ai_review_ambiguous_decision",
"decision": decision[:100],
"review_text_len": len(review_text),
})
return
+315
View File
@@ -0,0 +1,315 @@
"""CLIProxyAPI integration for browsing remote auth files and importing selected tokens."""
from __future__ import annotations
import json
import threading
import uuid
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timezone
from pathlib import Path
from threading import Lock
from curl_cffi.requests import Session
from services.account_service import account_service
from services.config import DATA_DIR
from services.proxy_service import proxy_settings
CPA_CONFIG_FILE = DATA_DIR / "cpa_config.json"
def _new_id() -> str:
return uuid.uuid4().hex[:12]
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _normalize_import_job(raw: object, *, fail_unfinished: bool) -> dict | None:
if not isinstance(raw, dict):
return None
status = str(raw.get("status") or "failed").strip() or "failed"
if fail_unfinished and status in {"pending", "running"}:
status = "failed"
return {
"job_id": str(raw.get("job_id") or uuid.uuid4().hex).strip(),
"status": status,
"created_at": str(raw.get("created_at") or _now_iso()).strip() or _now_iso(),
"updated_at": str(raw.get("updated_at") or raw.get("created_at") or _now_iso()).strip() or _now_iso(),
"total": int(raw.get("total") or 0),
"completed": int(raw.get("completed") or 0),
"added": int(raw.get("added") or 0),
"skipped": int(raw.get("skipped") or 0),
"refreshed": int(raw.get("refreshed") or 0),
"failed": int(raw.get("failed") or 0),
"errors": raw.get("errors") if isinstance(raw.get("errors"), list) else [],
}
def _normalize_pool(raw: dict) -> dict:
return {
"id": str(raw.get("id") or _new_id()).strip(),
"name": str(raw.get("name") or "").strip(),
"base_url": str(raw.get("base_url") or "").strip(),
"secret_key": str(raw.get("secret_key") or "").strip(),
"import_job": _normalize_import_job(raw.get("import_job"), fail_unfinished=True),
}
def _management_headers(secret_key: str) -> dict[str, str]:
return {
"Authorization": f"Bearer {secret_key}",
"Accept": "application/json",
}
class CPAConfig:
def __init__(self, store_file: Path):
self._store_file = store_file
self._lock = Lock()
self._pools: list[dict] = self._load()
def _load(self) -> list[dict]:
if not self._store_file.exists():
return []
try:
raw = json.loads(self._store_file.read_text(encoding="utf-8"))
if isinstance(raw, dict) and "base_url" in raw:
pool = _normalize_pool(raw)
return [pool] if pool["base_url"] else []
if isinstance(raw, list):
return [_normalize_pool(item) for item in raw if isinstance(item, dict)]
except Exception:
pass
return []
def _save(self) -> None:
self._store_file.parent.mkdir(parents=True, exist_ok=True)
self._store_file.write_text(json.dumps(self._pools, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def list_pools(self) -> list[dict]:
with self._lock:
return [dict(pool) for pool in self._pools]
def get_pool(self, pool_id: str) -> dict | None:
with self._lock:
for pool in self._pools:
if pool["id"] == pool_id:
return dict(pool)
return None
def add_pool(self, name: str, base_url: str, secret_key: str) -> dict:
pool = _normalize_pool({"id": _new_id(), "name": name, "base_url": base_url, "secret_key": secret_key})
with self._lock:
self._pools.append(pool)
self._save()
return dict(pool)
def update_pool(self, pool_id: str, updates: dict) -> dict | None:
with self._lock:
for index, pool in enumerate(self._pools):
if pool["id"] != pool_id:
continue
merged = {**pool, **{key: value for key, value in updates.items() if value is not None}, "id": pool_id}
self._pools[index] = _normalize_pool(merged)
self._save()
return dict(self._pools[index])
return None
def delete_pool(self, pool_id: str) -> bool:
with self._lock:
before = len(self._pools)
self._pools = [pool for pool in self._pools if pool["id"] != pool_id]
if len(self._pools) < before:
self._save()
return True
return False
def set_import_job(self, pool_id: str, import_job: dict | None) -> dict | None:
with self._lock:
for index, pool in enumerate(self._pools):
if pool["id"] != pool_id:
continue
next_pool = dict(pool)
next_pool["import_job"] = _normalize_import_job(import_job, fail_unfinished=False)
self._pools[index] = next_pool
self._save()
return dict(next_pool)
return None
def get_import_job(self, pool_id: str) -> dict | None:
with self._lock:
for pool in self._pools:
if pool["id"] == pool_id:
job = pool.get("import_job")
return dict(job) if isinstance(job, dict) else None
return None
def list_remote_files(pool: dict) -> list[dict]:
base_url = str(pool.get("base_url") or "").strip()
secret_key = str(pool.get("secret_key") or "").strip()
if not base_url or not secret_key:
return []
url = f"{base_url.rstrip('/')}/v0/management/auth-files"
session = Session(**proxy_settings.build_session_kwargs(verify=True))
try:
response = session.get(url, headers=_management_headers(secret_key), timeout=30)
if not response.ok:
raise RuntimeError(f"remote list failed: HTTP {response.status_code}")
payload = response.json()
finally:
session.close()
files = payload.get("files") if isinstance(payload, dict) else None
if not isinstance(files, list):
raise RuntimeError("remote list payload is invalid")
items: list[dict] = []
for item in files:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").strip()
email = str(item.get("email") or item.get("account") or "").strip()
if not name:
continue
items.append({"name": name, "email": email})
return items
def fetch_remote_access_token(pool: dict, file_name: str) -> tuple[str | None, str | None]:
base_url = str(pool.get("base_url") or "").strip()
secret_key = str(pool.get("secret_key") or "").strip()
file_name = str(file_name or "").strip()
if not base_url or not secret_key or not file_name:
return None, "invalid request"
url = f"{base_url.rstrip('/')}/v0/management/auth-files/download"
session = Session(**proxy_settings.build_session_kwargs(verify=True))
try:
response = session.get(url, headers=_management_headers(secret_key), params={"name": file_name}, timeout=30)
if not response.ok:
return None, f"HTTP {response.status_code}"
payload = response.json()
except Exception as exc:
return None, str(exc)
finally:
session.close()
if not isinstance(payload, dict):
return None, "invalid payload"
access_token = str(payload.get("access_token") or "").strip()
if not access_token:
return None, "missing access_token"
return access_token, None
class CPAImportService:
def __init__(self, cpa_config: CPAConfig):
self._config = cpa_config
def start_import(self, pool: dict, selected_files: list[str]) -> dict:
names = [str(name or "").strip() for name in selected_files if str(name or "").strip()]
if not names:
raise ValueError("selected files is required")
pool_id = str(pool.get("id") or "").strip()
job = {
"job_id": uuid.uuid4().hex,
"status": "pending",
"created_at": _now_iso(),
"updated_at": _now_iso(),
"total": len(names),
"completed": 0,
"added": 0,
"skipped": 0,
"refreshed": 0,
"failed": 0,
"errors": [],
}
saved_pool = self._config.set_import_job(pool_id, job)
if saved_pool is None:
raise ValueError("pool not found")
thread = threading.Thread(
target=self._run_import,
args=(pool_id, pool, names),
name=f"cpa-import-{pool_id}",
daemon=True,
)
thread.start()
return dict(saved_pool.get("import_job") or job)
def _update_job(self, pool_id: str, **updates) -> dict | None:
current = self._config.get_import_job(pool_id)
if current is None:
return None
next_job = {**current, **updates, "updated_at": _now_iso()}
pool = self._config.set_import_job(pool_id, next_job)
if pool is None:
return None
job = pool.get("import_job")
return dict(job) if isinstance(job, dict) else None
def _append_error(self, pool_id: str, file_name: str, message: str) -> None:
current = self._config.get_import_job(pool_id)
if current is None:
return
errors = list(current.get("errors") or [])
errors.append({"name": file_name, "error": message})
self._update_job(pool_id, errors=errors, failed=len(errors))
def _run_import(self, pool_id: str, pool: dict, names: list[str]) -> None:
self._update_job(pool_id, status="running")
tokens: list[str] = []
max_workers = min(16, max(1, len(names)))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_map = {executor.submit(fetch_remote_access_token, pool, name): name for name in names}
for future in as_completed(future_map):
file_name = future_map[future]
try:
token, error = future.result()
except Exception as exc:
token, error = None, str(exc)
if token:
tokens.append(token)
else:
self._append_error(pool_id, file_name, error or "unknown error")
current = self._config.get_import_job(pool_id) or {}
failed = len(current.get("errors") or [])
self._update_job(pool_id, completed=int(current.get("completed") or 0) + 1, failed=failed)
if not tokens:
current = self._config.get_import_job(pool_id) or {}
self._update_job(
pool_id,
status="failed",
completed=int(current.get("total") or 0),
failed=len(current.get("errors") or []),
)
return
add_result = account_service.add_accounts(tokens, source_type="codex")
refresh_result = account_service.refresh_accounts(tokens)
current = self._config.get_import_job(pool_id) or {}
self._update_job(
pool_id,
status="completed",
completed=len(names),
added=int(add_result.get("added") or 0),
skipped=int(add_result.get("skipped") or 0),
refreshed=int(refresh_result.get("refreshed") or 0),
failed=len(current.get("errors") or []),
)
cpa_config = CPAConfig(CPA_CONFIG_FILE)
cpa_import_service = CPAImportService(cpa_config)
+260
View File
@@ -0,0 +1,260 @@
from __future__ import annotations
import json
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Any
from urllib.parse import quote
from services.account_service import account_service
from services.config import DATA_DIR
from services.content_filter import request_text
from services.log_service import LOG_TYPE_CALL, log_service
from services.openai_backend_api import EDITABLE_FILE_MODEL, OpenAIBackendAPI
from utils.helper import new_uuid
TASK_STATUS_QUEUED = "queued"
TASK_STATUS_RUNNING = "running"
TASK_STATUS_SUCCESS = "success"
TASK_STATUS_ERROR = "error"
UNFINISHED_STATUSES = {TASK_STATUS_QUEUED, TASK_STATUS_RUNNING}
EDITABLE_FILE_PLAN_TYPES = ("Plus", "Team", "Pro", "Enterprise")
EDITABLE_FILE_ROOT = DATA_DIR / "files"
EDITABLE_FILE_TASKS_PATH = DATA_DIR / "editable_file_tasks.json"
def _now_iso() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def _clean(value: object, default: str = "") -> str:
return str(value or default).strip()
def _owner_id(identity: dict[str, object]) -> str:
return _clean(identity.get("id")) or "anonymous"
def _task_key(owner_id: str, task_id: str) -> str:
return f"{owner_id}:{task_id}"
def _elapsed_seconds(task: dict[str, Any]) -> int:
start = float(task.get("started_ts") or task.get("created_ts") or 0)
end = float(task.get("ended_ts") or time.time())
return max(0, int(end - start)) if start else 0
def _file_url(path: Path, base_url: str) -> str:
rel = path.resolve().relative_to(EDITABLE_FILE_ROOT.resolve()).as_posix()
prefix = str(base_url or "").strip().rstrip("/")
return f"{prefix}/files/{quote(rel, safe='/')}" if prefix else f"/files/{quote(rel, safe='/')}"
def _editable_access_token() -> str:
accounts = [
item for item in account_service.list_accounts()
if _clean(item.get("access_token"))
and item.get("status") not in {"禁用", "异常"}
and account_service._account_matches_any_plan_type(item, EDITABLE_FILE_PLAN_TYPES)
]
if not accounts:
raise RuntimeError("no available plus/team/pro account")
accounts.sort(key=lambda item: _clean(item.get("last_used_at")))
token = _clean(accounts[0].get("access_token"))
return account_service.refresh_access_token(token, event="editable_file_task") or token
def _public_task(task: dict[str, Any]) -> dict[str, Any]:
item = {
"id": task.get("id"),
"taskId": task.get("id"),
"status": task.get("status"),
"kind": task.get("kind"),
"created_at": task.get("created_at"),
"updated_at": task.get("updated_at"),
"elapsed_seconds": _elapsed_seconds(task),
}
for key in ("result", "error"):
if task.get(key):
item[key] = task[key]
return item
class EditableFileTaskService:
def __init__(self, path: Path = EDITABLE_FILE_TASKS_PATH) -> None:
self.path = path
self._lock = threading.RLock()
self._tasks: dict[str, dict[str, Any]] = {}
self.path.parent.mkdir(parents=True, exist_ok=True)
with self._lock:
self._tasks = self._load_locked()
if self._recover_unfinished_locked():
self._save_locked()
def submit_ppt(self, identity: dict[str, object], *, client_task_id: str = "", prompt: str = "", base64_images: list[str] | None = None, base_url: str = "") -> dict[str, Any]:
return self._submit(identity, client_task_id=client_task_id, kind="ppt", prompt=prompt, base64_images=base64_images or [], base_url=base_url)
def submit_psd(self, identity: dict[str, object], *, client_task_id: str = "", prompt: str = "", base64_images: list[str] | None = None, base_url: str = "") -> dict[str, Any]:
return self._submit(identity, client_task_id=client_task_id, kind="psd", prompt=prompt, base64_images=base64_images or [], base_url=base_url)
def list_tasks(self, identity: dict[str, object], task_ids: list[str]) -> dict[str, Any]:
owner = _owner_id(identity)
requested = [_clean(item) for item in task_ids if _clean(item)]
with self._lock:
if requested:
items = [task for task_id in requested if (task := self._tasks.get(_task_key(owner, task_id)))]
return {"items": [_public_task(item) for item in items], "missing_ids": [task_id for task_id in requested if _task_key(owner, task_id) not in self._tasks]}
items = [task for task in self._tasks.values() if task.get("owner_id") == owner]
items.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True)
return {"items": [_public_task(item) for item in items], "missing_ids": []}
def _submit(self, identity: dict[str, object], *, client_task_id: str, kind: str, prompt: str, base64_images: list[str], base_url: str) -> dict[str, Any]:
task_id = _clean(client_task_id) or new_uuid()
owner = _owner_id(identity)
key = _task_key(owner, task_id)
now = _now_iso()
with self._lock:
if key in self._tasks:
return _public_task(self._tasks[key])
ts = time.time()
self._tasks[key] = {"id": task_id, "owner_id": owner, "status": TASK_STATUS_QUEUED, "kind": kind, "model": EDITABLE_FILE_MODEL, "created_at": now, "updated_at": now, "created_ts": ts, "updated_ts": ts}
task = dict(self._tasks[key])
self._save_locked()
threading.Thread(target=self._run_task, args=(key, kind, prompt, base64_images, dict(identity), base_url), name=f"{kind}-file-task-{task_id[:16]}", daemon=True).start()
return _public_task(task)
def _run_task(self, key: str, kind: str, prompt: str, base64_images: list[str], identity: dict[str, object], base_url: str) -> None:
started = time.time()
token = ""
account_email = ""
self._update_task(key, status=TASK_STATUS_RUNNING, error="", started_ts=started)
backend = None
try:
if kind == "psd" and not base64_images:
raise ValueError("base64_images is empty")
token = _editable_access_token()
account = account_service.get_account(token) or {}
account_email = _clean(account.get("email"))
backend = OpenAIBackendAPI(token)
output_dir = EDITABLE_FILE_ROOT / kind / key.rsplit(":", 1)[-1]
result = backend.export_psd_zip(base64_images, prompt, output_dir) if kind == "psd" else backend.export_ppt_zip(base64_images, prompt, output_dir)
account_service.mark_text_used(token)
data = {"conversation_id": result.conversation_id, "primary_url": _file_url(result.primary_path, base_url), "zip_url": _file_url(result.zip_path, base_url)}
self._update_task(key, status=TASK_STATUS_SUCCESS, result=data, account_email=account_email, error="", ended_ts=time.time())
self._log_call(identity, kind, started, request_text(prompt), account_email=account_email, result=data)
except Exception as exc:
error = str(exc) or "editable file task failed"
self._update_task(key, status=TASK_STATUS_ERROR, error=error, account_email=account_email, ended_ts=time.time())
self._log_call(identity, kind, started, request_text(prompt), status="failed", error=error, account_email=account_email)
finally:
if backend is not None:
backend.close()
def public_file_path(self, relative_path: str) -> Path:
raw = str(relative_path or "").replace("\\", "/").lstrip("/")
path = (EDITABLE_FILE_ROOT / raw).resolve()
path.relative_to(EDITABLE_FILE_ROOT.resolve())
if not path.is_file():
raise FileNotFoundError(raw)
return path
def _update_task(self, key: str, **updates: Any) -> None:
with self._lock:
task = self._tasks.get(key)
if task is None:
return
task.update(updates)
task["updated_at"] = _now_iso()
task["updated_ts"] = time.time()
self._save_locked()
def _load_locked(self) -> dict[str, dict[str, Any]]:
if not self.path.exists():
return {}
try:
raw = json.loads(self.path.read_text(encoding="utf-8"))
except Exception:
return {}
tasks: dict[str, dict[str, Any]] = {}
for item in (raw.get("tasks") if isinstance(raw, dict) else raw) or []:
if not isinstance(item, dict):
continue
task_id = _clean(item.get("id"))
owner = _clean(item.get("owner_id"))
if not task_id or not owner:
continue
task = {
"id": task_id,
"owner_id": owner,
"status": _clean(item.get("status"), TASK_STATUS_ERROR),
"kind": "psd" if item.get("kind") == "psd" else "ppt",
"created_at": _clean(item.get("created_at"), _now_iso()),
"updated_at": _clean(item.get("updated_at"), _clean(item.get("created_at"), _now_iso())),
"created_ts": float(item.get("created_ts") or 0),
"updated_ts": float(item.get("updated_ts") or 0),
}
for field in ("result", "error", "started_ts", "ended_ts"):
if item.get(field):
task[field] = item[field]
tasks[_task_key(owner, task_id)] = task
return tasks
def _save_locked(self) -> None:
items = sorted(self._tasks.values(), key=lambda item: str(item.get("updated_at") or ""), reverse=True)
tmp_path = self.path.with_suffix(self.path.suffix + ".tmp")
tmp_path.write_text(json.dumps({"tasks": items}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
tmp_path.replace(self.path)
def _recover_unfinished_locked(self) -> bool:
changed = False
for task in self._tasks.values():
if task.get("status") in UNFINISHED_STATUSES:
task["status"] = TASK_STATUS_ERROR
task["error"] = "服务已重启,未完成的任务已中断"
task["ended_ts"] = time.time()
task["updated_at"] = _now_iso()
task["updated_ts"] = time.time()
changed = True
return changed
def _log_call(
self,
identity: dict[str, object],
kind: str,
started: float,
request_preview: str,
*,
status: str = "success",
error: str = "",
account_email: str = "",
result: dict[str, str] | None = None,
) -> None:
detail = {
"key_id": identity.get("id"),
"key_name": identity.get("name"),
"role": identity.get("role"),
"endpoint": f"/v1/{kind}/generations",
"model": EDITABLE_FILE_MODEL,
"started_at": datetime.fromtimestamp(started).strftime("%Y-%m-%d %H:%M:%S"),
"ended_at": _now_iso(),
"duration_ms": int((time.time() - started) * 1000),
"status": status,
}
if request_preview:
detail["request_text"] = request_preview
if account_email:
detail["account_email"] = account_email
if error:
detail["error"] = error
if result:
detail["result"] = result
try:
log_service.add(LOG_TYPE_CALL, f"{kind.upper()}生成任务{'失败' if status == 'failed' else '完成'}", detail)
except Exception:
pass
editable_file_task_service = EditableFileTaskService()
+376
View File
@@ -0,0 +1,376 @@
from __future__ import annotations
import io
import shutil
import threading
import time
import zipfile
from pathlib import Path
from fastapi import HTTPException
from fastapi.responses import FileResponse, Response
from PIL import Image, ImageOps
from services.config import config
from services.image_storage_service import image_storage_service
from services.image_tags_service import load_tags, remove_tags
from utils.log import logger
THUMBNAIL_SIZE = (320, 320)
def _cleanup_empty_dirs(root: Path) -> None:
for path in sorted((p for p in root.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True):
try:
path.rmdir()
except OSError:
pass
def _safe_relative_path(path: str) -> str:
value = str(path or "").strip().replace("\\", "/").lstrip("/")
if not value:
raise HTTPException(status_code=404, detail="image not found")
parts = Path(value).parts
if any(part in {"", ".", ".."} for part in parts):
raise HTTPException(status_code=404, detail="image not found")
return Path(*parts).as_posix()
def _safe_image_path(relative_path: str) -> Path:
rel = _safe_relative_path(relative_path)
root = config.images_dir.resolve()
path = (root / rel).resolve()
try:
path.relative_to(root)
except ValueError as exc:
raise HTTPException(status_code=404, detail="image not found") from exc
if not path.is_file():
raise HTTPException(status_code=404, detail="image not found")
return path
def get_image_response(relative_path: str) -> FileResponse | Response:
headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "*",
}
if image_storage_service.has_local(relative_path):
return FileResponse(_safe_image_path(relative_path), headers=headers)
return Response(content=image_storage_service.get_bytes(relative_path), media_type="image/png", headers=headers)
def _thumbnail_path(relative_path: str) -> Path:
rel = _safe_relative_path(relative_path)
return config.image_thumbnails_dir / f"{rel}.png"
def thumbnail_url(base_url: str, relative_path: str) -> str:
return f"{base_url.rstrip('/')}/image-thumbnails/{_safe_relative_path(relative_path)}"
def _image_dimensions(path: Path) -> tuple[int, int] | None:
try:
with Image.open(path) as image:
return image.size
except Exception:
return None
def ensure_thumbnail(relative_path: str) -> Path:
target = _thumbnail_path(relative_path)
source_mtime = 0.0
source: Path | None = None
if image_storage_service.has_local(relative_path):
source = _safe_image_path(relative_path)
source_mtime = source.stat().st_mtime
if target.exists() and (not source_mtime or target.stat().st_mtime >= source_mtime):
return target
target.parent.mkdir(parents=True, exist_ok=True)
try:
image_source = source if source is not None else io.BytesIO(image_storage_service.get_bytes(relative_path))
with Image.open(image_source) as image:
image = ImageOps.exif_transpose(image)
if image.mode not in {"RGB", "RGBA"}:
image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
image.thumbnail(THUMBNAIL_SIZE, Image.Resampling.LANCZOS)
image.save(target, format="PNG", optimize=True)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=422, detail="failed to create thumbnail") from exc
return target
def get_thumbnail_response(relative_path: str) -> FileResponse:
headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "*",
}
return FileResponse(ensure_thumbnail(relative_path), headers=headers)
def get_image_download_response(relative_path: str) -> FileResponse:
cors_headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "*",
}
if image_storage_service.has_local(relative_path):
path = _safe_image_path(relative_path)
headers = {**cors_headers, "Content-Disposition": f'attachment; filename="{path.name}"'}
return FileResponse(path, filename=path.name, headers=headers)
rel = _safe_relative_path(relative_path)
headers = {
**cors_headers,
"Content-Disposition": f'attachment; filename="{Path(rel).name}"',
}
return Response(
content=image_storage_service.get_bytes(rel),
media_type="image/png",
headers=headers,
)
def cleanup_image_thumbnails() -> int:
thumbnails_root = config.image_thumbnails_dir
removed = 0
for path in thumbnails_root.rglob("*"):
if not path.is_file():
continue
rel = path.relative_to(thumbnails_root).as_posix()
if not rel.endswith(".png") or not image_storage_service.exists(rel[:-4]):
path.unlink()
removed += 1
_cleanup_empty_dirs(thumbnails_root)
return removed
def list_images(base_url: str, start_date: str = "", end_date: str = "") -> dict[str, object]:
config.cleanup_old_images()
cleanup_image_thumbnails()
all_tags = load_tags()
items = [
{
**item,
"url": str(item.get("url") or f"{base_url.rstrip('/')}/images/{item['path']}"),
"thumbnail_url": thumbnail_url(base_url, str(item["path"])),
"tags": all_tags.get(str(item["path"]), []),
}
for item in image_storage_service.list_items(base_url, start_date, end_date)
]
groups: dict[str, list[dict[str, object]]] = {}
for item in items:
groups.setdefault(str(item["date"]), []).append(item)
return {"items": items, "groups": [{"date": key, "items": value} for key, value in groups.items()]}
def delete_images(paths: list[str] | None = None, start_date: str = "", end_date: str = "", all_matching: bool = False) -> dict[str, int]:
root = config.images_dir.resolve()
targets = [
str(item["path"])
for item in image_storage_service.list_items("", start_date=start_date, end_date=end_date)
] if all_matching else (paths or [])
removed = 0
for item in targets:
path = (root / item).resolve()
try:
path.relative_to(root)
except ValueError:
continue
if image_storage_service.delete(item):
removed += 1
for thumbnail in (_thumbnail_path(item), config.image_thumbnails_dir / _safe_relative_path(item)):
if thumbnail.is_file():
thumbnail.unlink()
remove_tags(item)
_cleanup_empty_dirs(root)
_cleanup_empty_dirs(config.image_thumbnails_dir)
return {"removed": removed}
def download_images_zip(paths: list[str]) -> io.BytesIO:
root = config.images_dir.resolve()
buf = io.BytesIO()
added = 0
used_names: set[str] = set()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for item in paths:
rel = _safe_relative_path(item)
path = (root / rel).resolve()
payload: bytes | None = None
try:
path.relative_to(root)
except ValueError:
continue
if path.is_file():
payload = path.read_bytes()
else:
try:
payload = image_storage_service.get_bytes(rel)
except Exception:
continue
name = path.name
if name in used_names:
stem = path.stem
suffix = path.suffix
counter = 2
while f"{stem}_{counter}{suffix}" in used_names:
counter += 1
name = f"{stem}_{counter}{suffix}"
used_names.add(name)
zf.writestr(name, payload)
added += 1
if added == 0:
raise HTTPException(status_code=404, detail="no images found")
buf.seek(0)
return buf
def storage_stats() -> dict:
import shutil
usage = shutil.disk_usage(config.images_dir)
total_mb = usage.total // (1024 * 1024)
used_mb = usage.used // (1024 * 1024)
free_mb = usage.free // (1024 * 1024)
image_count = 0
image_size = 0
for p in config.images_dir.rglob("*"):
if p.is_file():
image_count += 1
image_size += p.stat().st_size
return {
"disk_total_mb": total_mb,
"disk_used_mb": used_mb,
"disk_free_mb": free_mb,
"image_count": image_count,
"image_size_mb": image_size // (1024 * 1024),
"image_size_bytes": image_size,
}
def compress_images(quality: int = 60) -> dict:
"""重新压缩所有图片,返回节省的空间"""
saved = 0
count = 0
for p in sorted(config.images_dir.rglob("*.png")):
if not p.is_file():
continue
try:
orig = p.stat().st_size
with Image.open(p) as img:
img = ImageOps.exif_transpose(img)
img.save(str(p) + ".tmp", format="PNG", optimize=True)
new_size = Path(str(p) + ".tmp").stat().st_size
if new_size < orig:
Path(str(p) + ".tmp").replace(p)
saved += orig - new_size
count += 1
else:
Path(str(p) + ".tmp").unlink()
except Exception:
pass
return {"compressed": count, "saved_bytes": saved, "saved_mb": saved // (1024 * 1024)}
def delete_to_target(target_free_mb: int, dry_run: bool = False) -> dict:
"""删除最旧的图片直到剩余空间达到 target_free_mb"""
import shutil
usage = shutil.disk_usage(config.images_dir)
current_free = usage.free // (1024 * 1024)
if current_free >= target_free_mb and not dry_run:
return {"removed": 0, "current_free_mb": current_free, "target_free_mb": target_free_mb, "done": True}
files = sorted(
(p for p in config.images_dir.rglob("*.png") if p.is_file()),
key=lambda p: p.stat().st_mtime,
)
removed = 0
freed = 0
for p in files:
if current_free + freed // (1024 * 1024) >= target_free_mb:
break
size = p.stat().st_size
if not dry_run:
rel = p.relative_to(config.images_dir).as_posix()
for tp in (_thumbnail_path(rel), config.image_thumbnails_dir / _safe_relative_path(rel)):
if tp.is_file():
tp.unlink()
remove_tags(rel)
p.unlink()
freed += size
removed += 1
if not dry_run:
_cleanup_empty_dirs(config.images_dir)
_cleanup_empty_dirs(config.image_thumbnails_dir)
return {
"removed": removed,
"freed_mb": freed // (1024 * 1024),
"target_free_mb": target_free_mb,
"current_free_mb": current_free + (freed // (1024 * 1024)),
"done": (current_free + freed // (1024 * 1024)) >= target_free_mb,
"dry_run": dry_run,
}
def download_images_zip(paths: list[str]) -> io.BytesIO:
root = config.images_dir.resolve()
buf = io.BytesIO()
added = 0
used_names: set[str] = set()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for item in paths:
rel = _safe_relative_path(item)
path = (root / rel).resolve()
try:
path.relative_to(root)
except ValueError:
continue
if not path.is_file():
continue
name = path.name
if name in used_names:
stem = path.stem
suffix = path.suffix
counter = 2
while f"{stem}_{counter}{suffix}" in used_names:
counter += 1
name = f"{stem}_{counter}{suffix}"
used_names.add(name)
zf.write(path, name)
added += 1
if added == 0:
raise HTTPException(status_code=404, detail="no images found")
buf.seek(0)
return buf
def _auto_cleanup_worker(stop_event: threading.Event) -> None:
"""后台线程:每30分钟检查存储,空间低于阈值自动清理最旧图片"""
import shutil
min_free_mb = getattr(config, "image_min_free_mb", None)
if min_free_mb is None:
min_free_mb = 500
while not stop_event.wait(1800): # 每30分钟
try:
config.cleanup_old_images()
cleanup_image_thumbnails()
usage = shutil.disk_usage(config.images_dir)
free_mb = usage.free // (1024 * 1024)
if free_mb < min_free_mb:
logger.info({"event": "image_auto_cleanup", "free_mb": free_mb, "min_free_mb": min_free_mb})
result = delete_to_target(min_free_mb)
logger.info({"event": "image_auto_cleanup_done", **result})
except Exception:
pass
def start_image_cleanup_scheduler(stop_event: threading.Event) -> threading.Thread:
t = threading.Thread(target=_auto_cleanup_worker, args=(stop_event,), daemon=True, name="image-cleanup")
t.start()
return t
+405
View File
@@ -0,0 +1,405 @@
from __future__ import annotations
import hashlib
import io
import json
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from threading import Lock
from urllib.parse import quote, urlparse
from curl_cffi import requests
from fastapi import HTTPException
from PIL import Image
from services.config import DATA_DIR, config
IMAGE_INDEX_FILE = DATA_DIR / "image_index.json"
IMAGE_INDEX_LOCK = Lock()
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp"}
class ImageStorageError(RuntimeError):
pass
@dataclass(frozen=True)
class StoredImage:
rel: str
url: str
storage: str
size: int
def _clean(value: object) -> str:
return str(value or "").strip()
def _now_iso() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def _safe_relative_path(path: str) -> str:
value = str(path or "").strip().replace("\\", "/").lstrip("/")
if not value:
raise HTTPException(status_code=404, detail="image not found")
parts = Path(value).parts
if any(part in {"", ".", ".."} for part in parts):
raise HTTPException(status_code=404, detail="image not found")
return Path(*parts).as_posix()
def _image_dimensions(payload: bytes) -> tuple[int, int] | None:
try:
with Image.open(io.BytesIO(payload)) as image:
return image.size
except Exception:
return None
def _is_image_rel(path: str) -> bool:
try:
safe_rel = _safe_relative_path(path)
except HTTPException:
return False
return Path(safe_rel).suffix.lower() in IMAGE_EXTENSIONS
def _local_image_path(relative_path: str) -> Path:
rel = _safe_relative_path(relative_path)
root = config.images_dir.resolve()
path = (root / rel).resolve()
try:
path.relative_to(root)
except ValueError as exc:
raise HTTPException(status_code=404, detail="image not found") from exc
return path
def _read_json_object(path: Path) -> dict[str, object]:
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return {}
return data if isinstance(data, dict) else {}
def _write_json_object(path: Path, data: dict[str, object]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_suffix(path.suffix + ".tmp")
tmp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
tmp_path.replace(path)
class WebDAVClient:
def __init__(self, settings: dict[str, object]):
self.url = _clean(settings.get("webdav_url")).rstrip("/")
self.username = _clean(settings.get("webdav_username"))
self.password = _clean(settings.get("webdav_password"))
self.root_path = _clean(settings.get("webdav_root_path")).strip("/")
self.session = requests.Session()
def _auth_kwargs(self) -> dict[str, object]:
return {"auth": (self.username, self.password)} if self.username or self.password else {}
def _request(self, method: str, url: str, **kwargs):
response = self.session.request(method, url, timeout=30, **self._auth_kwargs(), **kwargs)
if response.status_code >= 400 and not (method == "MKCOL" and response.status_code in {405}):
raise ImageStorageError(f"WebDAV {method} failed: HTTP {response.status_code}")
return response
def remote_url(self, rel: str = "") -> str:
parts = [part for part in [self.root_path, _safe_relative_path(rel) if rel else ""] if part]
encoded = "/".join(quote(part, safe="") for item in parts for part in item.split("/") if part)
return f"{self.url}/{encoded}" if encoded else self.url
def ensure_dirs(self, rel: str) -> None:
parts = [part for part in [self.root_path, Path(_safe_relative_path(rel)).parent.as_posix()] if part and part != "."]
current = self.url
for item in "/".join(parts).split("/"):
if not item:
continue
current = f"{current}/{quote(item, safe='')}"
response = self.session.request("MKCOL", current, timeout=30, **self._auth_kwargs())
if response.status_code in {201, 405}:
continue
if response.status_code >= 400:
raise ImageStorageError(f"WebDAV MKCOL failed: HTTP {response.status_code}")
def put(self, rel: str, payload: bytes, content_type: str = "image/png") -> str:
self.ensure_dirs(rel)
url = self.remote_url(rel)
self._request("PUT", url, data=payload, headers={"Content-Type": content_type})
return url
def get(self, rel: str) -> bytes:
response = self._request("GET", self.remote_url(rel))
return bytes(response.content)
def delete(self, rel: str) -> bool:
response = self.session.request("DELETE", self.remote_url(rel), timeout=30, **self._auth_kwargs())
if response.status_code in {200, 202, 204, 404}:
return response.status_code != 404
raise ImageStorageError(f"WebDAV DELETE failed: HTTP {response.status_code}")
def test(self) -> dict[str, object]:
if not self.url:
return {"ok": False, "status": 0, "error": "WebDAV URL is required"}
if urlparse(self.url).scheme not in {"http", "https"}:
return {"ok": False, "status": 0, "error": "invalid WebDAV URL"}
test_rel = ".chatgpt2api_webdav_test.txt"
try:
self.put(test_rel, b"chatgpt2api webdav test\n", content_type="text/plain")
self.delete(test_rel)
return {"ok": True, "status": 200, "error": None}
except ImageStorageError as exc:
return {"ok": False, "status": 0, "error": str(exc)}
except Exception as exc:
return {"ok": False, "status": 0, "error": str(exc) or exc.__class__.__name__}
finally:
self.session.close()
class ImageStorageService:
def __init__(self, index_file: Path = IMAGE_INDEX_FILE):
self.index_file = index_file
self._index_lock = IMAGE_INDEX_LOCK
def settings(self) -> dict[str, object]:
return config.get_image_storage_settings()
def mode(self) -> str:
return _clean(self.settings().get("mode")) or "local"
def _load_index(self) -> dict[str, dict[str, object]]:
raw = _read_json_object(self.index_file)
items = raw.get("items")
if not isinstance(items, dict):
return {}
return {str(key): value for key, value in items.items() if isinstance(value, dict)}
def _load_clean_index(self) -> dict[str, dict[str, object]]:
items = self._load_index()
return {rel: item for rel, item in items.items() if _is_image_rel(rel)}
def _save_index(self, items: dict[str, dict[str, object]]) -> None:
_write_json_object(self.index_file, {"items": items})
def _public_url(self, rel: str, base_url: str | None = None) -> str:
settings = self.settings()
public_base_url = _clean(settings.get("public_base_url"))
if public_base_url:
return f"{public_base_url.rstrip('/')}/{_safe_relative_path(rel)}"
return f"{(base_url or config.base_url).rstrip('/')}/images/{_safe_relative_path(rel)}"
def make_relative_path(self, image_data: bytes) -> str:
file_hash = hashlib.md5(image_data).hexdigest()
filename = f"{int(time.time())}_{file_hash}.png"
relative_dir = Path(time.strftime("%Y"), time.strftime("%m"), time.strftime("%d"))
return f"{relative_dir.as_posix()}/{filename}"
def save(self, image_data: bytes, base_url: str | None = None) -> StoredImage:
config.cleanup_old_images()
rel = self.make_relative_path(image_data)
mode = self.mode()
if mode not in {"local", "webdav", "both"}:
mode = "local"
stored_local = False
stored_webdav = False
remote_url = ""
if mode in {"local", "both"}:
path = _local_image_path(rel)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(image_data)
stored_local = True
if mode in {"webdav", "both"}:
remote_url = WebDAVClient(self.settings()).put(rel, image_data)
stored_webdav = True
dimensions = _image_dimensions(image_data)
item = {
"rel": rel,
"path": rel,
"name": Path(rel).name,
"date": "-".join(rel.split("/")[:3]),
"size": len(image_data),
"created_at": _now_iso(),
"storage": "both" if stored_local and stored_webdav else ("webdav" if stored_webdav else "local"),
"local": stored_local,
"webdav": stored_webdav,
"remote_url": remote_url,
}
if dimensions:
item["width"], item["height"] = dimensions
with self._index_lock:
items = self._load_clean_index()
items[rel] = item
self._save_index(items)
return StoredImage(rel=rel, url=self._public_url(rel, base_url), storage=str(item["storage"]), size=len(image_data))
def get_bytes(self, rel: str) -> bytes:
safe_rel = _safe_relative_path(rel)
if not _is_image_rel(safe_rel):
raise HTTPException(status_code=404, detail="image not found")
path = _local_image_path(safe_rel)
if path.is_file():
return path.read_bytes()
item = self._load_clean_index().get(safe_rel, {})
if item.get("webdav"):
return WebDAVClient(self.settings()).get(safe_rel)
raise HTTPException(status_code=404, detail="image not found")
def exists(self, rel: str) -> bool:
safe_rel = _safe_relative_path(rel)
if not _is_image_rel(safe_rel):
return False
if _local_image_path(safe_rel).is_file():
return True
item = self._load_clean_index().get(safe_rel, {})
return bool(item.get("webdav"))
def has_local(self, rel: str) -> bool:
safe_rel = _safe_relative_path(rel)
return _is_image_rel(safe_rel) and _local_image_path(safe_rel).is_file()
def list_items(self, base_url: str, start_date: str = "", end_date: str = "") -> list[dict[str, object]]:
with self._index_lock:
indexed = self._load_clean_index()
root = config.images_dir
changed = False
for path in root.rglob("*"):
if not path.is_file() or not _is_image_rel(path.name):
continue
rel = path.relative_to(root).as_posix()
if rel in indexed:
continue
dimensions = None
try:
dimensions = _image_dimensions(path.read_bytes())
except Exception:
dimensions = None
indexed[rel] = {
"rel": rel,
"path": rel,
"name": path.name,
"date": "-".join(rel.split("/")[:3]) if len(rel.split("/")) >= 4 else datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d"),
"size": path.stat().st_size,
"created_at": datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
"storage": "local",
"local": True,
"webdav": False,
**({"width": dimensions[0], "height": dimensions[1]} if dimensions else {}),
}
changed = True
items: list[dict[str, object]] = []
for rel, item in list(indexed.items()):
if not _is_image_rel(rel):
indexed.pop(rel, None)
changed = True
continue
local = _local_image_path(rel).is_file()
webdav = bool(item.get("webdav"))
if not local and not webdav:
indexed.pop(rel, None)
changed = True
continue
storage = "both" if local and webdav else ("webdav" if webdav else "local")
if item.get("local") != local or item.get("storage") != storage:
item = {
**item,
"local": local,
"storage": storage,
}
indexed[rel] = item
changed = True
day = str(item.get("date") or "")
if start_date and day < start_date:
continue
if end_date and day > end_date:
continue
items.append({
**item,
"rel": rel,
"path": rel,
"url": self._public_url(rel, base_url),
})
if changed:
self._save_index(indexed)
items.sort(key=lambda item: str(item.get("created_at") or ""), reverse=True)
return items
def delete(self, rel: str) -> bool:
safe_rel = _safe_relative_path(rel)
removed = False
path = _local_image_path(safe_rel)
if path.is_file():
path.unlink()
removed = True
with self._index_lock:
items = self._load_clean_index()
item = items.get(safe_rel, {})
if item.get("webdav"):
try:
removed = WebDAVClient(self.settings()).delete(safe_rel) or removed
except ImageStorageError:
if not removed:
raise
if safe_rel in items:
items.pop(safe_rel, None)
self._save_index(items)
return removed
def sync_all(self) -> dict[str, int]:
settings = self.settings()
if self.mode() not in {"webdav", "both"}:
raise ImageStorageError("WebDAV 图片存储未启用")
uploaded = 0
skipped = 0
failed = 0
with self._index_lock:
items = self._load_clean_index()
client = WebDAVClient(settings)
for path in sorted(config.images_dir.rglob("*")):
if not path.is_file() or not _is_image_rel(path.name):
continue
rel = path.relative_to(config.images_dir).as_posix()
item = items.get(rel, {})
if item.get("webdav"):
skipped += 1
continue
try:
payload = path.read_bytes()
remote_url = client.put(rel, payload)
dimensions = _image_dimensions(payload)
items[rel] = {
**item,
"rel": rel,
"path": rel,
"name": path.name,
"date": "-".join(rel.split("/")[:3]) if len(rel.split("/")) >= 4 else datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d"),
"size": len(payload),
"created_at": str(item.get("created_at") or datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")),
"storage": "both",
"local": True,
"webdav": True,
"remote_url": remote_url,
**({"width": dimensions[0], "height": dimensions[1]} if dimensions else {}),
}
uploaded += 1
except Exception:
failed += 1
self._save_index(items)
return {"uploaded": uploaded, "skipped": skipped, "failed": failed}
def test_webdav(self) -> dict[str, object]:
return WebDAVClient(self.settings()).test()
image_storage_service = ImageStorageService()
+76
View File
@@ -0,0 +1,76 @@
from __future__ import annotations
import json
from pathlib import Path
from services.config import DATA_DIR
TAGS_FILE = DATA_DIR / "image_tags.json"
def _ensure_file() -> None:
TAGS_FILE.parent.mkdir(parents=True, exist_ok=True)
if not TAGS_FILE.exists():
TAGS_FILE.write_text("{}", encoding="utf-8")
def load_tags() -> dict[str, list[str]]:
_ensure_file()
try:
data = json.loads(TAGS_FILE.read_text(encoding="utf-8"))
except Exception:
return {}
return data if isinstance(data, dict) else {}
def save_tags(data: dict[str, list[str]]) -> None:
_ensure_file()
TAGS_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def get_tags(image_rel: str) -> list[str]:
return load_tags().get(image_rel, [])
def set_tags(image_rel: str, tags: list[str]) -> list[str]:
data = load_tags()
cleaned = list(dict.fromkeys(t.strip() for t in tags if t.strip()))
if cleaned:
data[image_rel] = cleaned
else:
data.pop(image_rel, None)
save_tags(data)
return cleaned
def remove_tags(image_rel: str) -> None:
data = load_tags()
if data.pop(image_rel, None) is not None:
save_tags(data)
def delete_tag(tag: str) -> int:
"""从所有图片中删除指定标签,返回受影响的图片数。"""
data = load_tags()
count = 0
for rel in list(data):
if tag in data[rel]:
data[rel] = [t for t in data[rel] if t != tag]
if not data[rel]:
del data[rel]
count += 1
if count > 0:
save_tags(data)
return count
def get_all_tags() -> list[str]:
data = load_tags()
seen: set[str] = set()
result: list[str] = []
for tags in data.values():
for t in tags:
if t not in seen:
seen.add(t)
result.append(t)
return result
+576
View File
@@ -0,0 +1,576 @@
from __future__ import annotations
import json
import threading
import time
from collections.abc import Callable
from datetime import datetime
from pathlib import Path
from typing import Any
from services.config import DATA_DIR, config
from services.content_filter import request_text
from services.log_service import LOG_TYPE_CALL, log_service
from services.protocol import openai_v1_image_edit, openai_v1_image_generations
from utils.log import logger
TASK_STATUS_QUEUED = "queued"
TASK_STATUS_RUNNING = "running"
TASK_STATUS_SUCCESS = "success"
TASK_STATUS_ERROR = "error"
TERMINAL_STATUSES = {TASK_STATUS_SUCCESS, TASK_STATUS_ERROR}
UNFINISHED_STATUSES = {TASK_STATUS_QUEUED, TASK_STATUS_RUNNING}
def _now_iso() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def _timestamp(value: object) -> float:
if not isinstance(value, str) or not value.strip():
return 0.0
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"):
try:
return datetime.strptime(value[:26], fmt).timestamp()
except ValueError:
continue
try:
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
except Exception:
return 0.0
def _clean(value: object, default: str = "") -> str:
return str(value or default).strip()
def _owner_id(identity: dict[str, object]) -> str:
return _clean(identity.get("id")) or "anonymous"
def _task_key(owner_id: str, task_id: str) -> str:
return f"{owner_id}:{task_id}"
def _collect_image_urls(data: list[Any]) -> list[str]:
urls: list[str] = []
for item in data:
if isinstance(item, dict):
url = item.get("url")
if isinstance(url, str) and url:
urls.append(url)
return urls
def _public_task(task: dict[str, Any]) -> dict[str, Any]:
item = {
"id": task.get("id"),
"status": task.get("status"),
"mode": task.get("mode"),
"model": task.get("model"),
"size": task.get("size"),
"quality": task.get("quality"),
"created_at": task.get("created_at"),
"updated_at": task.get("updated_at"),
}
if task.get("conversation_id"):
item["conversation_id"] = task.get("conversation_id")
if task.get("data") is not None:
item["data"] = task.get("data")
if task.get("usage") is not None:
item["usage"] = task.get("usage")
if task.get("error"):
item["error"] = task.get("error")
if task.get("progress"):
item["progress"] = task.get("progress")
if task.get("duration_ms") is not None:
item["duration_ms"] = task.get("duration_ms")
if task.get("stage_durations_ms"):
item["stage_durations_ms"] = dict(task["stage_durations_ms"])
if task.get("status") in (TASK_STATUS_RUNNING, TASK_STATUS_QUEUED):
if task.get("status") == TASK_STATUS_RUNNING:
# Include account selection, upload and the upstream response wait.
base_ts = task.get("started_ts")
else:
# QUEUED 状态从 created_ts 开始计时(排队等待中)
base_ts = task.get("created_ts") or task.get("updated_ts")
if base_ts:
item["elapsed_secs"] = round(time.time() - base_ts, 1)
return item
class ImageTaskService:
def __init__(
self,
path: Path,
*,
generation_handler: Callable[[dict[str, Any]], dict[str, Any]] = openai_v1_image_generations.handle,
edit_handler: Callable[[dict[str, Any]], dict[str, Any]] = openai_v1_image_edit.handle,
retention_days_getter: Callable[[], int] | None = None,
):
self.path = path
self.generation_handler = generation_handler
self.edit_handler = edit_handler
self.retention_days_getter = retention_days_getter or (lambda: config.image_retention_days)
self._lock = threading.RLock()
self._tasks: dict[str, dict[str, Any]] = {}
self.path.parent.mkdir(parents=True, exist_ok=True)
with self._lock:
self._tasks = self._load_locked()
changed = self._recover_unfinished_locked()
changed = self._cleanup_locked() or changed
if changed:
self._save_locked()
def submit_generation(
self,
identity: dict[str, object],
*,
client_task_id: str,
prompt: str,
model: str,
size: str | None,
quality: str = "auto",
base_url: str = "",
) -> dict[str, Any]:
payload = {
"prompt": prompt,
"model": model,
"n": 1,
"size": size,
"quality": quality,
"response_format": "url",
"base_url": base_url,
}
return self._submit(identity, client_task_id=client_task_id, mode="generate", payload=payload)
def submit_edit(
self,
identity: dict[str, object],
*,
client_task_id: str,
prompt: str,
model: str,
size: str | None,
quality: str = "auto",
base_url: str = "",
images: list[tuple[bytes, str, str]] | None = None,
masks: list[tuple[bytes, str, str]] | None = None,
) -> dict[str, Any]:
payload = {
"prompt": prompt,
"images": images or [],
"mask": masks or [],
"model": model,
"n": 1,
"size": size,
"quality": quality,
"response_format": "url",
"base_url": base_url,
}
return self._submit(identity, client_task_id=client_task_id, mode="edit", payload=payload)
def list_tasks(self, identity: dict[str, object], task_ids: list[str]) -> dict[str, Any]:
owner = _owner_id(identity)
requested_ids = [_clean(task_id) for task_id in task_ids if _clean(task_id)]
with self._lock:
if self._cleanup_locked():
self._save_locked()
items = []
missing_ids = []
for task_id in requested_ids:
task = self._tasks.get(_task_key(owner, task_id))
if task is None:
missing_ids.append(task_id)
else:
items.append(_public_task(task))
if not requested_ids:
items = [
_public_task(task)
for task in self._tasks.values()
if task.get("owner_id") == owner
]
items.sort(key=lambda item: str(item.get("updated_at") or ""), reverse=True)
missing_ids = []
return {"items": items, "missing_ids": missing_ids}
def _submit(
self,
identity: dict[str, object],
*,
client_task_id: str,
mode: str,
payload: dict[str, Any],
) -> dict[str, Any]:
task_id = _clean(client_task_id)
if not task_id:
raise ValueError("client_task_id is required")
owner = _owner_id(identity)
key = _task_key(owner, task_id)
now = _now_iso()
should_start = False
with self._lock:
cleaned = self._cleanup_locked()
task = self._tasks.get(key)
if task is not None:
if cleaned:
self._save_locked()
return _public_task(task)
task = {
"id": task_id,
"owner_id": owner,
"status": TASK_STATUS_QUEUED,
"mode": mode,
"model": _clean(payload.get("model"), "gpt-image-2"),
"size": _clean(payload.get("size")),
"quality": _clean(payload.get("quality"), "auto"),
"created_at": now,
"updated_at": now,
"created_ts": time.time(),
}
self._tasks[key] = task
self._save_locked()
should_start = True
if should_start:
thread = threading.Thread(
target=self._run_task,
args=(key, mode, payload, dict(identity), _clean(payload.get("model"), "gpt-image-2")),
name=f"image-task-{task_id[:16]}",
daemon=True,
)
thread.start()
return _public_task(task)
def _run_task(
self,
key: str,
mode: str,
payload: dict[str, Any],
identity: dict[str, object],
model: str,
) -> None:
started = time.time()
self._update_task(key, status=TASK_STATUS_RUNNING, error="", started_ts=started)
stage_started = time.monotonic()
current_stage = "getting_account"
stage_durations: dict[str, int] = {}
timing_lock = threading.Lock()
def advance_stage(step: str | None) -> dict[str, int]:
nonlocal stage_started, current_stage
with timing_lock:
now = time.monotonic()
stage_durations[current_stage] = stage_durations.get(current_stage, 0) + max(0, int((now - stage_started) * 1000))
stage_started = now
if step:
current_stage = step
return dict(stage_durations)
# 创建进度回调,每个步骤完成后更新任务状态
def progress_callback(step: str) -> None:
self._update_task(key, progress=step, stage_durations_ms=advance_stage(step))
# 将进度回调添加到 payload 中(handler 会提取并传递给 ConversationRequest)
payload_with_progress = {**payload, "progress_callback": progress_callback}
try:
handler = self.edit_handler if mode == "edit" else self.generation_handler
result = handler(payload_with_progress)
if not isinstance(result, dict):
raise RuntimeError("image task returned streaming result unexpectedly")
data = result.get("data")
account_email = _clean(result.get("_account_email") or result.get("account_email"))
if not isinstance(data, list) or not data:
upstream = _clean(result.get("message"))
if upstream:
message = upstream
else:
message = "号池中没有可用账号或所有账号均被限流,请检查号池状态(账号额度、是否被封禁、是否到达生图上限)"
error = RuntimeError(message)
if account_email:
setattr(error, "account_email", account_email)
raise error
usage = result.get("usage")
duration_ms = int((time.time() - started) * 1000)
timings = advance_stage(None)
self._update_task(key, status=TASK_STATUS_SUCCESS, data=data, usage=usage, error="", duration_ms=duration_ms, stage_durations_ms=timings)
logger.info({"event": "image_task_timings", "task_id": payload.get("client_task_id"), "duration_ms": duration_ms, "stages_ms": timings})
self._log_call(
identity,
mode,
model,
started,
"调用完成",
request_preview=request_text(payload.get("prompt")),
urls=_collect_image_urls(data),
account_email=account_email,
)
except Exception as exc:
error_message = str(exc) or "image task failed"
account_email = _clean(getattr(exc, "account_email", ""))
conversation_id = _clean(getattr(exc, "conversation_id", ""))
duration_ms = int((time.time() - started) * 1000)
timings = advance_stage(None)
self._update_task(key, status=TASK_STATUS_ERROR, error=error_message, data=[],
duration_ms=duration_ms, stage_durations_ms=timings,
**({"conversation_id": conversation_id} if conversation_id else {}))
logger.info({"event": "image_task_timings", "task_id": payload.get("client_task_id"), "duration_ms": duration_ms, "stages_ms": timings})
self._log_call(
identity,
mode,
model,
started,
"调用失败",
request_preview=request_text(payload.get("prompt")),
status="failed",
error=error_message,
account_email=account_email,
)
def _log_call(
self,
identity: dict[str, object],
mode: str,
model: str,
started: float,
suffix: str,
*,
request_preview: str = "",
status: str = "success",
error: str = "",
urls: list[str] | None = None,
account_email: str = "",
) -> None:
endpoint = "/v1/images/edits" if mode == "edit" else "/v1/images/generations"
summary_prefix = "图生图" if mode == "edit" else "文生图"
detail = {
"key_id": identity.get("id"),
"key_name": identity.get("name"),
"role": identity.get("role"),
"endpoint": endpoint,
"model": model,
"started_at": datetime.fromtimestamp(started).strftime("%Y-%m-%d %H:%M:%S"),
"ended_at": _now_iso(),
"duration_ms": int((time.time() - started) * 1000),
"status": status,
}
if request_preview:
detail["request_text"] = request_preview
if error:
detail["error"] = error
if account_email:
detail["account_email"] = account_email
if urls:
detail["urls"] = list(dict.fromkeys(urls))
try:
log_service.add(LOG_TYPE_CALL, f"{summary_prefix}{suffix}", detail)
except Exception:
pass
def _update_task(self, key: str, **updates: Any) -> None:
with self._lock:
task = self._tasks.get(key)
if task is None:
return
task.update(updates)
task["updated_at"] = _now_iso()
task["updated_ts"] = time.time()
self._save_locked()
def _load_locked(self) -> dict[str, dict[str, Any]]:
if not self.path.exists():
return {}
try:
raw = json.loads(self.path.read_text(encoding="utf-8"))
except Exception:
return {}
raw_items = raw.get("tasks") if isinstance(raw, dict) else raw
if not isinstance(raw_items, list):
return {}
tasks: dict[str, dict[str, Any]] = {}
for item in raw_items:
if not isinstance(item, dict):
continue
task_id = _clean(item.get("id"))
owner = _clean(item.get("owner_id"))
if not task_id or not owner:
continue
status = _clean(item.get("status"))
if status not in {TASK_STATUS_QUEUED, TASK_STATUS_RUNNING, TASK_STATUS_SUCCESS, TASK_STATUS_ERROR}:
status = TASK_STATUS_ERROR
task = {
"id": task_id,
"owner_id": owner,
"status": status,
"mode": "edit" if item.get("mode") == "edit" else "generate",
"model": _clean(item.get("model"), "gpt-image-2"),
"size": _clean(item.get("size")),
"quality": _clean(item.get("quality"), "auto"),
"created_at": _clean(item.get("created_at"), _now_iso()),
"updated_at": _clean(item.get("updated_at"), _clean(item.get("created_at"), _now_iso())),
"created_ts": item.get("created_ts"),
"updated_ts": item.get("updated_ts"),
"started_ts": item.get("started_ts"),
"duration_ms": item.get("duration_ms"),
}
data = item.get("data")
if isinstance(data, list):
task["data"] = data
usage = item.get("usage")
if isinstance(usage, dict):
task["usage"] = usage
timings = item.get("stage_durations_ms")
if isinstance(timings, dict):
task["stage_durations_ms"] = {
str(stage): duration for stage, duration in timings.items()
if isinstance(duration, int) and duration >= 0
}
error = _clean(item.get("error"))
if error:
task["error"] = error
tasks[_task_key(owner, task_id)] = task
return tasks
def _save_locked(self) -> None:
items = sorted(self._tasks.values(), key=lambda item: str(item.get("updated_at") or ""), reverse=True)
tmp_path = self.path.with_suffix(self.path.suffix + ".tmp")
tmp_path.write_text(json.dumps({"tasks": items}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
tmp_path.replace(self.path)
def _recover_unfinished_locked(self) -> bool:
changed = False
for task in self._tasks.values():
if task.get("status") in UNFINISHED_STATUSES:
task["status"] = TASK_STATUS_ERROR
task["error"] = "服务已重启,未完成的图片任务已中断"
task["updated_at"] = _now_iso()
changed = True
return changed
def _cleanup_locked(self) -> bool:
try:
retention_days = max(1, int(self.retention_days_getter()))
except Exception:
retention_days = 30
cutoff = time.time() - retention_days * 86400
removed_keys = [
key
for key, task in self._tasks.items()
if task.get("status") in TERMINAL_STATUSES and _timestamp(task.get("updated_at")) < cutoff
]
for key in removed_keys:
self._tasks.pop(key, None)
return bool(removed_keys)
def resume_poll(
self,
identity: dict[str, object],
task_id: str,
extra_timeout_secs: float = 30.0,
) -> dict[str, Any]:
"""恢复对已超时任务的轮询,额外等待 extra_timeout_secs 秒。"""
owner = _owner_id(identity)
key = _task_key(owner, _clean(task_id))
with self._lock:
task = self._tasks.get(key)
if task is None:
raise ValueError("task not found")
if task.get("status") != TASK_STATUS_ERROR:
raise ValueError("task is not in error state")
error_msg = _clean(task.get("error"))
if "超时" not in error_msg:
raise ValueError("task error is not a timeout error")
conversation_id = _clean(task.get("conversation_id"))
if not conversation_id:
raise ValueError("task has no conversation_id")
mode = task.get("mode", "generate")
model = task.get("model", "gpt-image-2")
# 将任务状态重置为 running
self._update_task(key, status=TASK_STATUS_RUNNING, error="")
# 启动新线程继续轮询
thread = threading.Thread(
target=self._run_resume_poll,
args=(key, conversation_id, extra_timeout_secs, dict(identity), mode, model),
name=f"image-resume-{_clean(task_id)[:16]}",
daemon=True,
)
thread.start()
return _public_task(task)
def _run_resume_poll(
self,
key: str,
conversation_id: str,
extra_timeout_secs: float,
identity: dict[str, object],
mode: str,
model: str,
) -> None:
"""后台线程:继续轮询已有 conversation_id 的图片结果。"""
started = time.time()
backend = None
try:
from services.openai_backend_api import OpenAIBackendAPI
from services.protocol.conversation import format_image_result
backend = OpenAIBackendAPI(proxy_url=config.proxy_url or None)
file_ids, sediment_ids = backend._poll_image_results(
conversation_id,
extra_timeout_secs,
)
if not file_ids and not sediment_ids:
raise RuntimeError(
f"继续等待 {extra_timeout_secs} 秒后仍未找到图片结果。"
)
image_urls = backend.resolve_conversation_image_urls(
conversation_id, file_ids, sediment_ids, poll=False,
)
if not image_urls:
raise RuntimeError("图片 URL 解析失败")
image_items = [
{"b64_json": __import__("base64").b64encode(image_data).decode("ascii")}
for image_data in backend.download_image_bytes(image_urls)
]
# 获取 task 的原始 prompt(从 _public_task 的 mode 判断)
with self._lock:
task = self._tasks.get(key)
quality = _clean(task.get("quality"), "auto") if task else "auto"
size = _clean(task.get("size")) if task else None
data = format_image_result(
image_items,
"", # prompt 已不重要,结果已经拿到了
"b64_json",
"",
int(time.time()),
)["data"]
self._update_task(key, status=TASK_STATUS_SUCCESS, data=data, error="", duration_ms=int((time.time() - started) * 1000))
self._log_call(
identity,
mode,
model,
started,
"调用完成(续轮询)",
status="success",
urls=_collect_image_urls(data),
)
except Exception as exc:
error_message = str(exc) or "resume poll failed"
duration_ms = int((time.time() - started) * 1000)
self._update_task(key, status=TASK_STATUS_ERROR, error=error_message, data=[], duration_ms=duration_ms)
self._log_call(
identity,
mode,
model,
started,
"调用失败(续轮询)",
status="failed",
error=error_message,
)
finally:
if backend is not None:
backend.close()
image_task_service = ImageTaskService(DATA_DIR / "image_tasks.json")
+337
View File
@@ -0,0 +1,337 @@
from __future__ import annotations
import hashlib
import json
import itertools
import time
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any
from uuid import uuid4
from fastapi import HTTPException
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import JSONResponse, StreamingResponse
from services.config import DATA_DIR
from services.protocol.error_response import anthropic_error_response, openai_error_response
from utils.helper import anthropic_sse_stream, sse_json_stream
LOG_TYPE_CALL = "call"
LOG_TYPE_ACCOUNT = "account"
INTERNAL_RESPONSE_KEYS = {"_account_email", "_conversation_id"}
class LogService:
def __init__(self, path: Path):
self.path = path
self.path.parent.mkdir(parents=True, exist_ok=True)
@staticmethod
def _legacy_id(raw_line: str, line_number: int) -> str:
payload = f"{line_number}:{raw_line}".encode("utf-8", errors="ignore")
return hashlib.sha1(payload).hexdigest()[:24]
def _parse_line(self, raw_line: str, line_number: int) -> dict[str, Any] | None:
try:
item = json.loads(raw_line)
except Exception:
return None
if not isinstance(item, dict):
return None
parsed = dict(item)
parsed["id"] = str(parsed.get("id") or self._legacy_id(raw_line, line_number))
return parsed
@staticmethod
def _serialize_item(item: dict[str, Any]) -> str:
return json.dumps(item, ensure_ascii=False, separators=(",", ":"))
@staticmethod
def _matches_filters(item: dict[str, Any], *, type: str = "", start_date: str = "", end_date: str = "") -> bool:
t = str(item.get("time") or "")
day = t[:10]
if type and item.get("type") != type:
return False
if start_date and day < start_date:
return False
if end_date and day > end_date:
return False
return True
def add(self, type: str, summary: str = "", detail: dict[str, Any] | None = None, **data: Any) -> None:
item = {
"id": uuid4().hex,
"time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"type": type,
"summary": summary,
"detail": detail or data,
}
with self.path.open("a", encoding="utf-8") as file:
file.write(self._serialize_item(item) + "\n")
def list(self, type: str = "", start_date: str = "", end_date: str = "", limit: int = 200) -> list[dict[str, Any]]:
if not self.path.exists():
return []
items: list[dict[str, Any]] = []
lines = self.path.read_text(encoding="utf-8").splitlines()
for line_number in range(len(lines) - 1, -1, -1):
item = self._parse_line(lines[line_number], line_number)
if item is None:
continue
if not self._matches_filters(item, type=type, start_date=start_date, end_date=end_date):
continue
items.append(item)
if len(items) >= limit:
break
return items
def delete(self, ids: list[str]) -> dict[str, int]:
target_ids = {str(item or "").strip() for item in ids if str(item or "").strip()}
if not self.path.exists() or not target_ids:
return {"removed": 0}
lines = self.path.read_text(encoding="utf-8").splitlines()
kept_lines: list[str] = []
removed = 0
for line_number, raw_line in enumerate(lines):
item = self._parse_line(raw_line, line_number)
if item is None:
kept_lines.append(raw_line)
continue
if str(item.get("id") or "") in target_ids:
removed += 1
continue
kept_lines.append(self._serialize_item(item))
content = "\n".join(kept_lines)
if content:
content += "\n"
self.path.write_text(content, encoding="utf-8")
return {"removed": removed}
log_service = LogService(DATA_DIR / "logs.jsonl")
def _collect_urls(value: object) -> list[str]:
urls: list[str] = []
if isinstance(value, dict):
for key, item in value.items():
if key == "url" and isinstance(item, str):
urls.append(item)
elif key == "urls" and isinstance(item, list):
urls.extend(str(url) for url in item if isinstance(url, str))
else:
urls.extend(_collect_urls(item))
elif isinstance(value, list):
for item in value:
urls.extend(_collect_urls(item))
return urls
def _collect_account_emails(value: object) -> list[str]:
emails: list[str] = []
if isinstance(value, dict):
for key, item in value.items():
if key in {"_account_email", "account_email"} and isinstance(item, str) and item.strip():
emails.append(item.strip())
else:
emails.extend(_collect_account_emails(item))
elif isinstance(value, list):
for item in value:
emails.extend(_collect_account_emails(item))
return emails
def _collect_conversation_ids(value: object) -> list[str]:
ids: list[str] = []
if isinstance(value, dict):
for key, item in value.items():
if key == "_conversation_id" and isinstance(item, str) and item.strip():
ids.append(item.strip())
else:
ids.extend(_collect_conversation_ids(item))
elif isinstance(value, list):
for item in value:
ids.extend(_collect_conversation_ids(item))
return ids
def _strip_internal_response_fields(value: object) -> object:
if isinstance(value, dict):
return {
key: _strip_internal_response_fields(item)
for key, item in value.items()
if key not in INTERNAL_RESPONSE_KEYS
}
if isinstance(value, list):
return [_strip_internal_response_fields(item) for item in value]
return value
def _request_excerpt(text: object, limit: int = 1000) -> str:
value = str(text or "").strip()
if not value:
return ""
normalized = " ".join(value.split())
if len(normalized) <= limit:
return normalized
return normalized[: limit - 1].rstrip() + "…"
def _image_error_response(exc: Exception) -> JSONResponse:
from services.protocol.conversation import public_image_error_message
message = public_image_error_message(str(exc))
if "no available image quota" in message.lower():
return openai_error_response(
{
"error": {
"message": "no available image quota",
"type": "insufficient_quota",
"param": None,
"code": "insufficient_quota",
}
},
429,
)
if hasattr(exc, "to_openai_error") and hasattr(exc, "status_code"):
return JSONResponse(status_code=int(exc.status_code), content=exc.to_openai_error())
return openai_error_response(message, 502)
def _protocol_error_response(exc: Exception, status_code: int, sse: str) -> JSONResponse:
message = str(exc)
if sse == "anthropic":
return anthropic_error_response(message, status_code)
return openai_error_response(message, status_code)
def _next_item(items):
try:
return True, next(items)
except StopIteration:
return False, None
@dataclass
class LoggedCall:
identity: dict[str, object]
endpoint: str
model: str
summary: str
started: float = field(default_factory=time.time)
request_text: str = ""
request_shape: dict[str, int] | None = None
async def run(self, handler, *args, sse: str = "openai"):
from services.protocol.conversation import ImageGenerationError
try:
result = await run_in_threadpool(handler, *args)
except ImageGenerationError as exc:
self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""),
conversation_id=getattr(exc, "conversation_id", ""))
return _image_error_response(exc)
except HTTPException as exc:
self.log("调用失败", status="failed", error=str(exc.detail))
raise
except Exception as exc:
self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""))
if self.endpoint.startswith("/v1/images"):
return _image_error_response(exc)
return _protocol_error_response(exc, 502, sse)
if isinstance(result, dict):
self.log("调用完成", result)
response = dict(result)
response.pop("_account_email", None)
return response
sender = anthropic_sse_stream if sse == "anthropic" else sse_json_stream
try:
has_first, first = await run_in_threadpool(_next_item, result)
except ImageGenerationError as exc:
self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""),
conversation_id=getattr(exc, "conversation_id", ""))
return _image_error_response(exc)
except HTTPException as exc:
self.log("调用失败", status="failed", error=str(exc.detail))
raise
except Exception as exc:
self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""))
if self.endpoint.startswith("/v1/images"):
return _image_error_response(exc)
return _protocol_error_response(exc, 502, sse)
if not has_first:
self.log("流式调用结束")
return StreamingResponse(sender(()), media_type="text/event-stream")
return StreamingResponse(sender(self.stream(itertools.chain([first], result))), media_type="text/event-stream")
def stream(self, items):
urls: list[str] = []
account_emails: list[str] = []
conversation_ids: list[str] = []
failed = False
try:
for item in items:
urls.extend(_collect_urls(item))
account_emails.extend(_collect_account_emails(item))
conversation_ids.extend(_collect_conversation_ids(item))
yield _strip_internal_response_fields(item)
except Exception as exc:
failed = True
self.log(
"流式调用失败",
status="failed",
error=str(exc),
urls=urls,
account_email=(account_emails[0] if account_emails else getattr(exc, "account_email", "")),
conversation_id=(conversation_ids[0] if conversation_ids else getattr(exc, "conversation_id", "")),
)
if self.endpoint.startswith("/v1/images") and not hasattr(exc, "to_openai_error"):
from services.protocol.conversation import ImageGenerationError, public_image_error_message
raise ImageGenerationError(public_image_error_message(str(exc))) from exc
raise
finally:
if not failed:
self.log("流式调用结束", urls=urls, account_email=account_emails[0] if account_emails else "",
conversation_id=conversation_ids[0] if conversation_ids else "")
def log(self, suffix: str, result: object = None, status: str = "success", error: str = "",
urls: list[str] | None = None, account_email: str = "", conversation_id: str = "") -> None:
detail = {
"key_id": self.identity.get("id"),
"key_name": self.identity.get("name"),
"role": self.identity.get("role"),
"endpoint": self.endpoint,
"model": self.model,
"started_at": datetime.fromtimestamp(self.started).strftime("%Y-%m-%d %H:%M:%S"),
"ended_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"duration_ms": int((time.time() - self.started) * 1000),
"status": status,
}
request_excerpt = _request_excerpt(self.request_text)
if request_excerpt:
detail["request_text"] = request_excerpt
if self.request_shape:
detail["request_shape"] = self.request_shape
if error:
detail["error"] = error
email = str(account_email or "").strip()
if not email:
emails = _collect_account_emails(result)
email = emails[0] if emails else ""
if email:
detail["account_email"] = email
conv_id = str(conversation_id or "").strip()
if not conv_id:
conv_ids = _collect_conversation_ids(result)
conv_id = conv_ids[0] if conv_ids else ""
if conv_id:
detail["conversation_id"] = conv_id
collected_urls = [*(urls or []), *_collect_urls(result)]
if collected_urls and not self.endpoint.startswith("/v1/search"):
detail["urls"] = list(dict.fromkeys(collected_urls))
log_service.add(LOG_TYPE_CALL, f"{self.summary}{suffix}", detail)
+182
View File
@@ -0,0 +1,182 @@
from __future__ import annotations
import time
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from threading import RLock
from typing import Any
from services.account_service import AccountService, account_service
from services.openai_backend_api import OpenAIBackendAPI
from utils.log import logger
@dataclass(frozen=True)
class ModelRoute:
account_types: frozenset[str]
allow_anonymous: bool = False
class ModelUnavailableError(RuntimeError):
pass
class ModelCatalogService:
"""Caches the model catalogs advertised to each active account type."""
def __init__(
self,
accounts: AccountService,
*,
backend_factory: Callable[..., Any] = OpenAIBackendAPI,
cache_ttl_seconds: float = 300,
clock: Callable[[], float] = time.monotonic,
) -> None:
self._accounts = accounts
self._backend_factory = backend_factory
self._cache_ttl_seconds = max(1.0, float(cache_ttl_seconds))
self._clock = clock
self._lock = RLock()
self._expires_at = 0.0
self._account_signature: tuple[tuple[str, int], ...] = ()
self._anonymous_models: dict[str, dict[str, Any]] = {}
self._models_by_account_type: dict[str, dict[str, dict[str, Any]]] = {}
@staticmethod
def _model_map(result: object) -> dict[str, dict[str, Any]]:
if not isinstance(result, dict) or not isinstance(result.get("data"), list):
raise TypeError("upstream model response has no data list")
models: dict[str, dict[str, Any]] = {}
for item in result["data"]:
if not isinstance(item, dict):
continue
model_id = str(item.get("id") or "").strip()
if model_id and model_id not in models:
models[model_id] = dict(item)
return models
def _active_accounts_by_type(self) -> dict[str, list[str]]:
groups: dict[str, list[str]] = {}
for account in self._accounts.list_accounts():
if not isinstance(account, dict) or account.get("status") in {"禁用", "异常"}:
continue
access_token = str(account.get("access_token") or "").strip()
account_type = self._accounts._normalize_account_type(account.get("type"))
if access_token and account_type:
groups.setdefault(account_type, []).append(access_token)
return groups
@staticmethod
def _signature(groups: dict[str, list[str]]) -> tuple[tuple[str, int], ...]:
return tuple(
(account_type, len(tokens))
for account_type, tokens in sorted(groups.items())
)
def _fetch_models(self, access_token: str = "") -> dict[str, dict[str, Any]]:
backend = self._backend_factory(access_token=access_token)
try:
return self._model_map(backend.list_models())
finally:
backend.close()
def _fetch_account_type_models(
self,
account_type: str,
access_tokens: list[str],
) -> dict[str, dict[str, Any]] | None:
attempted_tokens: set[str] = set()
last_error: Exception | None = None
for access_token in access_tokens:
try:
resolved_token = self._accounts.refresh_access_token(
access_token,
event="list_models",
) or access_token
if resolved_token in attempted_tokens:
continue
attempted_tokens.add(resolved_token)
return self._fetch_models(resolved_token)
except Exception as exc: # noqa: BLE001 - try the next account for any upstream failure
last_error = exc
if last_error is not None:
logger.warning({
"event": "model_catalog_account_type_failed",
"account_type": account_type,
"error_type": type(last_error).__name__,
})
return None
def _refresh(self, groups: dict[str, list[str]], signature: tuple[tuple[str, int], ...]) -> None:
models_by_account_type: dict[str, dict[str, dict[str, Any]]] = {}
with ThreadPoolExecutor(max_workers=min(4, len(groups) + 1)) as executor:
anonymous_future = executor.submit(self._fetch_models)
account_futures = {
account_type: executor.submit(
self._fetch_account_type_models,
account_type,
access_tokens,
)
for account_type, access_tokens in groups.items()
}
try:
anonymous_models = anonymous_future.result()
except Exception as exc: # noqa: BLE001 - retain cached models on upstream failure
logger.warning({
"event": "model_catalog_anonymous_failed",
"error_type": type(exc).__name__,
})
anonymous_models = self._anonymous_models
for account_type, future in account_futures.items():
models = future.result()
if models is not None:
models_by_account_type[account_type] = models
elif account_type in self._models_by_account_type:
models_by_account_type[account_type] = self._models_by_account_type[account_type]
self._anonymous_models = anonymous_models
self._models_by_account_type = models_by_account_type
self._account_signature = signature
self._expires_at = self._clock() + self._cache_ttl_seconds
def _ensure_catalog(self) -> None:
groups = self._active_accounts_by_type()
signature = self._signature(groups)
with self._lock:
if signature == self._account_signature and self._clock() < self._expires_at:
return
self._refresh(groups, signature)
def list_models(self) -> dict[str, Any]:
self._ensure_catalog()
with self._lock:
union: dict[str, dict[str, Any]] = {
model_id: dict(item)
for model_id, item in self._anonymous_models.items()
}
for account_type in sorted(self._models_by_account_type):
for model_id, item in self._models_by_account_type[account_type].items():
union.setdefault(model_id, dict(item))
return {
"object": "list",
"data": [union[model_id] for model_id in sorted(union)],
}
def route_for_model(self, model: str) -> ModelRoute:
model = str(model or "").strip()
self._ensure_catalog()
with self._lock:
account_types = frozenset(
account_type
for account_type, models in self._models_by_account_type.items()
if model in models
)
return ModelRoute(
account_types=account_types,
allow_anonymous=model in self._anonymous_models,
)
model_catalog_service = ModelCatalogService(account_service)
+267
View File
@@ -0,0 +1,267 @@
"""手动 OAuth 桥服务
让用户用自己浏览器走一遍 OpenAI 的标准 OAuth + PKCE 授权码流程:
1. 后端生成 code_verifier / code_challenge / state,构造 authorize URL。
2. 用户在浏览器登录,浏览器最终被 OpenAI 重定向到 platform.openai.com 的
callback 地址;用户从地址栏或 devtools 抓出 code,回填到前端。
3. 后端拿之前存好的 code_verifier + 回填的 code 调用 /api/accounts/oauth/token
得到 {access_token, refresh_token, id_token}。
得到的 refresh_token 跟 account_service 自动刷新机制用的 client_id 是同一个
(app_2SKx67EdpoN0G6j64rFvigXD),所以落盘后能直接进入 keepalive 周期。
"""
from __future__ import annotations
import secrets
import threading
import time
import uuid
from typing import Any
from urllib.parse import parse_qs, urlencode, urlparse
from curl_cffi import requests
from services.openai_oauth import (
auth_base,
common_headers,
platform_auth0_client,
platform_base,
platform_oauth_audience,
platform_oauth_client_id,
platform_oauth_redirect_uri,
sec_ch_ua,
user_agent,
)
from services.proxy_service import proxy_settings
class OAuthLoginError(Exception):
"""OAuth 桥流程中的可预期错误,会被 API 层翻译成 400。"""
class OAuthLoginService:
"""维护 PKCE 临时会话,并完成 code → token 的兑换。"""
_SESSION_TTL_SECONDS = 10 * 60 # 用户点开浏览器 + 拿 code 给的时间上限
_MAX_SESSIONS = 64 # 防止异常累积;超过容量时清理最老的
def __init__(self) -> None:
self._lock = threading.Lock()
self._sessions: dict[str, dict[str, Any]] = {}
@staticmethod
def _generate_pkce() -> tuple[str, str]:
"""生成 PKCE code_verifier 与对应的 code_challenge(S256)。"""
from utils.pkce import generate_pkce
return generate_pkce()
def _purge_expired_locked(self) -> None:
"""清理过期或溢出容量的会话,必须在持锁状态下调用。"""
now = time.time()
expired = [sid for sid, item in self._sessions.items() if now - item["created_at"] > self._SESSION_TTL_SECONDS]
for sid in expired:
self._sessions.pop(sid, None)
if len(self._sessions) > self._MAX_SESSIONS:
ordered = sorted(self._sessions.items(), key=lambda kv: kv[1]["created_at"])
for sid, _ in ordered[: len(self._sessions) - self._MAX_SESSIONS]:
self._sessions.pop(sid, None)
def start(self, email_hint: str = "") -> dict[str, str]:
"""登记一个新的 PKCE 会话,返回 session_id 与可让用户打开的 authorize_url。
state 形如 "<session_id>.<nonce>",让 callback URL 自带 session_id,
finish 时即便前端 React 状态被覆盖也能从 URL 恢复正确的 verifier。
"""
verifier, challenge = self._generate_pkce()
nonce = secrets.token_urlsafe(32)
device_id = str(uuid.uuid4())
session_id = uuid.uuid4().hex
state = f"{session_id}.{secrets.token_urlsafe(16)}"
params = {
"issuer": auth_base,
"client_id": platform_oauth_client_id,
"audience": platform_oauth_audience,
"redirect_uri": platform_oauth_redirect_uri,
"device_id": device_id,
"screen_hint": "login_or_signup",
"max_age": "0",
"scope": "openid profile email offline_access",
"response_type": "code",
"response_mode": "query",
"state": state,
"nonce": nonce,
"code_challenge": challenge,
"code_challenge_method": "S256",
"auth0Client": platform_auth0_client,
}
email_hint = str(email_hint or "").strip()
if email_hint:
params["login_hint"] = email_hint
authorize_url = f"{auth_base}/api/accounts/authorize?{urlencode(params)}"
with self._lock:
self._purge_expired_locked()
self._sessions[session_id] = {
"code_verifier": verifier,
"state": state,
"created_at": time.time(),
"redirect_uri": platform_oauth_redirect_uri,
}
return {
"session_id": session_id,
"authorize_url": authorize_url,
"expires_in": str(self._SESSION_TTL_SECONDS),
"redirect_uri_prefix": platform_oauth_redirect_uri,
}
@staticmethod
def _extract_code_from_callback(value: str) -> tuple[str, str]:
"""从 callback URL 或 raw code 中提取 (code, state)。
既允许用户粘贴整段 platform.openai.com/auth/callback?code=...&state=... 的 URL,
也允许只粘 code 本身。
"""
raw = str(value or "").strip()
if not raw:
return "", ""
if raw.startswith("http://") or raw.startswith("https://"):
try:
parsed = parse_qs(urlparse(raw).query)
except Exception as exc:
raise OAuthLoginError(f"无法解析 callback URL: {exc}") from exc
code = str((parsed.get("code") or [""])[0]).strip()
state = str((parsed.get("state") or [""])[0]).strip()
if not code:
err = str((parsed.get("error_description") or parsed.get("error") or [""])[0]).strip()
raise OAuthLoginError(err or "callback URL 中没有 code 参数")
return code, state
# 用户可能直接粘了 code 字符串
return raw, ""
def finish(self, session_id: str, callback: str) -> dict[str, str]:
"""用 session_id 配对的 code_verifier 把 callback 里的 code 换成 token 三件套。
- 优先用 callback URL 自带 state 里的 session_id(更可靠),
找不到才用前端传来的 session_id;
- 失败时不立刻销毁 session(OAuth code 错配换 token 失败通常不会消耗 code),
只有成功兑换才 pop,便于用户用同一 verifier 重试。
"""
body_sid = str(session_id or "").strip()
code, state = self._extract_code_from_callback(callback)
if not code:
raise OAuthLoginError("缺少 code 或 callback URL")
# state 里嵌的 session_id 优先级最高
state_sid = state.split(".", 1)[0] if state else ""
candidate_sids = [sid for sid in (state_sid, body_sid) if sid]
if not candidate_sids:
raise OAuthLoginError("既未提供 session_id,callback URL 中也未携带 state")
with self._lock:
self._purge_expired_locked()
session = None
picked_sid = ""
for sid in candidate_sids:
cur = self._sessions.get(sid)
if cur is not None:
session = cur
picked_sid = sid
break
if session is None:
raise OAuthLoginError(
"OAuth 会话已过期或不存在,请回到导入对话框点\"重新生成\"再走一次"
)
if state and session.get("state") and state != session["state"]:
raise OAuthLoginError(
"state 不匹配。常见原因:你点过两次\"打开授权页面\",但浏览器里登录的还是前一次的窗口。请点\"重新生成\"重来。"
)
tokens = self._exchange_code(
code,
session["code_verifier"],
session.get("redirect_uri") or platform_oauth_redirect_uri,
)
# 仅在成功兑换之后才消耗 session
with self._lock:
self._sessions.pop(picked_sid, None)
return tokens
@staticmethod
def _exchange_code(code: str, code_verifier: str, redirect_uri: str) -> dict[str, str]:
"""调用 /api/accounts/oauth/token 用 code+verifier 换 token 三件套。"""
kwargs = proxy_settings.build_session_kwargs(impersonate="chrome", verify=False)
session = requests.Session(**kwargs)
try:
response = session.post(
f"{auth_base}/api/accounts/oauth/token",
headers={
**common_headers,
"referer": f"{platform_base}/",
"origin": platform_base,
"auth0-client": platform_auth0_client,
"sec-ch-ua": sec_ch_ua,
"user-agent": user_agent,
},
json={
"client_id": platform_oauth_client_id,
"code_verifier": code_verifier,
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
},
timeout=60,
)
except Exception as exc:
raise OAuthLoginError(f"换 token 网络异常: {exc}") from exc
finally:
session.close()
try:
data = response.json() if response.text else {}
except Exception:
data = {}
if response.status_code != 200 or not isinstance(data, dict) or not data.get("access_token"):
detail = ""
if isinstance(data, dict):
detail = str(data.get("error_description") or data.get("error") or data.get("message") or "")
if not detail:
try:
detail = str(response.text or "")[:300]
except Exception:
detail = ""
# 打到 docker logs 方便排错——OAuth 换 token 的失败原因往往只有这里能看到
print(
f"[oauth-login] /api/accounts/oauth/token rejected: "
f"status={response.status_code} detail={detail!r} "
f"raw_body={(getattr(response, 'text', '') or '')[:500]!r}",
flush=True,
)
raise OAuthLoginError(
f"OpenAI 拒绝换 token (HTTP {response.status_code}){': ' + detail if detail else ''}"
)
access_token = str(data.get("access_token") or "").strip()
refresh_token = str(data.get("refresh_token") or "").strip()
id_token = str(data.get("id_token") or "").strip()
if not access_token:
raise OAuthLoginError("OpenAI 返回的 access_token 为空")
if not refresh_token:
# scope 含 offline_access 时正常会下发 refresh_token;这里给出明确提示
raise OAuthLoginError(
"OpenAI 没有返回 refresh_token(可能 scope 未包含 offline_access 或 code 已使用过)"
)
return {
"access_token": access_token,
"refresh_token": refresh_token,
"id_token": id_token,
}
oauth_login_service = OAuthLoginService()
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
auth_base = "https://auth.openai.com"
platform_base = "https://platform.openai.com"
platform_oauth_client_id = "app_2SKx67EdpoN0G6j64rFvigXD"
platform_oauth_redirect_uri = f"{platform_base}/auth/callback"
platform_oauth_audience = "https://api.openai.com/v1"
platform_auth0_client = "eyJuYW1lIjoiYXV0aDAtc3BhLWpzIiwidmVyc2lvbiI6IjEuMjEuMCJ9"
user_agent = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/145.0.0.0 Safari/537.36"
)
sec_ch_ua = '"Google Chrome";v="145", "Not?A_Brand";v="8", "Chromium";v="145"'
common_headers = {
"accept": "application/json",
"accept-encoding": "gzip, deflate, br",
"accept-language": "en-US,en;q=0.9",
"cache-control": "no-cache",
"connection": "keep-alive",
"content-type": "application/json",
"dnt": "1",
"origin": auth_base,
"priority": "u=1, i",
"sec-gpc": "1",
"sec-ch-ua": sec_ch_ua,
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin",
"user-agent": user_agent,
}
+2
View File
@@ -0,0 +1,2 @@
"""Protocol converters for OpenAI-compatible endpoints."""
+307
View File
@@ -0,0 +1,307 @@
from __future__ import annotations
import html
import json
import re
import time
import uuid
from collections.abc import Callable, Iterable, Iterator
from dataclasses import dataclass
from typing import Any
from services.account_service import account_service
from services.openai_backend_api import OpenAIBackendAPI
from services.protocol.conversation import count_message_tokens, count_text_tokens, normalize_messages
from services.protocol.openai_v1_chat_complete import collect_chat_content, stream_text_chat_completion
XML_TOOL_RULE = """Tool output adapter: when calling tools, output ONLY this XML and no prose/markdown:
<tool_calls><tool_call><tool_name>TOOL_NAME</tool_name><parameters><PARAM><![CDATA[value]]></PARAM></parameters></tool_call></tool_calls>"""
@dataclass
class MessageRequest:
backend: OpenAIBackendAPI
messages: list[dict[str, Any]]
model: str
tools: Any = None
def _tool_meta(tool: dict[str, object]) -> tuple[str, str, object]:
fn = tool.get("function") if isinstance(tool.get("function"), dict) else {}
name = str(tool.get("name") or fn.get("name") or "").strip()
desc = str(tool.get("description") or fn.get("description") or "").strip()
schema = tool.get("input_schema") or tool.get("parameters") or fn.get("input_schema") or fn.get("parameters") or {}
return name, desc, schema
def build_tool_prompt(tools: object) -> str:
if not isinstance(tools, list):
return ""
blocks = []
for tool in tools:
if not isinstance(tool, dict):
continue
name, desc, schema = _tool_meta(tool)
if name:
blocks.append(f"Tool: {name}\nDescription: {desc}\nParameters: {json.dumps(schema, ensure_ascii=False)}")
if not blocks:
return ""
return "Available tools:\n" + "\n".join(blocks) + """
Tool use rules:
- If the user asks to list/read/search files, inspect project state, run a command, or answer from local code, you MUST call a suitable tool first. Do not say you cannot access files.
- To call tools, output ONLY XML and no prose/markdown:
<tool_calls><tool_call><tool_name>TOOL_NAME</tool_name><parameters><PARAM><![CDATA[value]]></PARAM></parameters></tool_call></tool_calls>
- Put parameters under <parameters> using the exact schema names.
""".strip()
def merge_system(system: object, extra: str) -> object:
system = compact_system(system)
if _has_claude_code_system(system):
extra = XML_TOOL_RULE
if not extra:
return system
if isinstance(system, str) and system.strip():
return f"{system.strip()}\n\n{extra}"
if isinstance(system, list):
return [*system, {"type": "text", "text": extra}]
return extra
def _has_claude_code_system(system: object) -> bool:
if isinstance(system, str):
return "You are Claude Code" in system
if isinstance(system, list):
return any(isinstance(item, dict) and "You are Claude Code" in str(item.get("text") or "") for item in system)
return False
def compact_system(system: object) -> object:
if isinstance(system, str):
return _compact_system_text(system)
if isinstance(system, list):
result = []
for item in system:
if isinstance(item, dict) and str(item.get("type") or "") == "text":
copied = dict(item)
copied["text"] = _compact_system_text(str(item.get("text") or ""))
result.append(copied)
else:
result.append(item)
return result
return system
def _compact_system_text(text: str) -> str:
return text or ""
def _compact_message_text(text: str) -> str:
return text or ""
def preprocess_payload(payload: dict[str, object], text_mapper: Callable[[str], str] | None = None) -> dict[str, object]:
payload["messages"] = preprocess_messages(payload.get("messages"), text_mapper)
payload["system"] = merge_system(payload.get("system"), build_tool_prompt(payload.get("tools")))
return payload
def message_request(body: dict[str, Any]) -> MessageRequest:
payload = preprocess_payload(dict(body))
model = str(payload.get("model") or "auto").strip() or "auto"
return MessageRequest(
backend=OpenAIBackendAPI(access_token=account_service.get_text_access_token(model=model)),
messages=normalize_messages(payload.get("messages"), payload.get("system")),
model=model,
tools=payload.get("tools"),
)
def preprocess_messages(messages: object, text_mapper: Callable[[str], str] | None = None) -> object:
if not isinstance(messages, list):
return messages
mapper = text_mapper or (lambda text: text)
result = []
for message in messages:
if not isinstance(message, dict):
continue
item = dict(message)
content = item.get("content")
if isinstance(content, str):
item["content"] = _compact_message_text(mapper(content))
elif isinstance(content, list):
item["content"] = [_preprocess_block(block, mapper) for block in content]
result.append(item)
return result
def _preprocess_block(block: object, text_mapper: Callable[[str], str]) -> object:
if not isinstance(block, dict):
return block
block_type = str(block.get("type") or "")
if block_type == "text":
item = dict(block)
item["text"] = _compact_message_text(text_mapper(str(block.get("text") or "")))
return item
if block_type == "tool_use":
return {"type": "text", "text": f"<tool_calls><tool_call><tool_name>{block.get('name') or ''}</tool_name><parameters>{json.dumps(block.get('input') or {}, ensure_ascii=False)}</parameters></tool_call></tool_calls>"}
if block_type == "tool_result":
return {"type": "text", "text": f"Tool result {block.get('tool_use_id') or ''}: {block.get('content') or ''}"}
return block
def message_response(model: str, text: str, input_tokens: int, output_tokens: int, tools: object = None) -> dict[str, object]:
content, stop_reason = content_blocks(text, tools)
return {
"id": f"msg_{uuid.uuid4()}",
"type": "message",
"role": "assistant",
"model": model,
"content": content,
"stop_reason": stop_reason,
"stop_sequence": None,
"usage": {"input_tokens": input_tokens, "output_tokens": output_tokens},
}
def content_blocks(text: str, tools: object = None) -> tuple[list[dict[str, object]], str]:
calls = parse_tool_calls(text) if isinstance(tools, list) and tools else []
text = strip_tool_markup(text)
if calls:
content = ([{"type": "text", "text": text}] if text else []) + [{"type": "tool_use", "id": f"toolu_{uuid.uuid4()}", "name": name, "input": args} for name, args in calls]
return content, "tool_use"
return [{"type": "text", "text": text}], "end_turn"
def strip_tool_markup(text: str) -> str:
return re.sub(r"(?is)<tool_calls\b[^>]*>.*?</tool_calls>|<tool_call\b[^>]*>.*?</tool_call>|<function_call\b[^>]*>.*?</function_call>|<invoke\b[^>]*>.*?</invoke>", "", text or "").strip()
def streamable_text(text: str) -> str:
text = text or ""
match = re.search(r"(?is)<tool_calls\b|<tool_call\b|<function_call\b|<invoke\b", text)
return text[:match.start()].rstrip() if match else text
def parse_tool_calls(text: str) -> list[tuple[str, dict[str, object]]]:
text = re.sub(r"(?is)```.*?```", "", text or "").strip()
blocks = re.findall(r"(?is)<tool_call\b[^>]*>(.*?)</tool_call>|<function_call\b[^>]*>(.*?)</function_call>|<invoke\b[^>]*>(.*?)</invoke>", text)
result = []
for block in (next((part for part in match if part), "") for match in blocks):
name = xml_value(block, "tool_name") or xml_value(block, "name") or xml_value(block, "function")
params = xml_value(block, "parameters") or xml_value(block, "input") or xml_value(block, "arguments") or "{}"
if name:
result.append((name, parse_tool_params(params)))
return result
def xml_value(text: str, tag: str) -> str:
match = re.search(rf"(?is)<{tag}\b[^>]*>(.*?)</{tag}>", text)
if not match:
return ""
value = match.group(1).strip()
cdata = re.fullmatch(r"(?is)<!\[CDATA\[(.*?)]]>", value)
return html.unescape(cdata.group(1) if cdata else value).strip()
def parse_tool_params(raw: str) -> dict[str, object]:
raw = raw.strip()
try:
parsed = json.loads(raw)
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {m.group(1): parse_tool_value(m.group(2)) for m in re.finditer(r"(?is)<([\w.-]+)\b[^>]*>(.*?)</\1>", raw)}
def parse_tool_value(raw: str) -> object:
value = xml_value(f"<x>{raw}</x>", "x")
try:
return json.loads(value)
except Exception:
return value
def stream_events(chunks: Iterable[dict[str, object]], model: str, input_tokens: int, output_tokens: Callable[[str], int], tools: object = None) -> Iterator[dict[str, object]]:
message_id = f"msg_{uuid.uuid4()}"
created = int(time.time())
current_text = ""
streamed_text = ""
tool_mode = isinstance(tools, list) and bool(tools)
tool_started = False
text_open = False
yield {"type": "message_start", "message": {"id": message_id, "type": "message", "role": "assistant", "model": model, "content": [], "stop_reason": None, "stop_sequence": None, "usage": {"input_tokens": input_tokens, "output_tokens": 0}}}
if not tool_mode:
text_open = True
yield {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}
for chunk in chunks:
choice = (chunk.get("choices") or [{}])[0]
delta = choice.get("delta") or {}
text_delta = delta.get("content", "") if isinstance(delta, dict) else ""
if text_delta:
current_text += text_delta
if not tool_started:
visible_text = current_text if not tool_mode else streamable_text(current_text)
if visible_text.startswith(streamed_text):
text_delta = visible_text[len(streamed_text):]
if text_delta:
if not text_open:
text_open = True
yield {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}
streamed_text = visible_text
yield {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": text_delta}}
tool_started = tool_mode and visible_text != current_text
if choice.get("finish_reason"):
content, stop_reason = content_blocks(current_text, tools)
if text_open:
yield {"type": "content_block_stop", "index": 0}
if stop_reason == "tool_use":
start_index = 1 if text_open else 0
if content and content[0]["type"] == "text":
remaining = str(content[0].get("text") or "")[len(streamed_text):]
if remaining:
if not text_open:
yield {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}
yield {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": remaining}}
if not text_open:
yield {"type": "content_block_stop", "index": 0}
start_index = 1
content = content[1:]
yield from _stream_buffered_blocks(content, start_index)
yield {"type": "message_delta", "delta": {"stop_reason": stop_reason, "stop_sequence": None}, "usage": {"output_tokens": output_tokens(current_text)}}
break
yield {"type": "message_stop", "created": created}
def _stream_buffered_blocks(content: list[dict[str, object]], start_index: int = 0) -> Iterator[dict[str, object]]:
for offset, block in enumerate(content):
index = start_index + offset
if block["type"] == "tool_use":
start = {"type": "tool_use", "id": block["id"], "name": block["name"], "input": {}}
delta = {"type": "input_json_delta", "partial_json": json.dumps(block.get("input") or {}, ensure_ascii=False)}
else:
start = {"type": "text", "text": ""}
delta = {"type": "text_delta", "text": block.get("text") or ""}
yield {"type": "content_block_start", "index": index, "content_block": start}
yield {"type": "content_block_delta", "index": index, "delta": delta}
yield {"type": "content_block_stop", "index": index}
def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
request = message_request(body)
if body.get("stream"):
return stream_events(
stream_text_chat_completion(request.backend, request.messages, request.model),
request.model,
count_message_tokens(request.messages, request.model),
lambda text: count_text_tokens(text, request.model),
request.tools,
)
text = collect_chat_content(stream_text_chat_completion(request.backend, request.messages, request.model))
return message_response(
request.model,
text,
count_message_tokens(request.messages, request.model),
count_text_tokens(text, request.model),
request.tools,
)
+238
View File
@@ -0,0 +1,238 @@
from __future__ import annotations
import copy
import hashlib
import json
import threading
import time
from dataclasses import dataclass, field
from typing import Any, Callable, Iterable, Iterator
from services.config import config
CACHEABLE_TEXT_KEYS = {
"frequency_penalty",
"max_completion_tokens",
"max_tokens",
"metadata",
"model",
"presence_penalty",
"reasoning_effort",
"response_format",
"seed",
"stop",
"temperature",
"thinking_effort",
"tool_choice",
"tools",
"top_p",
"user",
"reasoning",
}
@dataclass
class CacheEntry:
expires_at: float
value: Any
@dataclass
class InflightCall:
condition: threading.Condition = field(default_factory=lambda: threading.Condition(threading.RLock()))
done: bool = False
value: Any = None
error: BaseException | None = None
def _json_safe(value: Any) -> Any:
if isinstance(value, bytes):
return {"__bytes_sha256__": hashlib.sha256(value).hexdigest(), "length": len(value)}
if isinstance(value, bytearray):
data = bytes(value)
return {"__bytes_sha256__": hashlib.sha256(data).hexdigest(), "length": len(data)}
if isinstance(value, dict):
return {str(key): _json_safe(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_json_safe(item) for item in value]
return value
def canonical_body(body: dict[str, Any], messages: list[dict[str, Any]], *, stream: bool) -> dict[str, Any]:
payload = {key: body.get(key) for key in CACHEABLE_TEXT_KEYS if key in body}
payload["messages"] = messages
payload["stream"] = bool(stream)
return payload
def cache_key(body: dict[str, Any], messages: list[dict[str, Any]], *, stream: bool) -> str:
encoded = json.dumps(
_json_safe(canonical_body(body, messages, stream=stream)),
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _message_signature(message: dict[str, Any]) -> str:
return json.dumps(_json_safe(message), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def normalize_text_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
settings = config.get_chat_completion_cache_settings()
if not settings.get("normalize_messages"):
return messages
normalized: list[dict[str, Any]] = []
previous_signature = ""
for message in messages:
if settings.get("drop_assistant_history") and str(message.get("role") or "") == "assistant":
continue
signature = _message_signature(message)
if settings.get("drop_adjacent_duplicates") and signature == previous_signature:
continue
normalized.append(message)
previous_signature = signature
return normalized
class ChatCompletionCache:
def __init__(self) -> None:
self._lock = threading.RLock()
self._entries: dict[str, CacheEntry] = {}
self._inflight: dict[str, InflightCall] = {}
def clear(self) -> None:
with self._lock:
self._entries.clear()
self._inflight.clear()
def _settings(self) -> dict[str, object]:
return config.get_chat_completion_cache_settings()
def _prune_locked(self, now: float, max_entries: int) -> None:
expired = [key for key, item in self._entries.items() if item.expires_at <= now]
for key in expired:
self._entries.pop(key, None)
while len(self._entries) > max_entries:
oldest_key = min(self._entries, key=lambda key: self._entries[key].expires_at)
self._entries.pop(oldest_key, None)
@staticmethod
def _copy(value: Any) -> Any:
return copy.deepcopy(value)
def get_or_compute_response(self, key: str, compute: Callable[[], dict[str, Any]]) -> dict[str, Any]:
settings = self._settings()
if not settings.get("enabled") or int(settings.get("ttl_seconds") or 0) <= 0:
return compute()
now = time.time()
max_entries = int(settings.get("max_entries") or 1)
with self._lock:
self._prune_locked(now, max_entries)
entry = self._entries.get(key)
if entry and entry.expires_at > now:
return self._copy(entry.value)
inflight = self._inflight.get(key) if settings.get("dedupe_inflight") else None
if inflight is None:
inflight = InflightCall()
if settings.get("dedupe_inflight"):
self._inflight[key] = inflight
owner = True
else:
owner = False
if not owner:
with inflight.condition:
while not inflight.done:
inflight.condition.wait()
if inflight.error:
raise inflight.error
return self._copy(inflight.value)
try:
value = compute()
except BaseException as exc:
with self._lock:
self._inflight.pop(key, None)
with inflight.condition:
inflight.error = exc
inflight.done = True
inflight.condition.notify_all()
raise
expires_at = time.time() + int(settings.get("ttl_seconds") or 0)
with self._lock:
self._entries[key] = CacheEntry(expires_at=expires_at, value=self._copy(value))
self._prune_locked(time.time(), max_entries)
self._inflight.pop(key, None)
with inflight.condition:
inflight.value = self._copy(value)
inflight.done = True
inflight.condition.notify_all()
return value
def get_or_compute_stream(self, key: str, compute: Callable[[], Iterable[dict[str, Any]]]) -> Iterator[dict[str, Any]]:
settings = self._settings()
if (
not settings.get("enabled")
or not settings.get("stream_cache")
or int(settings.get("ttl_seconds") or 0) <= 0
):
yield from compute()
return
now = time.time()
max_entries = int(settings.get("max_entries") or 1)
with self._lock:
self._prune_locked(now, max_entries)
entry = self._entries.get(key)
if entry and entry.expires_at > now:
yield from self._copy(entry.value)
return
inflight = self._inflight.get(key) if settings.get("dedupe_inflight") else None
if inflight is None:
inflight = InflightCall()
if settings.get("dedupe_inflight"):
self._inflight[key] = inflight
owner = True
else:
owner = False
if not owner:
with inflight.condition:
while not inflight.done:
inflight.condition.wait()
if inflight.error:
raise inflight.error
yield from self._copy(inflight.value)
return
chunks: list[dict[str, Any]] = []
try:
for chunk in compute():
chunks.append(self._copy(chunk))
yield chunk
except BaseException as exc:
with self._lock:
self._inflight.pop(key, None)
with inflight.condition:
inflight.error = exc
inflight.done = True
inflight.condition.notify_all()
raise
expires_at = time.time() + int(settings.get("ttl_seconds") or 0)
with self._lock:
self._entries[key] = CacheEntry(expires_at=expires_at, value=self._copy(chunks))
self._prune_locked(time.time(), max_entries)
self._inflight.pop(key, None)
with inflight.condition:
inflight.value = self._copy(chunks)
inflight.done = True
inflight.condition.notify_all()
chat_completion_cache = ChatCompletionCache()
File diff suppressed because it is too large Load Diff
+124
View File
@@ -0,0 +1,124 @@
from __future__ import annotations
from typing import Any
from fastapi.responses import JSONResponse
def _message_from_value(value: object) -> str:
if isinstance(value, str):
return value
if not isinstance(value, dict):
return ""
message = value.get("message")
if isinstance(message, str) and message:
return message
return _message_from_value(value.get("error"))
def error_message_from_detail(detail: object) -> str:
if isinstance(detail, list):
messages = []
for item in detail:
if not isinstance(item, dict):
continue
location = ".".join(str(part) for part in item.get("loc", []) if part != "body")
message = str(item.get("msg") or "").strip()
if location and message:
messages.append(f"{location}: {message}")
elif message:
messages.append(message)
return "; ".join(messages)
if isinstance(detail, dict):
message = _message_from_value(detail.get("error")) or _message_from_value(detail)
if message:
return message
return str(detail or "").strip()
def _default_error_type(status_code: int) -> str:
if status_code == 401:
return "authentication_error"
if status_code == 403:
return "permission_error"
if status_code == 429:
return "rate_limit_error"
if 400 <= status_code < 500:
return "invalid_request_error"
return "server_error"
def _default_error_code(status_code: int) -> str:
if status_code == 401:
return "invalid_api_key"
if status_code == 403:
return "permission_denied"
if status_code == 429:
return "rate_limit_exceeded"
if 400 <= status_code < 500:
return "bad_request"
return "upstream_error"
def openai_error_payload(
detail: object,
status_code: int,
*,
error_type: str | None = None,
code: object | None = None,
param: object | None = None,
) -> dict[str, Any]:
error_detail = detail.get("error") if isinstance(detail, dict) else None
if isinstance(error_detail, dict):
return {
"error": {
"message": error_message_from_detail(error_detail) or "request failed",
"type": str(error_detail.get("type") or error_type or _default_error_type(status_code)),
"param": error_detail.get("param", param),
"code": error_detail.get("code", code if code is not None else _default_error_code(status_code)),
}
}
return {
"error": {
"message": error_message_from_detail(detail) or "request failed",
"type": error_type or _default_error_type(status_code),
"param": param,
"code": code if code is not None else _default_error_code(status_code),
}
}
def openai_error_response(
detail: object,
status_code: int,
*,
headers: dict[str, str] | None = None,
error_type: str | None = None,
code: object | None = None,
param: object | None = None,
) -> JSONResponse:
return JSONResponse(
status_code=status_code,
content=openai_error_payload(detail, status_code, error_type=error_type, code=code, param=param),
headers=headers,
)
def anthropic_error_response(
detail: object,
status_code: int,
*,
headers: dict[str, str] | None = None,
) -> JSONResponse:
error_type = "api_error" if status_code >= 500 else _default_error_type(status_code)
return JSONResponse(
status_code=status_code,
content={
"type": "error",
"error": {
"type": error_type,
"message": error_message_from_detail(detail) or "request failed",
},
},
headers=headers,
)
+19
View File
@@ -0,0 +1,19 @@
from __future__ import annotations
from services.account_service import account_service
from services.openai_backend_api import OpenAIBackendAPI, SEARCH_MODEL
MODEL = SEARCH_MODEL
def handle(body: dict[str, object]) -> dict[str, object]:
token = account_service.get_text_access_token()
account = account_service.get_account(token) or {}
backend = OpenAIBackendAPI(token)
try:
result = backend.search(str(body["prompt"]))
finally:
backend.close()
account_service.mark_text_used(token)
result["_account_email"] = str(account.get("email") or "")
return result
@@ -0,0 +1,316 @@
from __future__ import annotations
import time
import uuid
from typing import Any, Iterable, Iterator
from fastapi import HTTPException
from services.protocol.chat_completion_cache import cache_key, chat_completion_cache, normalize_text_messages
from services.protocol.conversation import (
ConversationRequest,
ImageOutput,
collect_image_outputs,
collect_text,
count_message_image_tokens,
count_message_text_tokens,
count_text_tokens,
encode_images,
normalize_messages,
stream_image_outputs_with_pool,
stream_text_deltas,
text_backend,
)
from services.protocol.web_search_tool import (
WEB_SEARCH_TOOL_TYPES,
has_unsupported_tools,
is_web_search_chat_request,
run_web_search,
search_query_from_messages,
text_with_url_citations,
)
from utils.helper import build_chat_image_markdown_content, extract_chat_image, extract_chat_prompt, is_image_chat_request, parse_image_count
from utils.image_tokens import (
chat_usage_from_image_usage,
count_image_inputs_tokens,
count_image_output_items_tokens,
image_usage,
)
TOOL_UNAVAILABLE_SYSTEM_MESSAGE = (
"This compatibility backend cannot execute local tools, shell commands, non-search tools, "
"or file operations. Do not claim to have run tools or inspected external resources. "
"If a user asks you to use a tool, say that tool execution is unavailable through this backend."
)
def normalize_thinking_effort(value: object) -> str:
normalized = str(value or "").strip().lower()
if normalized in {"", "none"}:
return ""
if normalized in {"low", "medium", "high"}:
return normalized
if normalized in {"xhigh", "extended"}:
return "extended"
return ""
def thinking_effort_from_body(body: dict[str, Any]) -> str:
if "thinking_effort" in body:
return normalize_thinking_effort(body.get("thinking_effort"))
if "reasoning_effort" in body:
return normalize_thinking_effort(body.get("reasoning_effort"))
reasoning = body.get("reasoning")
if isinstance(reasoning, dict):
return normalize_thinking_effort(reasoning.get("effort"))
return ""
def completion_chunk(model: str, delta: dict[str, Any], finish_reason: str | None = None, completion_id: str = "", created: int | None = None) -> dict[str, Any]:
return {
"id": completion_id or f"chatcmpl-{uuid.uuid4().hex}",
"object": "chat.completion.chunk",
"created": created or int(time.time()),
"model": model,
"choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}],
}
def completion_response(
model: str,
content: str,
created: int | None = None,
messages: list[dict[str, Any]] | None = None,
annotations: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
prompt_text_tokens = count_message_text_tokens(messages, model) if messages else 0
prompt_image_tokens = count_message_image_tokens(messages, model) if messages else 0
prompt_tokens = prompt_text_tokens + prompt_image_tokens
completion_tokens = count_text_tokens(content, model) if messages else 0
message = {"role": "assistant", "content": content}
if annotations:
message["annotations"] = annotations
return {
"id": f"chatcmpl-{uuid.uuid4().hex}",
"object": "chat.completion",
"created": created or int(time.time()),
"model": model,
"choices": [{
"index": 0,
"message": message,
"finish_reason": "stop",
}],
"usage": {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": prompt_tokens + completion_tokens,
"prompt_tokens_details": {
"text_tokens": prompt_text_tokens,
"image_tokens": prompt_image_tokens,
"cached_tokens": 0,
},
"completion_tokens_details": {
"text_tokens": completion_tokens,
"image_tokens": 0,
"reasoning_tokens": 0,
},
},
}
def stream_text_chat_completion(
backend,
messages: list[dict[str, Any]],
model: str,
thinking_effort: str = "",
) -> Iterator[dict[str, Any]]:
completion_id = f"chatcmpl-{uuid.uuid4().hex}"
created = int(time.time())
sent_role = False
request = ConversationRequest(model=model, messages=messages, thinking_effort=thinking_effort)
for delta_text in stream_text_deltas(backend, request):
if not sent_role:
sent_role = True
yield completion_chunk(model, {"role": "assistant", "content": delta_text}, None, completion_id, created)
else:
yield completion_chunk(model, {"content": delta_text}, None, completion_id, created)
if not sent_role:
yield completion_chunk(model, {"role": "assistant", "content": ""}, None, completion_id, created)
yield completion_chunk(model, {}, "stop", completion_id, created)
def collect_chat_content(chunks: Iterable[dict[str, Any]]) -> str:
parts: list[str] = []
for chunk in chunks:
choices = chunk.get("choices")
first = choices[0] if isinstance(choices, list) and choices and isinstance(choices[0], dict) else {}
delta = first.get("delta") if isinstance(first.get("delta"), dict) else {}
content = str(delta.get("content") or "")
if content:
parts.append(content)
return "".join(parts)
def chat_messages_from_body(body: dict[str, Any]) -> list[dict[str, Any]]:
messages = body.get("messages")
if isinstance(messages, list) and messages:
return [message for message in messages if isinstance(message, dict)]
prompt = str(body.get("prompt") or "").strip()
if prompt:
return [{"role": "user", "content": prompt}]
raise HTTPException(status_code=400, detail={"error": "messages or prompt is required"})
def chat_image_args(body: dict[str, Any]) -> tuple[str, str, int, list[tuple[bytes, str, str]]]:
model = str(body.get("model") or "gpt-image-2").strip() or "gpt-image-2"
prompt = extract_chat_prompt(body)
if not prompt:
raise HTTPException(status_code=400, detail={"error": "prompt is required"})
images = [
(data, f"image_{idx}.png", mime)
for idx, (data, mime) in enumerate(extract_chat_image(body), start=1)
]
return model, prompt, parse_image_count(body.get("n")), images
def text_chat_parts(body: dict[str, Any]) -> tuple[str, list[dict[str, Any]]]:
model = str(body.get("model") or "auto").strip() or "auto"
messages = normalize_text_messages(normalize_messages(chat_messages_from_body(body)))
if has_unsupported_tools(body, WEB_SEARCH_TOOL_TYPES):
messages.insert(0, {"role": "system", "content": TOOL_UNAVAILABLE_SYSTEM_MESSAGE})
return model, messages
def chat_completion_annotations(annotations: list[dict[str, Any]]) -> list[dict[str, Any]]:
output = []
for item in annotations:
if item.get("type") != "url_citation":
continue
output.append({
"type": "url_citation",
"url_citation": {
"start_index": item.get("start_index", 0),
"end_index": item.get("end_index", 0),
"url": item.get("url", ""),
"title": item.get("title", ""),
},
})
return output
def web_search_chat_response(messages: list[dict[str, Any]], model: str) -> dict[str, Any]:
query = search_query_from_messages(messages)
if not query:
raise HTTPException(status_code=400, detail={"error": "messages or prompt is required for web search"})
text, annotations = text_with_url_citations(run_web_search(query))
return completion_response(
model,
text,
messages=messages,
annotations=chat_completion_annotations(annotations),
)
def stream_web_search_chat_completion(messages: list[dict[str, Any]], model: str) -> Iterator[dict[str, Any]]:
query = search_query_from_messages(messages)
if not query:
raise HTTPException(status_code=400, detail={"error": "messages or prompt is required for web search"})
text, _annotations = text_with_url_citations(run_web_search(query))
completion_id = f"chatcmpl-{uuid.uuid4().hex}"
created = int(time.time())
yield completion_chunk(model, {"role": "assistant", "content": text}, None, completion_id, created)
yield completion_chunk(model, {}, "stop", completion_id, created)
def image_result_content(result: dict[str, Any]) -> str:
data = result.get("data")
if isinstance(data, list) and data:
return build_chat_image_markdown_content(result)
return str(result.get("message") or "Image generation completed.")
def image_chat_response(body: dict[str, Any]) -> dict[str, Any]:
model, prompt, n, images = chat_image_args(body)
result = collect_image_outputs(stream_image_outputs_with_pool(ConversationRequest(
prompt=prompt,
model=model,
n=n,
response_format="b64_json",
images=encode_images(images) or None,
)))
response = completion_response(model, image_result_content(result), int(result.get("created") or 0) or None)
usage = image_usage(
input_text_tokens=count_text_tokens(prompt, model),
input_image_tokens=count_image_inputs_tokens(images, model),
output_tokens=count_image_output_items_tokens(result.get("data")),
)
response["usage"] = chat_usage_from_image_usage(usage)
return response
def image_chat_events(body: dict[str, Any]) -> Iterator[dict[str, Any]]:
model, prompt, n, images = chat_image_args(body)
image_outputs = stream_image_outputs_with_pool(ConversationRequest(
prompt=prompt,
model=model,
n=n,
response_format="b64_json",
images=encode_images(images) or None,
))
yield from stream_image_chat_completion(image_outputs, model)
def stream_image_chat_completion(image_outputs: Iterable[ImageOutput], model: str) -> Iterator[dict[str, Any]]:
completion_id = f"chatcmpl-{uuid.uuid4().hex}"
created = int(time.time())
sent_role = False
sent_text = ""
for output in image_outputs:
content = ""
if output.kind == "progress":
content = output.text
sent_text += content
elif output.kind == "result":
content = build_chat_image_markdown_content({"data": output.data})
elif output.kind == "message":
content = output.text[len(sent_text):] if output.text.startswith(sent_text) else output.text
if not content:
continue
if not sent_role:
sent_role = True
yield completion_chunk(model, {"role": "assistant", "content": content}, None, completion_id, created)
else:
yield completion_chunk(model, {"content": content}, None, completion_id, created)
if not sent_role:
yield completion_chunk(model, {"role": "assistant", "content": ""}, None, completion_id, created)
yield completion_chunk(model, {}, "stop", completion_id, created)
def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
if body.get("stream"):
if is_image_chat_request(body):
return image_chat_events(body)
model, messages = text_chat_parts(body)
if is_web_search_chat_request(body) and not has_unsupported_tools(body, WEB_SEARCH_TOOL_TYPES):
return stream_web_search_chat_completion(messages, model)
thinking_effort = thinking_effort_from_body(body)
key = cache_key(body, messages, stream=True)
return chat_completion_cache.get_or_compute_stream(
key,
lambda: stream_text_chat_completion(text_backend(model), messages, model, thinking_effort),
)
if is_image_chat_request(body):
return image_chat_response(body)
model, messages = text_chat_parts(body)
if is_web_search_chat_request(body) and not has_unsupported_tools(body, WEB_SEARCH_TOOL_TYPES):
return web_search_chat_response(messages, model)
thinking_effort = thinking_effort_from_body(body)
key = cache_key(body, messages, stream=False)
return chat_completion_cache.get_or_compute_response(
key,
lambda: completion_response(
model,
collect_text(text_backend(model), ConversationRequest(model=model, messages=messages, thinking_effort=thinking_effort)),
messages=messages,
),
)
+86
View File
@@ -0,0 +1,86 @@
from __future__ import annotations
from io import BytesIO
from typing import Any, Iterator
from PIL import Image
from services.protocol.conversation import (
ConversationRequest,
ImageGenerationError,
collect_image_outputs,
count_text_tokens,
encode_images,
stream_image_chunks,
stream_image_outputs_with_pool,
)
from utils.image_tokens import count_image_inputs_tokens, count_image_output_items_tokens, image_usage
def _composite_mask(
images: list[tuple[bytes, str, str]],
masks: list[tuple[bytes, str, str]],
) -> list[tuple[bytes, str, str]]:
"""将 mask 的 alpha 通道合成到图片中,标识需要编辑的区域。
mask 的透明区域(低 alpha)= 需要编辑的区域,
mask 的不透明区域(高 alpha)= 保留的区域。
如果无 mask 则返回原图。
"""
if not masks:
return images
result: list[tuple[bytes, str, str]] = []
for i, (data, filename, mime_type) in enumerate(images):
mask_data = masks[i][0] if i < len(masks) else masks[-1][0]
img = Image.open(BytesIO(data)).convert("RGBA")
mask_img = Image.open(BytesIO(mask_data))
if mask_img.mode == "RGBA":
alpha = mask_img.split()[3]
elif mask_img.mode == "L":
alpha = mask_img
else:
alpha = mask_img.convert("L")
alpha = alpha.resize(img.size, Image.LANCZOS)
img.putalpha(alpha)
buf = BytesIO()
img.save(buf, format="PNG")
result.append((buf.getvalue(), filename, "image/png"))
return result
def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
prompt = str(body.get("prompt") or "")
images = body.get("images") or []
masks = body.get("mask") or []
images = _composite_mask(images, masks)
model = str(body.get("model") or "gpt-image-2")
n = int(body.get("n") or 1)
size = body.get("size")
quality = str(body.get("quality") or "auto")
response_format = str(body.get("response_format") or "b64_json")
base_url = str(body.get("base_url") or "") or None
progress_callback = body.get("progress_callback")
encoded_images = encode_images(images)
if not encoded_images:
raise ImageGenerationError("image is required")
outputs = stream_image_outputs_with_pool(ConversationRequest(
prompt=prompt,
model=model,
n=n,
size=size,
quality=quality,
response_format=response_format,
base_url=base_url,
images=encoded_images,
message_as_error=True,
progress_callback=progress_callback,
))
if body.get("stream"):
return stream_image_chunks(outputs)
result = collect_image_outputs(outputs)
result["usage"] = image_usage(
input_text_tokens=count_text_tokens(prompt, model),
input_image_tokens=count_image_inputs_tokens(images, model),
output_tokens=count_image_output_items_tokens(result.get("data"), size, quality),
)
return result
@@ -0,0 +1,42 @@
from __future__ import annotations
from typing import Any, Iterator
from services.protocol.conversation import (
ConversationRequest,
collect_image_outputs,
count_text_tokens,
stream_image_chunks,
stream_image_outputs_with_pool,
)
from utils.image_tokens import count_image_output_items_tokens, image_usage
def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
prompt = str(body.get("prompt") or "")
model = str(body.get("model") or "gpt-image-2")
n = int(body.get("n") or 1)
size = body.get("size")
quality = str(body.get("quality") or "auto")
response_format = str(body.get("response_format") or "b64_json")
base_url = str(body.get("base_url") or "") or None
progress_callback = body.get("progress_callback")
outputs = stream_image_outputs_with_pool(ConversationRequest(
prompt=prompt,
model=model,
n=n,
size=size,
quality=quality,
response_format=response_format,
base_url=base_url,
message_as_error=True,
progress_callback=progress_callback,
))
if body.get("stream"):
return stream_image_chunks(outputs)
result = collect_image_outputs(outputs)
result["usage"] = image_usage(
input_text_tokens=count_text_tokens(prompt, model),
output_tokens=count_image_output_items_tokens(result.get("data"), size, quality),
)
return result
+53
View File
@@ -0,0 +1,53 @@
from __future__ import annotations
from typing import Any
from services.account_service import account_service
from services.model_service import model_catalog_service
from utils.helper import CODEX_IMAGE_MODEL
def list_models() -> dict[str, Any]:
result = model_catalog_service.list_models()
data = result.get("data")
if not isinstance(data, list):
return result
seen = {str(item.get("id") or "").strip() for item in data if isinstance(item, dict)}
dynamic_models: set[str] = set()
accounts = account_service.list_accounts()
web_image_accounts = [
account
for account in accounts
if isinstance(account, dict)
]
codex_types = {
normalized
for account in accounts
if isinstance(account, dict)
and account_service._normalize_source_type(account.get("source_type")) == "codex"
and (normalized := account_service._normalize_account_type(account.get("type")))
}
if web_image_accounts:
dynamic_models.add("gpt-image-2")
if codex_types & {"Plus", "Team", "Pro"}:
dynamic_models.add(CODEX_IMAGE_MODEL)
if "Plus" in codex_types:
dynamic_models.add(f"plus-{CODEX_IMAGE_MODEL}")
if "Team" in codex_types:
dynamic_models.add(f"team-{CODEX_IMAGE_MODEL}")
if "Pro" in codex_types:
dynamic_models.add(f"pro-{CODEX_IMAGE_MODEL}")
for model in sorted(dynamic_models):
if model not in seen:
data.append({
"id": model,
"object": "model",
"created": 0,
"owned_by": "chatgpt2api",
"permission": [],
"root": model,
"parent": None,
})
return result
+453
View File
@@ -0,0 +1,453 @@
from __future__ import annotations
import time
import uuid
from typing import Any, Iterable, Iterator
from fastapi import HTTPException
from services.protocol.chat_completion_cache import cache_key, chat_completion_cache, normalize_text_messages
from services.protocol.conversation import (
ConversationRequest,
ImageOutput,
count_message_image_tokens,
count_message_text_tokens,
count_text_tokens,
encode_images,
normalize_messages,
stream_image_outputs_with_pool,
stream_text_deltas,
text_backend,
)
from services.protocol.web_search_tool import (
WEB_SEARCH_TOOL_TYPES,
has_unsupported_tools,
has_web_search_tool,
normalized_sources,
run_web_search,
search_query_from_messages,
text_with_url_citations,
)
from utils.helper import extract_image_from_message_content, extract_response_prompt, has_response_image_generation_tool
from utils.image_tokens import (
count_image_content_tokens,
count_image_output_items_tokens,
image_usage,
token_usage,
)
TOOL_UNAVAILABLE_SYSTEM_MESSAGE = (
"This compatibility backend cannot execute local tools, shell commands, non-search tools, "
"or file operations. Do not claim to have run tools or inspected external resources. "
"If a user asks you to use a tool, say that tool execution is unavailable through this backend."
)
RESPONSE_CONTENT_PART_TYPES = {"text", "input_text", "output_text", "image_url", "input_image", "image"}
def normalize_thinking_effort(value: object) -> str:
normalized = str(value or "").strip().lower()
if normalized in {"", "none"}:
return ""
if normalized in {"low", "medium", "high"}:
return normalized
if normalized in {"xhigh", "extended"}:
return "extended"
return ""
def thinking_effort_from_body(body: dict[str, Any]) -> str:
reasoning = body.get("reasoning")
if isinstance(reasoning, dict):
return normalize_thinking_effort(reasoning.get("effort"))
if "thinking_effort" in body:
return normalize_thinking_effort(body.get("thinking_effort"))
if "reasoning_effort" in body:
return normalize_thinking_effort(body.get("reasoning_effort"))
return ""
def is_text_response_request(body: dict[str, Any]) -> bool:
return not has_response_image_generation_tool(body)
def has_unsupported_response_tools(body: dict[str, Any]) -> bool:
return has_unsupported_tools(body, {"image_generation", *WEB_SEARCH_TOOL_TYPES})
def response_image_tool(body: dict[str, Any]) -> dict[str, object]:
for tool in body.get("tools") or []:
if isinstance(tool, dict) and tool.get("type") == "image_generation":
return tool
return {}
def extract_response_image(input_value: object) -> tuple[bytes, str] | None:
if isinstance(input_value, dict):
if str(input_value.get("type") or "").strip() == "input_image":
images = extract_image_from_message_content([input_value])
return images[0] if images else None
images = extract_image_from_message_content(input_value.get("content"))
return images[0] if images else None
if not isinstance(input_value, list):
return None
for item in reversed(input_value):
if isinstance(item, dict):
if str(item.get("type") or "").strip() == "input_image":
images = extract_image_from_message_content([item])
if images:
return images[0]
images = extract_image_from_message_content(item.get("content"))
if images:
return images[0]
return None
def _input_image_parts(input_value: object) -> list[dict[str, Any]]:
parts: list[dict[str, Any]] = []
if isinstance(input_value, dict):
content = input_value.get("content")
if isinstance(content, list):
parts.extend(item for item in content if isinstance(item, dict))
return parts
if not isinstance(input_value, list):
return parts
if all(isinstance(item, dict) and item.get("type") for item in input_value):
return [item for item in input_value if isinstance(item, dict)]
for item in input_value:
if isinstance(item, dict):
content = item.get("content")
if isinstance(content, list):
parts.extend(part for part in content if isinstance(part, dict))
return parts
def _is_response_content_part(value: object) -> bool:
if not isinstance(value, dict):
return False
part_type = str(value.get("type") or "").strip()
return part_type in RESPONSE_CONTENT_PART_TYPES or ("image_url" in value and part_type != "message")
def _message_content_from_response_item(item: dict[str, Any]) -> object:
content = item.get("content")
if isinstance(content, list):
return [dict(part) if isinstance(part, dict) else part for part in content]
if isinstance(content, str):
return content
return extract_response_prompt([item]) or content or ""
def _append_response_message(messages: list[dict[str, Any]], role: object, content: object) -> None:
if isinstance(content, str):
if content.strip():
messages.append({"role": str(role or "user"), "content": content.strip()})
return
if isinstance(content, list) and content:
messages.append({"role": str(role or "user"), "content": content})
def messages_from_input(input_value: object, instructions: object = None) -> list[dict[str, Any]]:
messages: list[dict[str, Any]] = []
system_text = str(instructions or "").strip()
if system_text:
messages.append({"role": "system", "content": system_text})
if isinstance(input_value, str):
if input_value.strip():
messages.append({"role": "user", "content": input_value.strip()})
return messages
if isinstance(input_value, dict):
if _is_response_content_part(input_value):
_append_response_message(messages, "user", [dict(input_value)])
return messages
_append_response_message(
messages,
input_value.get("role") or "user",
_message_content_from_response_item(input_value),
)
return messages
if isinstance(input_value, list):
if all(_is_response_content_part(item) for item in input_value):
_append_response_message(messages, "user", [dict(item) for item in input_value if isinstance(item, dict)])
return messages
pending_parts: list[dict[str, Any]] = []
for item in input_value:
if _is_response_content_part(item):
pending_parts.append(dict(item))
continue
if pending_parts:
_append_response_message(messages, "user", pending_parts)
pending_parts = []
if not isinstance(item, dict):
continue
_append_response_message(
messages,
item.get("role") or "user",
_message_content_from_response_item(item),
)
if pending_parts:
_append_response_message(messages, "user", pending_parts)
return messages
def text_output_item(
text: str,
item_id: str | None = None,
status: str = "completed",
annotations: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
return {
"id": item_id or f"msg_{uuid.uuid4().hex}",
"type": "message",
"status": status,
"role": "assistant",
"content": [{"type": "output_text", "text": text, "annotations": annotations or []}],
}
def web_search_call_item(
query: str,
item_id: str | None = None,
status: str = "completed",
sources: list[dict[str, str]] | None = None,
) -> dict[str, Any]:
action: dict[str, Any] = {
"type": "search",
"query": query,
"queries": [query],
}
if sources:
action["sources"] = [
{"type": "url", "url": source["url"]}
for source in sources
if source.get("url")
]
return {
"id": item_id or f"ws_{uuid.uuid4().hex}",
"type": "web_search_call",
"status": status,
"action": action,
}
def image_output_items(prompt: str, data: list[dict[str, Any]], item_id: str | None = None) -> list[dict[str, Any]]:
output = []
for item in data:
b64_json = str(item.get("b64_json") or "").strip()
if b64_json:
output.append({
"id": item_id or f"ig_{len(output) + 1}",
"type": "image_generation_call",
"status": "completed",
"result": b64_json,
"revised_prompt": str(item.get("revised_prompt") or prompt).strip() or prompt,
})
return output
def response_created(response_id: str, model: str, created: int) -> dict[str, Any]:
return {
"type": "response.created",
"response": {
"id": response_id,
"object": "response",
"created_at": created,
"status": "in_progress",
"error": None,
"incomplete_details": None,
"model": model,
"output": [],
"parallel_tool_calls": False,
},
}
def response_completed(
response_id: str,
model: str,
created: int,
output: list[dict[str, Any]],
usage: dict[str, Any] | None = None,
) -> dict[str, Any]:
response = {
"type": "response.completed",
"response": {
"id": response_id,
"object": "response",
"created_at": created,
"status": "completed",
"error": None,
"incomplete_details": None,
"model": model,
"output": output,
"parallel_tool_calls": False,
},
}
if usage:
response["response"]["usage"] = usage
return response
def text_response_parts(body: dict[str, Any]) -> tuple[str, list[dict[str, Any]]]:
model = str(body.get("model") or "auto").strip() or "auto"
messages = normalize_text_messages(normalize_messages(messages_from_input(body.get("input"), body.get("instructions"))))
if has_unsupported_response_tools(body):
messages.insert(0, {"role": "system", "content": TOOL_UNAVAILABLE_SYSTEM_MESSAGE})
return model, messages
def stream_text_response(backend, body: dict[str, Any], messages: list[dict[str, Any]] | None = None) -> Iterator[dict[str, Any]]:
model = str(body.get("model") or "auto").strip() or "auto"
messages = messages if messages is not None else messages_from_input(body.get("input"), body.get("instructions"))
thinking_effort = thinking_effort_from_body(body)
response_id = f"resp_{uuid.uuid4().hex}"
item_id = f"msg_{uuid.uuid4().hex}"
created = int(time.time())
full_text = ""
yield response_created(response_id, model, created)
yield {"type": "response.output_item.added", "output_index": 0, "item": text_output_item("", item_id, "in_progress")}
request = ConversationRequest(model=model, messages=messages, thinking_effort=thinking_effort)
for delta in stream_text_deltas(backend, request):
full_text += delta
yield {"type": "response.output_text.delta", "item_id": item_id, "output_index": 0, "content_index": 0, "delta": delta}
yield {"type": "response.output_text.done", "item_id": item_id, "output_index": 0, "content_index": 0, "text": full_text}
item = text_output_item(full_text, item_id, "completed")
yield {"type": "response.output_item.done", "output_index": 0, "item": item}
usage = token_usage(
input_text_tokens=count_message_text_tokens(messages, model),
input_image_tokens=count_message_image_tokens(messages, model),
output_text_tokens=count_text_tokens(full_text, model),
)
yield response_completed(response_id, model, created, [item], usage)
def stream_web_search_response(body: dict[str, Any], messages: list[dict[str, Any]] | None = None) -> Iterator[dict[str, Any]]:
model = str(body.get("model") or "auto").strip() or "auto"
messages = messages if messages is not None else messages_from_input(body.get("input"), body.get("instructions"))
query = search_query_from_messages(messages) or extract_response_prompt(body.get("input"))
if not query:
raise HTTPException(status_code=400, detail={"error": "input text is required for web_search"})
response_id = f"resp_{uuid.uuid4().hex}"
search_id = f"ws_{uuid.uuid4().hex}"
item_id = f"msg_{uuid.uuid4().hex}"
created = int(time.time())
yield response_created(response_id, model, created)
searching_item = web_search_call_item(query, search_id, "in_progress")
yield {"type": "response.output_item.added", "output_index": 0, "item": searching_item}
yield {"type": "response.web_search_call.in_progress", "output_index": 0, "item_id": search_id}
yield {"type": "response.web_search_call.searching", "output_index": 0, "item_id": search_id}
result = run_web_search(query)
search_item = web_search_call_item(query, search_id, "completed", normalized_sources(result))
yield {"type": "response.web_search_call.completed", "output_index": 0, "item_id": search_id}
yield {"type": "response.output_item.done", "output_index": 0, "item": search_item}
text, annotations = text_with_url_citations(result)
message_item = text_output_item("", item_id, "in_progress", annotations)
yield {"type": "response.output_item.added", "output_index": 1, "item": message_item}
if text:
yield {"type": "response.output_text.delta", "item_id": item_id, "output_index": 1, "content_index": 0, "delta": text}
yield {"type": "response.output_text.done", "item_id": item_id, "output_index": 1, "content_index": 0, "text": text}
message_item = text_output_item(text, item_id, "completed", annotations)
yield {"type": "response.output_item.done", "output_index": 1, "item": message_item}
usage = token_usage(
input_text_tokens=count_message_text_tokens(messages, model),
input_image_tokens=count_message_image_tokens(messages, model),
output_text_tokens=count_text_tokens(text, model),
)
yield response_completed(response_id, model, created, [search_item, message_item], usage)
def stream_image_response(
image_outputs: Iterable[ImageOutput],
prompt: str,
model: str,
input_image_tokens: int = 0,
size: object = None,
quality: str = "auto",
) -> Iterator[dict[str, Any]]:
response_id = f"resp_{uuid.uuid4().hex}"
created = int(time.time())
yield response_created(response_id, model, created)
for output in image_outputs:
if output.kind == "message":
text = output.text
item = text_output_item(text)
usage = token_usage(
input_text_tokens=count_text_tokens(prompt, model),
input_image_tokens=input_image_tokens,
output_text_tokens=count_text_tokens(text, model),
)
yield {"type": "response.output_text.delta", "item_id": item["id"], "output_index": 0, "content_index": 0, "delta": text}
yield {"type": "response.output_text.done", "item_id": item["id"], "output_index": 0, "content_index": 0, "text": text}
yield {"type": "response.output_item.done", "output_index": 0, "item": item}
yield response_completed(response_id, model, created, [item], usage)
return
if output.kind != "result":
continue
items = image_output_items(prompt, output.data)
if items:
usage = image_usage(
input_text_tokens=count_text_tokens(prompt, model),
input_image_tokens=input_image_tokens,
output_tokens=count_image_output_items_tokens(output.data, size, quality),
)
for output_index, item in enumerate(items):
yield {"type": "response.output_item.done", "output_index": output_index, "item": item}
yield response_completed(response_id, model, created, items, usage)
return
raise RuntimeError("image generation failed")
def collect_response(events: Iterable[dict[str, Any]]) -> dict[str, Any]:
completed = {}
for event in events:
if event.get("type") == "response.completed":
completed = event.get("response") if isinstance(event.get("response"), dict) else {}
if not completed:
raise RuntimeError("response generation failed")
return completed
def response_events(body: dict[str, Any]) -> Iterator[dict[str, Any]]:
if is_text_response_request(body):
model, messages = text_response_parts(body)
if has_web_search_tool(body) and not has_unsupported_response_tools(body):
yield from stream_web_search_response(body, messages)
return
key = cache_key(body, messages, stream=bool(body.get("stream")))
yield from chat_completion_cache.get_or_compute_stream(
key,
lambda: stream_text_response(text_backend(model), body, messages),
)
return
prompt = extract_response_prompt(body.get("input"))
if not prompt:
raise HTTPException(status_code=400, detail={"error": "input text is required"})
model = str(body.get("model") or "gpt-image-2").strip() or "gpt-image-2"
image_info = extract_response_image(body.get("input"))
if image_info:
image_data, mime_type = image_info
images = encode_images([(image_data, "image.png", mime_type)])
else:
images = None
input_image_tokens = count_image_content_tokens(_input_image_parts(body.get("input")), model)
tool = response_image_tool(body)
image_outputs = stream_image_outputs_with_pool(ConversationRequest(
prompt=prompt,
model=model,
size=tool.get("size"),
quality=str(tool.get("quality") or "auto"),
response_format="b64_json",
images=images,
))
yield from stream_image_response(image_outputs, prompt, model, input_image_tokens, tool.get("size"), str(tool.get("quality") or "auto"))
def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
events = response_events(body)
if body.get("stream"):
return events
return collect_response(events)
+160
View File
@@ -0,0 +1,160 @@
from __future__ import annotations
import re
from typing import Any
from services.account_service import account_service
from services.openai_backend_api import OpenAIBackendAPI
WEB_SEARCH_TOOL_TYPES = {"web_search", "web_search_preview", "web_search_preview_2025_03_11"}
SEARCH_CHAT_MODEL_PREFIXES = (
"gpt-4o-search-preview",
"gpt-4o-mini-search-preview",
"gpt-5-search-api",
)
def _tool_type(tool: object) -> str:
return str(tool.get("type") or "").strip() if isinstance(tool, dict) else ""
def has_web_search_tool(body: dict[str, Any]) -> bool:
tools = body.get("tools")
if isinstance(tools, list):
return any(_tool_type(tool) in WEB_SEARCH_TOOL_TYPES for tool in tools)
tool_choice = body.get("tool_choice")
return _tool_type(tool_choice) in WEB_SEARCH_TOOL_TYPES
def is_web_search_chat_request(body: dict[str, Any]) -> bool:
model = str(body.get("model") or "").strip()
return (
has_web_search_tool(body)
or isinstance(body.get("web_search_options"), dict)
or any(
model == prefix or model.startswith(f"{prefix}-")
for prefix in SEARCH_CHAT_MODEL_PREFIXES
)
)
def has_unsupported_tools(body: dict[str, Any], allowed_types: set[str]) -> bool:
tools = body.get("tools")
if not isinstance(tools, list):
return False
return any(_tool_type(tool) not in allowed_types for tool in tools if isinstance(tool, dict))
def message_text(value: object) -> str:
if isinstance(value, str):
return value.strip()
if isinstance(value, list):
parts: list[str] = []
for item in value:
if isinstance(item, str):
text = item.strip()
elif isinstance(item, dict):
text = str(item.get("text") or item.get("input_text") or "").strip()
else:
text = ""
if text:
parts.append(text)
return "\n".join(parts).strip()
return ""
def search_query_from_messages(messages: list[dict[str, Any]]) -> str:
for message in reversed(messages):
if str(message.get("role") or "").strip().lower() != "user":
continue
text = message_text(message.get("content"))
if text:
return text
return ""
def _readable_annotation_part(parts: list[str]) -> str:
for part in parts:
value = part.strip()
lower = value.lower()
if value and not (
lower.startswith(("turn", "source", "sources"))
or re.fullmatch(r"\d+", value)
):
return value
return ""
def clean_search_text(text: str) -> str:
def replace_annotation(match: re.Match[str]) -> str:
parts = [part.strip() for part in match.group(1).split("\ue202")]
kind = (parts[0] if parts else "").lower()
data = parts[1:]
if kind == "url":
label = data[0] if data else ""
url = data[1] if len(data) > 1 else ""
if label and url.startswith(("http://", "https://")):
return f"{label} ({url})"
return label or url
if kind == "cite":
return _readable_annotation_part(data)
return _readable_annotation_part(data)
text = re.sub(r"\ue200([^\ue201]*)\ue201", replace_annotation, text)
text = re.sub(r"\ue200[^\ue201]*$", "", text)
return re.sub(r"\s+([.,;:!?])", r"\1", text).strip()
def normalized_sources(result: dict[str, Any]) -> list[dict[str, str]]:
sources = result.get("sources")
if not isinstance(sources, list):
return []
output: list[dict[str, str]] = []
seen: set[str] = set()
for item in sources:
if not isinstance(item, dict):
continue
url = str(item.get("url") or "").strip()
title = str(item.get("title") or "").strip()
snippet = str(item.get("snippet") or "").strip()
if not url or url in seen:
continue
seen.add(url)
output.append({"title": title, "url": url, "snippet": snippet})
return output
def text_with_url_citations(result: dict[str, Any]) -> tuple[str, list[dict[str, Any]]]:
text = clean_search_text(str(result.get("answer") or ""))
annotations: list[dict[str, Any]] = []
sources = normalized_sources(result)
if sources:
text = text.rstrip()
if text:
text += "\n\n"
text += "Sources:\n"
for index, source in enumerate(sources, start=1):
title = source["title"] or source["url"]
line_prefix = f"{index}. {title}"
text += line_prefix
if source["url"]:
if source["title"]:
text += " - "
start = len(text)
text += source["url"]
annotations.append({
"type": "url_citation",
"start_index": start,
"end_index": len(text),
"url": source["url"],
"title": source["title"] or source["url"],
})
text += "\n"
return text.strip(), annotations
def run_web_search(query: str) -> dict[str, Any]:
token = account_service.get_text_access_token()
result = OpenAIBackendAPI(token).search(query)
account_service.mark_text_used(token)
return result
+648
View File
@@ -0,0 +1,648 @@
"""Global outbound proxy and Cloudflare clearance helpers."""
from __future__ import annotations
from dataclasses import dataclass, field, replace
import json
import re
import threading
import time
from typing import Callable, Mapping
from urllib import request as urllib_request
from urllib.parse import quote, urlparse
from curl_cffi.requests import Session
from services.config import config
FlareSolverrRequestMethod = Callable[[str, bytes, dict[str, str], float], bytes]
def normalize_proxy_url(url: str) -> str:
"""Normalize proxy URLs for curl_cffi.
SOCKS proxies should use remote-DNS resolution by default, so generic
``socks://`` and ``socks5://`` inputs are upgraded to ``socks5h://``.
HTTP/HTTPS/socks5h inputs are otherwise left untouched except trimming.
"""
candidate = str(url or "").strip()
if candidate and "://" not in candidate:
candidate = _colon_proxy_to_url(candidate)
lowered = candidate.lower()
if lowered.startswith("socks://"):
return "socks5h://" + candidate[len("socks://") :]
if lowered.startswith("socks5://"):
return "socks5h://" + candidate[len("socks5://") :]
return candidate
@dataclass(frozen=True)
class ProxyRuntimeProfile:
proxy_url: str = ""
proxy_source: str = "direct"
resource: bool = False
runtime_enabled: bool = False
egress_mode: str = "direct"
skip_ssl_verify: bool = False
reset_session_status_codes: tuple[int, ...] = field(default_factory=lambda: (403,))
clearance: dict[str, object] = field(default_factory=dict, repr=False)
@property
def clearance_enabled(self) -> bool:
return (
self.runtime_enabled
and bool(self.clearance.get("enabled"))
and self.clearance_mode in {"manual", "flaresolverr"}
)
@property
def clearance_mode(self) -> str:
return str(self.clearance.get("mode") or "none").strip().lower()
@property
def refresh_interval(self) -> int:
try:
return max(0, int(self.clearance.get("refresh_interval") or 0))
except (OverflowError, TypeError, ValueError):
return 0
@property
def timeout_sec(self) -> int:
try:
return max(1, int(self.clearance.get("timeout_sec") or 60))
except (OverflowError, TypeError, ValueError):
return 60
@dataclass(frozen=True)
class ClearanceBundle:
target_host: str
proxy_url: str = ""
cookies: dict[str, str] = field(default_factory=dict, repr=False)
user_agent: str = ""
created_at: float = field(default_factory=time.time)
expires_at: float | None = None
def is_valid_for(self, target_host: str, proxy_url: str, *, now: float | None = None) -> bool:
host = _normalize_host(target_host)
if self.target_host and host and _normalize_host(self.target_host) != host:
return False
if normalize_proxy_url(self.proxy_url) != normalize_proxy_url(proxy_url):
return False
if self.expires_at is not None and (time.time() if now is None else now) >= self.expires_at:
return False
return bool(self.cookies or self.user_agent)
def cookie_header(self) -> str:
return _cookies_to_header(self.cookies)
class FlareSolverrClearanceProvider:
def __init__(self, flaresolverr_url: str, request_method: FlareSolverrRequestMethod | None = None) -> None:
self.flaresolverr_url = str(flaresolverr_url or "").strip().rstrip("/")
self._request_method = request_method or self._urllib_post
def get_clearance(self, target_url: str, proxy_url: str = "", timeout_sec: int = 60) -> ClearanceBundle | None:
if not self.flaresolverr_url:
return None
timeout = _coerce_timeout(timeout_sec)
payload: dict[str, object] = {
"cmd": "request.get",
"url": str(target_url or ""),
"maxTimeout": int(timeout * 1000),
}
proxy_url = normalize_proxy_url(proxy_url)
if proxy_url:
payload["proxy"] = {"url": proxy_url}
endpoint = f"{self.flaresolverr_url}/v1"
try:
body = json.dumps(payload).encode("utf-8")
raw_response = self._request_method(
endpoint,
body,
{"Content-Type": "application/json"},
timeout,
)
data = json.loads(raw_response.decode("utf-8") if isinstance(raw_response, bytes) else raw_response)
except Exception:
return None
if not isinstance(data, dict) or str(data.get("status") or "").lower() != "ok":
return None
solution = data.get("solution")
if not isinstance(solution, dict):
return None
target_host = _host_from_url(target_url)
cookies = _filter_flaresolverr_cookies(solution.get("cookies"), target_host)
user_agent = str(solution.get("userAgent") or "").strip()
if not cookies and not user_agent:
return None
return ClearanceBundle(
target_host=target_host,
proxy_url=proxy_url,
cookies=cookies,
user_agent=user_agent,
)
@staticmethod
def _urllib_post(endpoint: str, body: bytes, headers: dict[str, str], timeout: float) -> bytes:
req = urllib_request.Request(endpoint, data=body, headers=headers, method="POST")
with urllib_request.urlopen(req, timeout=timeout) as response:
return response.read()
class ProxySettingsStore:
def __init__(
self,
config_store=None,
clearance_provider_factory: Callable[[str], FlareSolverrClearanceProvider] | None = None,
) -> None:
self._config = config_store or config
self._clearance_provider_factory = clearance_provider_factory or FlareSolverrClearanceProvider
self._clearance_cache: dict[tuple[str, str], ClearanceBundle] = {}
self._provider_cache: dict[str, FlareSolverrClearanceProvider] = {}
self._flight_locks: dict[tuple[str, str], threading.Lock] = {}
self._lock = threading.RLock()
def get_profile(
self,
account: dict | None = None,
proxy: str = "",
resource: bool = False,
upstream: bool = False,
) -> ProxyRuntimeProfile:
runtime = self._get_runtime_settings()
clearance = dict(runtime.get("clearance") if isinstance(runtime.get("clearance"), dict) else {})
runtime_enabled = bool(runtime.get("enabled"))
egress_mode = str(runtime.get("egress_mode") or "direct").strip().lower()
account_proxy = _clean((account or {}).get("proxy") if isinstance(account, dict) else "")
explicit_proxy = _clean(proxy)
legacy_proxy = _clean(self._config.get_proxy_settings())
runtime_proxy = ""
runtime_proxy_source = "runtime"
if upstream and runtime_enabled and egress_mode == "single_proxy":
resource_proxy = _clean(runtime.get("resource_proxy_url")) if resource else ""
runtime_proxy = resource_proxy or _clean(runtime.get("proxy_url"))
runtime_proxy_source = "runtime_resource" if resource_proxy else "runtime"
selected_proxy = ""
source = "direct"
if account_proxy:
selected_proxy = account_proxy
source = "account"
elif runtime_proxy:
selected_proxy = runtime_proxy
source = runtime_proxy_source
elif explicit_proxy:
selected_proxy = explicit_proxy
source = "explicit"
elif legacy_proxy:
selected_proxy = legacy_proxy
source = "global"
return ProxyRuntimeProfile(
proxy_url=normalize_proxy_url(selected_proxy),
proxy_source=source,
resource=bool(resource),
runtime_enabled=runtime_enabled,
egress_mode=egress_mode,
skip_ssl_verify=bool(runtime.get("skip_ssl_verify")) if runtime_enabled else False,
reset_session_status_codes=_status_codes_tuple(runtime.get("reset_session_status_codes")),
clearance=clearance,
)
def build_session_kwargs(
self,
account: dict | None = None,
proxy: str = "",
resource: bool = False,
upstream: bool = False,
**session_kwargs,
) -> dict[str, object]:
profile = self.get_profile(account=account, proxy=proxy, resource=resource, upstream=upstream)
if profile.proxy_url:
session_kwargs["proxy"] = profile.proxy_url
if profile.runtime_enabled and profile.skip_ssl_verify:
session_kwargs["verify"] = False
return session_kwargs
def build_headers(
self,
headers: Mapping[str, object] | None = None,
target_url: str = "https://chatgpt.com",
account: dict | None = None,
proxy: str = "",
resource: bool = False,
upstream: bool = True,
) -> dict[str, object]:
merged_headers: dict[str, object] = dict(headers or {})
profile = self.get_profile(account=account, proxy=proxy, resource=resource, upstream=upstream)
if not profile.clearance_enabled:
return merged_headers
target_host = _host_from_url(target_url)
bundle = self._bundle_for_headers(profile, target_host)
if bundle is None or not bundle.is_valid_for(target_host, profile.proxy_url):
return merged_headers
if bundle.user_agent and _find_header_key(merged_headers, "user-agent") is None:
merged_headers["User-Agent"] = bundle.user_agent
if bundle.cookies:
cookie_key = _find_header_key(merged_headers, "cookie") or "Cookie"
existing_cookie = str(merged_headers.get(cookie_key) or "")
cookie_header = _merge_cookie_header(existing_cookie, bundle.cookies)
if cookie_header:
merged_headers[cookie_key] = cookie_header
return merged_headers
def refresh_clearance(
self,
target_url: str = "https://chatgpt.com",
account: dict | None = None,
proxy: str = "",
resource: bool = False,
force: bool = False,
upstream: bool = True,
) -> ClearanceBundle | None:
profile = self.get_profile(account=account, proxy=proxy, resource=resource, upstream=upstream)
if not profile.clearance_enabled:
return None
target_host = _host_from_url(target_url)
key = self._cache_key(profile.proxy_url, target_host)
if profile.clearance_mode == "manual":
bundle = self._build_manual_bundle(profile, target_host)
if bundle is not None:
self._set_cached_bundle(key, bundle)
return bundle
if profile.clearance_mode != "flaresolverr":
return None
cached_before = self._get_cached_bundle(key)
if cached_before is not None and not force and cached_before.is_valid_for(target_host, profile.proxy_url):
return cached_before
lock = self._get_flight_lock(key)
if not lock.acquire(blocking=False):
with lock:
pass
return self._get_cached_bundle(key) or cached_before
try:
cached_now = self._get_cached_bundle(key)
if cached_now is not None and not force and cached_now.is_valid_for(target_host, profile.proxy_url):
return cached_now
flaresolverr_url = str(profile.clearance.get("flaresolverr_url") or "").strip()
provider = self._get_provider(flaresolverr_url)
new_bundle = provider.get_clearance(target_url, proxy_url=profile.proxy_url, timeout_sec=profile.timeout_sec)
if new_bundle is not None:
expires_at = time.time() + profile.refresh_interval if profile.refresh_interval else None
if (
not new_bundle.target_host
or normalize_proxy_url(new_bundle.proxy_url) != normalize_proxy_url(profile.proxy_url)
or new_bundle.expires_at != expires_at
):
new_bundle = replace(
new_bundle,
target_host=new_bundle.target_host or target_host,
proxy_url=profile.proxy_url,
expires_at=expires_at,
)
self._set_cached_bundle(key, new_bundle)
return new_bundle
return cached_now or cached_before
finally:
lock.release()
def invalidate_clearance(
self,
target_url: str = "https://chatgpt.com",
account: dict | None = None,
proxy: str = "",
resource: bool = False,
upstream: bool = True,
) -> None:
profile = self.get_profile(account=account, proxy=proxy, resource=resource, upstream=upstream)
target_host = _host_from_url(target_url)
key = self._cache_key(profile.proxy_url, target_host)
with self._lock:
self._clearance_cache.pop(key, None)
def get_runtime_status(self) -> dict[str, object]:
profile = self.get_profile(upstream=True)
with self._lock:
cached_hosts = [host for _proxy, host in self._clearance_cache]
cached_count = len(self._clearance_cache)
return {
"enabled": profile.runtime_enabled,
"egress_mode": profile.egress_mode,
"proxy_source": profile.proxy_source,
"has_proxy": bool(profile.proxy_url),
"clearance_enabled": profile.clearance_enabled,
"clearance_mode": profile.clearance_mode,
"has_clearance_bundle": cached_count > 0,
"cached_clearance_hosts": sorted(set(cached_hosts)),
}
def _get_runtime_settings(self) -> dict[str, object]:
try:
runtime = self._config.get_proxy_runtime_settings()
except AttributeError:
runtime = {}
return runtime if isinstance(runtime, dict) else {}
def _bundle_for_headers(self, profile: ProxyRuntimeProfile, target_host: str) -> ClearanceBundle | None:
key = self._cache_key(profile.proxy_url, target_host)
if profile.clearance_mode == "manual":
bundle = self._build_manual_bundle(profile, target_host)
if bundle is not None:
self._set_cached_bundle(key, bundle)
return bundle
if profile.clearance_mode == "flaresolverr":
return self._get_cached_bundle(key)
return None
def _build_manual_bundle(self, profile: ProxyRuntimeProfile, target_host: str) -> ClearanceBundle | None:
cookies = _parse_cookie_header(str(profile.clearance.get("cf_cookies") or ""))
cf_clearance = str(profile.clearance.get("cf_clearance") or "").strip()
if cf_clearance and "cf_clearance" not in cookies:
cookies["cf_clearance"] = cf_clearance
user_agent = str(profile.clearance.get("user_agent") or "").strip()
if not cookies and not user_agent:
return None
now = time.time()
expires_at = now + profile.refresh_interval if profile.refresh_interval else None
return ClearanceBundle(
target_host=target_host,
proxy_url=profile.proxy_url,
cookies=cookies,
user_agent=user_agent,
created_at=now,
expires_at=expires_at,
)
def _get_provider(self, flaresolverr_url: str) -> FlareSolverrClearanceProvider:
url = str(flaresolverr_url or "").strip().rstrip("/")
with self._lock:
provider = self._provider_cache.get(url)
if provider is None:
provider = self._clearance_provider_factory(url)
self._provider_cache[url] = provider
return provider
def _get_flight_lock(self, key: tuple[str, str]) -> threading.Lock:
with self._lock:
lock = self._flight_locks.get(key)
if lock is None:
lock = threading.Lock()
self._flight_locks[key] = lock
return lock
def _get_cached_bundle(self, key: tuple[str, str]) -> ClearanceBundle | None:
with self._lock:
return self._clearance_cache.get(key)
def _set_cached_bundle(self, key: tuple[str, str], bundle: ClearanceBundle) -> None:
with self._lock:
self._clearance_cache[key] = bundle
@staticmethod
def _cache_key(proxy_url: str, target_host: str) -> tuple[str, str]:
return (normalize_proxy_url(proxy_url), _normalize_host(target_host))
def _clean(value: object) -> str:
return str(value or "").strip()
def _colon_proxy_to_url(url: str) -> str:
parts = url.split(":", 3)
if len(parts) == 4 and parts[1].isdigit():
host, port, username, password = parts
return f"http://{quote(username, safe='')}:{quote(password, safe='')}@{host}:{port}"
if len(parts) == 2 and parts[1].isdigit():
return f"http://{url}"
return url
def _normalize_host(host: str) -> str:
return str(host or "").strip().strip(".").lower()
def _host_from_url(url: str) -> str:
candidate = str(url or "").strip()
parsed = urlparse(candidate)
if not parsed.hostname and candidate and "://" not in candidate:
parsed = urlparse(f"https://{candidate}")
return _normalize_host(parsed.hostname or "")
def _status_codes_tuple(value: object) -> tuple[int, ...]:
source = value if isinstance(value, list) else [403]
codes: list[int] = []
for item in source:
if isinstance(item, bool):
continue
try:
code = int(item)
except (OverflowError, TypeError, ValueError):
continue
if 100 <= code <= 599 and code not in codes:
codes.append(code)
return tuple(codes or [403])
def _coerce_timeout(value: object) -> float:
try:
timeout = float(value)
except (OverflowError, TypeError, ValueError):
timeout = 60.0
return max(1.0, timeout)
def _is_valid_proxy_url(url: str) -> bool:
parsed = urlparse(normalize_proxy_url(url))
return parsed.scheme in {"http", "https", "socks5", "socks5h"} and bool(parsed.netloc)
def _domain_matches(host: str, domain: str) -> bool:
normalized_host = _normalize_host(host)
normalized_domain = _normalize_host(domain.lstrip("."))
if not normalized_domain:
return True
return normalized_host == normalized_domain or normalized_host.endswith(f".{normalized_domain}")
def _filter_flaresolverr_cookies(raw_cookies: object, target_host: str) -> dict[str, str]:
if not isinstance(raw_cookies, list):
return {}
filtered_cookies: dict[str, str] = {}
for item in raw_cookies:
if not isinstance(item, dict):
continue
name = str(item.get("name") or "").strip()
if not name:
continue
value = str(item.get("value") or "")
domain = str(item.get("domain") or "").strip()
if not domain or _domain_matches(target_host, domain):
filtered_cookies[name] = value
return filtered_cookies
def _parse_cookie_header(header: str) -> dict[str, str]:
cookies: dict[str, str] = {}
for part in str(header or "").split(";"):
name, sep, value = part.strip().partition("=")
if sep and name:
cookies[name.strip()] = value.strip()
return cookies
def _cookies_to_header(cookies: Mapping[str, str]) -> str:
return "; ".join(f"{name}={value}" for name, value in cookies.items() if name)
def _merge_cookie_header(existing_header: str, cookies: Mapping[str, str]) -> str:
existing = str(existing_header or "").strip()
existing_names = set(_parse_cookie_header(existing).keys())
additions = [f"{name}={value}" for name, value in cookies.items() if name and name not in existing_names]
if existing and additions:
return existing.rstrip("; ") + "; " + "; ".join(additions)
if existing:
return existing
return "; ".join(additions)
def _find_header_key(headers: Mapping[str, object], name: str) -> str | None:
target = name.lower()
for key in headers:
if str(key).lower() == target:
return str(key)
return None
def _redact_url_credentials(text: str) -> str:
return re.sub(
r"((?:https?|socks5h?|socks)://)([^\s/@:]+):([^\s/@]+)@",
r"\1[REDACTED]@",
str(text or ""),
flags=re.IGNORECASE,
)
def test_proxy(url: str = "", *, timeout: float = 15.0) -> dict:
candidate = normalize_proxy_url(_clean(url))
proxy_source = "input"
if not candidate:
profile = proxy_settings.get_profile(upstream=True)
candidate = profile.proxy_url
proxy_source = profile.proxy_source
result_base = {"proxy_source": proxy_source, "has_proxy": bool(candidate)}
if not candidate:
return {
"ok": False,
"status": 0,
"latency_ms": 0,
"error": "no active proxy configured",
**result_base,
}
if not _is_valid_proxy_url(candidate):
return {
"ok": False,
"status": 0,
"latency_ms": 0,
"error": "invalid proxy url",
**result_base,
}
session = Session(impersonate="edge101", verify=True, proxy=candidate)
started = time.perf_counter()
try:
response = session.get(
"https://chatgpt.com/api/auth/csrf",
headers={"user-agent": "Mozilla/5.0 (chatgpt2api proxy test)"},
timeout=timeout,
)
latency_ms = int((time.perf_counter() - started) * 1000)
return {
"ok": response.status_code < 500,
"status": int(response.status_code),
"latency_ms": latency_ms,
"error": None if response.status_code < 500 else f"HTTP {response.status_code}",
**result_base,
}
except Exception as exc:
latency_ms = int((time.perf_counter() - started) * 1000)
return {
"ok": False,
"status": 0,
"latency_ms": latency_ms,
"error": _redact_url_credentials(str(exc) or exc.__class__.__name__),
**result_base,
}
finally:
session.close()
def test_clearance(target_url: str = "https://chatgpt.com") -> dict:
target_url = str(target_url or "https://chatgpt.com").strip() or "https://chatgpt.com"
started = time.perf_counter()
status = proxy_settings.get_runtime_status()
if not status.get("clearance_enabled"):
return {
"ok": False,
"status": "disabled",
"latency_ms": 0,
"has_cookies": False,
"user_agent": "",
"error": "clearance is disabled",
"runtime": status,
}
try:
bundle = proxy_settings.refresh_clearance(target_url=target_url, force=True, upstream=True)
except Exception as exc:
latency_ms = int((time.perf_counter() - started) * 1000)
return {
"ok": False,
"status": "error",
"latency_ms": latency_ms,
"has_cookies": False,
"user_agent": "",
"error": _redact_url_credentials(str(exc) or exc.__class__.__name__),
"runtime": proxy_settings.get_runtime_status(),
}
latency_ms = int((time.perf_counter() - started) * 1000)
runtime = proxy_settings.get_runtime_status()
if bundle is None:
return {
"ok": False,
"status": "failed",
"latency_ms": latency_ms,
"has_cookies": False,
"user_agent": "",
"error": "clearance refresh returned no bundle",
"runtime": runtime,
}
return {
"ok": True,
"status": "ok",
"latency_ms": latency_ms,
"has_cookies": bool(bundle.cookies),
"user_agent": bundle.user_agent or "",
"error": None,
"runtime": runtime,
}
proxy_settings = ProxySettingsStore()
+5
View File
@@ -0,0 +1,5 @@
from __future__ import annotations
from services.storage.factory import create_storage_backend
__all__ = ["create_storage_backend"]
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
class StorageBackend(ABC):
"""抽象存储后端基类"""
@abstractmethod
def load_accounts(self) -> list[dict[str, Any]]:
"""加载所有账号数据"""
pass
@abstractmethod
def save_accounts(self, accounts: list[dict[str, Any]]) -> None:
"""保存所有账号数据"""
pass
@abstractmethod
def load_auth_keys(self) -> list[dict[str, Any]]:
"""加载所有鉴权密钥数据"""
pass
@abstractmethod
def save_auth_keys(self, auth_keys: list[dict[str, Any]]) -> None:
"""保存所有鉴权密钥数据"""
pass
@abstractmethod
def health_check(self) -> dict[str, Any]:
"""健康检查,返回存储后端状态"""
pass
@abstractmethod
def get_backend_info(self) -> dict[str, Any]:
"""获取存储后端信息"""
pass
+194
View File
@@ -0,0 +1,194 @@
from __future__ import annotations
import json
from typing import Any
from sqlalchemy import Column, String, Text, create_engine, Integer, text
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from services.storage.base import StorageBackend
Base = declarative_base()
class AccountModel(Base):
"""账号数据模型"""
__tablename__ = "accounts"
id = Column(Integer, primary_key=True, autoincrement=True)
access_token = Column(String(2048), unique=True, nullable=False, index=True)
data = Column(Text, nullable=False) # JSON 格式存储完整账号数据
class AuthKeyModel(Base):
"""鉴权密钥数据模型"""
__tablename__ = "auth_keys"
id = Column(Integer, primary_key=True, autoincrement=True)
key_id = Column(String(255), unique=True, nullable=False, index=True)
data = Column(Text, nullable=False)
class DatabaseStorageBackend(StorageBackend):
"""数据库存储后端(支持 SQLite、PostgreSQL、MySQL 等)"""
def __init__(self, database_url: str):
self.database_url = database_url
self.engine = create_engine(
database_url,
pool_pre_ping=True, # 自动检测连接是否有效
pool_recycle=3600, # 1小时回收连接
)
Base.metadata.create_all(self.engine)
self.Session = sessionmaker(bind=self.engine)
def load_accounts(self) -> list[dict[str, Any]]:
"""从数据库加载账号数据"""
session = self.Session()
try:
accounts = []
for row in session.query(AccountModel).all():
try:
account_data = json.loads(row.data)
if isinstance(account_data, dict):
accounts.append(account_data)
except json.JSONDecodeError:
continue
return accounts
finally:
session.close()
def save_accounts(self, accounts: list[dict[str, Any]]) -> None:
"""保存账号数据到数据库"""
self._save_rows(AccountModel, accounts, "access_token")
def load_auth_keys(self) -> list[dict[str, Any]]:
"""从数据库加载鉴权密钥数据"""
return self._load_rows(AuthKeyModel)
def save_auth_keys(self, auth_keys: list[dict[str, Any]]) -> None:
"""保存鉴权密钥数据到数据库"""
self._save_rows(AuthKeyModel, auth_keys, "id", "key_id")
def _load_rows(self, model: type[AccountModel] | type[AuthKeyModel]) -> list[dict[str, Any]]:
session = self.Session()
try:
items = []
for row in session.query(model).all():
try:
item_data = json.loads(row.data)
if isinstance(item_data, dict):
items.append(item_data)
except json.JSONDecodeError:
continue
return items
finally:
session.close()
def _save_rows(
self,
model: type[AccountModel] | type[AuthKeyModel],
items: list[dict[str, Any]],
source_key: str,
target_key: str | None = None,
) -> None:
session = self.Session()
try:
key_column = target_key or source_key
existing_rows = {
str(getattr(row, key_column)): row
for row in session.query(model).all()
}
incoming_keys: set[str] = set()
for item in items:
if not isinstance(item, dict):
continue
key_value = str(item.get(source_key) or "").strip()
if not key_value:
continue
if key_value in incoming_keys:
raise ValueError(f"Duplicate {source_key} in storage snapshot")
incoming_keys.add(key_value)
serialized_data = json.dumps(item, ensure_ascii=False)
existing_row = existing_rows.get(key_value)
if existing_row is None:
session.add(
model(
**{key_column: key_value},
data=serialized_data,
)
)
elif existing_row.data != serialized_data:
existing_row.data = serialized_data
for key_value, row in existing_rows.items():
if key_value not in incoming_keys:
session.delete(row)
session.commit()
except Exception:
session.rollback()
raise
finally:
session.close()
def health_check(self) -> dict[str, Any]:
"""健康检查"""
try:
session = self.Session()
try:
# 尝试执行简单查询
session.execute(text("SELECT 1"))
count = session.query(AccountModel).count()
auth_key_count = session.query(AuthKeyModel).count()
return {
"status": "healthy",
"backend": "database",
"database_url": self._mask_password(self.database_url),
"account_count": count,
"auth_key_count": auth_key_count,
}
finally:
session.close()
except Exception as e:
return {
"status": "unhealthy",
"backend": "database",
"error": str(e),
}
def get_backend_info(self) -> dict[str, Any]:
"""获取存储后端信息"""
db_type = "unknown"
if "sqlite" in self.database_url:
db_type = "sqlite"
elif "postgresql" in self.database_url or "postgres" in self.database_url:
db_type = "postgresql"
elif "mysql" in self.database_url:
db_type = "mysql"
return {
"type": "database",
"db_type": db_type,
"description": f"数据库存储 ({db_type})",
"database_url": self._mask_password(self.database_url),
}
@staticmethod
def _mask_password(url: str) -> str:
"""隐藏数据库连接字符串中的密码"""
if "://" not in url:
return url
try:
protocol, rest = url.split("://", 1)
if "@" in rest:
credentials, host = rest.split("@", 1)
if ":" in credentials:
username, _ = credentials.split(":", 1)
return f"{protocol}://{username}:****@{host}"
return url
except Exception:
return url
+104
View File
@@ -0,0 +1,104 @@
from __future__ import annotations
import os
from pathlib import Path
from services.storage.base import StorageBackend
from services.storage.database_storage import DatabaseStorageBackend
from services.storage.git_storage import GitStorageBackend
from services.storage.json_storage import JSONStorageBackend
def create_storage_backend(data_dir: Path) -> StorageBackend:
"""
根据环境变量创建存储后端
环境变量:
- STORAGE_BACKEND: json|sqlite|postgres|git (默认 json)
- DATABASE_URL: 数据库连接字符串 (用于 sqlite/postgres)
- GIT_REPO_URL: Git 仓库地址 (用于 git)
- GIT_TOKEN: Git 访问令牌 (用于 git)
- GIT_BRANCH: Git 分支 (默认 main)
- GIT_FILE_PATH: Git 仓库中的文件路径 (默认 accounts.json)
"""
backend_type = os.getenv("STORAGE_BACKEND", "json").lower().strip()
print(f"[storage] Initializing storage backend: {backend_type}")
if backend_type == "json":
# 本地 JSON 文件存储
file_path = data_dir / "accounts.json"
auth_keys_path = data_dir / "auth_keys.json"
print(f"[storage] Using JSON storage: {file_path}")
return JSONStorageBackend(file_path, auth_keys_path)
elif backend_type in ("sqlite", "postgres", "postgresql", "mysql", "database"):
# 数据库存储
database_url = os.getenv("DATABASE_URL", "").strip()
if not database_url:
# 如果没有指定 DATABASE_URL,使用本地 SQLite
database_url = f"sqlite:///{data_dir / 'accounts.db'}"
print(f"[storage] No DATABASE_URL provided, using local SQLite: {database_url}")
else:
print(f"[storage] Using database storage: {_mask_password(database_url)}")
return DatabaseStorageBackend(database_url)
elif backend_type == "git":
# Git 仓库存储
repo_url = os.getenv("GIT_REPO_URL", "").strip()
token = os.getenv("GIT_TOKEN", "").strip()
branch = os.getenv("GIT_BRANCH", "main").strip()
file_path = os.getenv("GIT_FILE_PATH", "accounts.json").strip()
auth_keys_file_path = os.getenv("GIT_AUTH_KEYS_FILE_PATH", "auth_keys.json").strip()
if not repo_url:
raise ValueError(
"GIT_REPO_URL is required when using git storage backend. "
"Please set GIT_REPO_URL environment variable."
)
print(f"[storage] Using Git storage: {_mask_token(repo_url)}, branch: {branch}, file: {file_path}")
cache_dir = data_dir / "git_cache"
return GitStorageBackend(
repo_url=repo_url,
token=token,
branch=branch,
file_path=file_path,
auth_keys_file_path=auth_keys_file_path,
local_cache_dir=cache_dir,
)
else:
raise ValueError(
f"Unknown storage backend: {backend_type}. "
f"Supported backends: json, sqlite, postgres, git"
)
def _mask_password(url: str) -> str:
"""隐藏数据库连接字符串中的密码"""
if "://" not in url:
return url
try:
protocol, rest = url.split("://", 1)
if "@" in rest:
credentials, host = rest.split("@", 1)
if ":" in credentials:
username, _ = credentials.split(":", 1)
return f"{protocol}://{username}:****@{host}"
return url
except Exception:
return url
def _mask_token(url: str) -> str:
"""隐藏 URL 中的 token"""
if "@" in url and "://" in url:
protocol, rest = url.split("://", 1)
if "@" in rest:
_, host = rest.split("@", 1)
return f"{protocol}://****@{host}"
return url
+183
View File
@@ -0,0 +1,183 @@
from __future__ import annotations
import json
import shutil
import tempfile
from pathlib import Path
from typing import Any
from git import Repo
from git.exc import GitCommandError
from services.storage.base import StorageBackend
class GitStorageBackend(StorageBackend):
"""Git 私有仓库存储后端"""
def __init__(
self,
repo_url: str,
token: str,
branch: str = "main",
file_path: str = "accounts.json",
auth_keys_file_path: str = "auth_keys.json",
local_cache_dir: Path | None = None,
):
self.repo_url = repo_url
self.token = token
self.branch = branch
self.file_path = file_path
self.auth_keys_file_path = auth_keys_file_path
# 本地缓存目录
if local_cache_dir is None:
local_cache_dir = Path(tempfile.gettempdir()) / "chatgpt2api_git_cache"
self.local_cache_dir = local_cache_dir
self.local_cache_dir.mkdir(parents=True, exist_ok=True)
# 构建带认证的 Git URL
self.auth_repo_url = self._build_auth_url(repo_url, token)
@staticmethod
def _build_auth_url(repo_url: str, token: str) -> str:
"""构建带认证的 Git URL"""
if not token:
return repo_url
# 支持 HTTPS 格式:https://github.com/user/repo.git
if repo_url.startswith("https://"):
# 插入 token
return repo_url.replace("https://", f"https://{token}@")
# 支持 git@ 格式:git@github.com:user/repo.git
# 转换为 HTTPS 格式
if repo_url.startswith("git@"):
repo_url = repo_url.replace("git@", "https://")
repo_url = repo_url.replace(".com:", ".com/")
return repo_url.replace("https://", f"https://{token}@")
return repo_url
def _clone_or_pull(self) -> Repo:
"""克隆或拉取仓库"""
repo_path = self.local_cache_dir / "repo"
if repo_path.exists() and (repo_path / ".git").exists():
# 仓库已存在,拉取最新代码
try:
repo = Repo(repo_path)
origin = repo.remote("origin")
origin.pull(self.branch)
return repo
except GitCommandError:
# 拉取失败,删除重新克隆
shutil.rmtree(repo_path)
# 克隆仓库
repo = Repo.clone_from(
self.auth_repo_url,
repo_path,
branch=self.branch,
)
return repo
def load_accounts(self) -> list[dict[str, Any]]:
"""从 Git 仓库加载账号数据"""
try:
return self._load_json_file(self.file_path)
except Exception as e:
print(f"[git-storage] load failed: {e}")
raise
def save_accounts(self, accounts: list[dict[str, Any]]) -> None:
"""保存账号数据到 Git 仓库"""
try:
self._save_json_file(self.file_path, accounts, "Update accounts data")
except Exception as e:
print(f"[git-storage] save failed: {e}")
raise e
def load_auth_keys(self) -> list[dict[str, Any]]:
"""从 Git 仓库加载鉴权密钥数据"""
try:
data = self._load_json_value(self.auth_keys_file_path)
if isinstance(data, dict):
data = data.get("items")
return data if isinstance(data, list) else []
except Exception as e:
print(f"[git-storage] load failed: {e}")
raise
def save_auth_keys(self, auth_keys: list[dict[str, Any]]) -> None:
"""保存鉴权密钥数据到 Git 仓库"""
try:
self._save_json_file(self.auth_keys_file_path, {"items": auth_keys}, "Update auth keys data")
except Exception as e:
print(f"[git-storage] save failed: {e}")
raise e
def _load_json_file(self, file_path: str) -> list[dict[str, Any]]:
data = self._load_json_value(file_path)
return data if isinstance(data, list) else []
def _load_json_value(self, file_path: str) -> Any:
repo = self._clone_or_pull()
file_full_path = Path(repo.working_dir) / file_path
if not file_full_path.exists():
return None
return json.loads(file_full_path.read_text(encoding="utf-8"))
def _save_json_file(self, file_path: str, items: Any, message: str) -> None:
repo = self._clone_or_pull()
file_full_path = Path(repo.working_dir) / file_path
file_full_path.parent.mkdir(parents=True, exist_ok=True)
file_full_path.write_text(
json.dumps(items, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
repo.index.add([file_path])
if repo.is_dirty():
repo.index.commit(message)
repo.remote("origin").push(self.branch)
def health_check(self) -> dict[str, Any]:
"""健康检查"""
try:
repo = self._clone_or_pull()
return {
"status": "healthy",
"backend": "git",
"repo_url": self._mask_token(self.repo_url),
"branch": self.branch,
"file_path": self.file_path,
"auth_keys_file_path": self.auth_keys_file_path,
"last_commit": repo.head.commit.hexsha[:8],
}
except Exception as e:
return {
"status": "unhealthy",
"backend": "git",
"error": str(e),
}
def get_backend_info(self) -> dict[str, Any]:
"""获取存储后端信息"""
return {
"type": "git",
"description": "Git 私有仓库存储",
"repo_url": self._mask_token(self.repo_url),
"branch": self.branch,
"file_path": self.file_path,
"auth_keys_file_path": self.auth_keys_file_path,
}
@staticmethod
def _mask_token(url: str) -> str:
"""隐藏 URL 中的 token"""
if "@" in url and "://" in url:
protocol, rest = url.split("://", 1)
if "@" in rest:
_, host = rest.split("@", 1)
return f"{protocol}://****@{host}"
return url
+95
View File
@@ -0,0 +1,95 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from services.storage.base import StorageBackend
class JSONStorageBackend(StorageBackend):
"""本地 JSON 文件存储后端"""
def __init__(self, file_path: Path, auth_keys_path: Path | None = None):
self.file_path = file_path
self.auth_keys_path = auth_keys_path or file_path.with_name("auth_keys.json")
self.file_path.parent.mkdir(parents=True, exist_ok=True)
self.auth_keys_path.parent.mkdir(parents=True, exist_ok=True)
@staticmethod
def _load_json_list(file_path: Path) -> list[dict[str, Any]]:
if not file_path.exists():
return []
try:
data = json.loads(file_path.read_text(encoding="utf-8"))
return data if isinstance(data, list) else []
except (json.JSONDecodeError, Exception):
return []
@staticmethod
def _save_json_list(file_path: Path, items: list[dict[str, Any]]) -> None:
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(
json.dumps(items, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
def load_accounts(self) -> list[dict[str, Any]]:
"""从 JSON 文件加载账号数据"""
return self._load_json_list(self.file_path)
def save_accounts(self, accounts: list[dict[str, Any]]) -> None:
"""保存账号数据到 JSON 文件"""
self._save_json_list(self.file_path, accounts)
def load_auth_keys(self) -> list[dict[str, Any]]:
"""从 JSON 文件加载鉴权密钥数据"""
if not self.auth_keys_path.exists():
return []
try:
data = json.loads(self.auth_keys_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, Exception):
return []
if isinstance(data, dict):
data = data.get("items")
return data if isinstance(data, list) else []
def save_auth_keys(self, auth_keys: list[dict[str, Any]]) -> None:
"""保存鉴权密钥数据到 JSON 文件"""
self.auth_keys_path.parent.mkdir(parents=True, exist_ok=True)
self.auth_keys_path.write_text(
json.dumps({"items": auth_keys}, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
def health_check(self) -> dict[str, Any]:
"""健康检查"""
try:
# 检查文件是否可读写
if self.file_path.exists():
self.file_path.read_text(encoding="utf-8")
return {
"status": "healthy",
"backend": "json",
"file_exists": self.file_path.exists(),
"file_path": str(self.file_path),
"auth_keys_file_exists": self.auth_keys_path.exists(),
"auth_keys_file_path": str(self.auth_keys_path),
}
except Exception as e:
return {
"status": "unhealthy",
"backend": "json",
"error": str(e),
}
def get_backend_info(self) -> dict[str, Any]:
"""获取存储后端信息"""
return {
"type": "json",
"description": "本地 JSON 文件存储",
"file_path": str(self.file_path),
"file_exists": self.file_path.exists(),
"auth_keys_file_path": str(self.auth_keys_path),
"auth_keys_file_exists": self.auth_keys_path.exists(),
}
+531
View File
@@ -0,0 +1,531 @@
"""Sub2API integration for browsing and importing ChatGPT OAuth accounts from a sub2api admin."""
from __future__ import annotations
import json
import threading
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from threading import Lock
from curl_cffi.requests import Session
from services.account_service import account_service
from services.config import DATA_DIR
SUB2API_CONFIG_FILE = DATA_DIR / "sub2api_config.json"
# Cached JWT per server to avoid re-login on every list/import call.
# Token lifetime on sub2api defaults to 24h; we refresh 5 min before expiry.
_TOKEN_REFRESH_SKEW = 5 * 60
def _new_id() -> str:
return uuid.uuid4().hex[:12]
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _clean(value: object) -> str:
return str(value or "").strip()
def _normalize_import_job(raw: object, *, fail_unfinished: bool) -> dict | None:
if not isinstance(raw, dict):
return None
status = _clean(raw.get("status")) or "failed"
if fail_unfinished and status in {"pending", "running"}:
status = "failed"
return {
"job_id": _clean(raw.get("job_id")) or uuid.uuid4().hex,
"status": status,
"created_at": _clean(raw.get("created_at")) or _now_iso(),
"updated_at": _clean(raw.get("updated_at")) or _clean(raw.get("created_at")) or _now_iso(),
"total": int(raw.get("total") or 0),
"completed": int(raw.get("completed") or 0),
"added": int(raw.get("added") or 0),
"skipped": int(raw.get("skipped") or 0),
"refreshed": int(raw.get("refreshed") or 0),
"failed": int(raw.get("failed") or 0),
"errors": raw.get("errors") if isinstance(raw.get("errors"), list) else [],
}
def _normalize_server(raw: dict) -> dict:
return {
"id": _clean(raw.get("id")) or _new_id(),
"name": _clean(raw.get("name")),
"base_url": _clean(raw.get("base_url")),
"email": _clean(raw.get("email")),
"password": _clean(raw.get("password")),
"api_key": _clean(raw.get("api_key")),
"group_id": _clean(raw.get("group_id")),
"import_job": _normalize_import_job(raw.get("import_job"), fail_unfinished=True),
}
class Sub2APIConfig:
def __init__(self, store_file: Path):
self._store_file = store_file
self._lock = Lock()
self._servers: list[dict] = self._load()
def _load(self) -> list[dict]:
if not self._store_file.exists():
return []
try:
raw = json.loads(self._store_file.read_text(encoding="utf-8"))
if isinstance(raw, list):
return [_normalize_server(item) for item in raw if isinstance(item, dict)]
except Exception:
pass
return []
def _save(self) -> None:
self._store_file.parent.mkdir(parents=True, exist_ok=True)
self._store_file.write_text(
json.dumps(self._servers, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
def list_servers(self) -> list[dict]:
with self._lock:
return [dict(server) for server in self._servers]
def get_server(self, server_id: str) -> dict | None:
with self._lock:
for server in self._servers:
if server["id"] == server_id:
return dict(server)
return None
def add_server(
self,
*,
name: str,
base_url: str,
email: str,
password: str,
api_key: str,
group_id: str = "",
) -> dict:
server = _normalize_server({
"id": _new_id(),
"name": name,
"base_url": base_url,
"email": email,
"password": password,
"api_key": api_key,
"group_id": group_id,
})
with self._lock:
self._servers.append(server)
self._save()
_token_cache.pop(server["id"], None)
return dict(server)
def update_server(self, server_id: str, updates: dict) -> dict | None:
with self._lock:
for index, server in enumerate(self._servers):
if server["id"] != server_id:
continue
merged = {**server, **{k: v for k, v in updates.items() if v is not None}, "id": server_id}
self._servers[index] = _normalize_server(merged)
self._save()
result = dict(self._servers[index])
break
else:
return None
_token_cache.pop(server_id, None)
return result
def delete_server(self, server_id: str) -> bool:
with self._lock:
before = len(self._servers)
self._servers = [server for server in self._servers if server["id"] != server_id]
removed = len(self._servers) < before
if removed:
self._save()
if removed:
_token_cache.pop(server_id, None)
return removed
def set_import_job(self, server_id: str, import_job: dict | None) -> dict | None:
with self._lock:
for index, server in enumerate(self._servers):
if server["id"] != server_id:
continue
next_server = dict(server)
next_server["import_job"] = _normalize_import_job(import_job, fail_unfinished=False)
self._servers[index] = next_server
self._save()
return dict(next_server)
return None
def get_import_job(self, server_id: str) -> dict | None:
with self._lock:
for server in self._servers:
if server["id"] == server_id:
job = server.get("import_job")
return dict(job) if isinstance(job, dict) else None
return None
# Per-server cached access token: {server_id: (jwt, expires_at_epoch)}
_token_cache: dict[str, tuple[str, float]] = {}
_token_cache_lock = Lock()
def _login(base_url: str, email: str, password: str) -> tuple[str, float]:
url = f"{base_url.rstrip('/')}/api/v1/auth/login"
session = Session(verify=True)
try:
response = session.post(
url,
json={"email": email, "password": password},
headers={"Accept": "application/json", "Content-Type": "application/json"},
timeout=30,
)
if not response.ok:
raise RuntimeError(f"sub2api login failed: HTTP {response.status_code} {response.text[:200]}")
payload = response.json()
finally:
session.close()
body = _unwrap_envelope(payload)
if not isinstance(body, dict):
raise RuntimeError("sub2api login payload is invalid")
token = _clean(body.get("access_token"))
if not token:
raise RuntimeError("sub2api login did not return access_token")
expires_in = int(body.get("expires_in") or 3600)
expires_at = time.time() + max(60, expires_in) - _TOKEN_REFRESH_SKEW
return token, expires_at
def _auth_headers(server: dict) -> dict[str, str]:
api_key = _clean(server.get("api_key"))
if api_key:
return {"x-api-key": api_key, "Accept": "application/json"}
email = _clean(server.get("email"))
password = _clean(server.get("password"))
if not email or not password:
raise RuntimeError("sub2api server requires email+password or api_key")
server_id = _clean(server.get("id"))
base_url = _clean(server.get("base_url"))
with _token_cache_lock:
cached = _token_cache.get(server_id)
if cached and cached[1] > time.time():
return {"Authorization": f"Bearer {cached[0]}", "Accept": "application/json"}
token, expires_at = _login(base_url, email, password)
with _token_cache_lock:
_token_cache[server_id] = (token, expires_at)
return {"Authorization": f"Bearer {token}", "Accept": "application/json"}
def _extract_access_token(credentials: object) -> str:
if not isinstance(credentials, dict):
return ""
for key in ("access_token", "accessToken", "token"):
value = _clean(credentials.get(key))
if value:
return value
return ""
def _unwrap_envelope(payload: object) -> object:
"""Peel sub2api's `{code, message, data}` envelope, returning the inner `data` field
when present. Also handles unwrapped responses from older/alt versions."""
if isinstance(payload, dict) and "data" in payload and "code" in payload:
return payload.get("data")
return payload
def _extract_paged_items(payload: object) -> tuple[list, int]:
"""Return (items, total) from a paginated sub2api response.
Handles both the wrapped shape `{code,data:{items,total,...}}` and a few looser
variants (`{data:[...]}`, `[...]`, `{items:[...],total:N}`)."""
inner = _unwrap_envelope(payload)
if isinstance(inner, list):
return inner, len(inner)
if isinstance(inner, dict):
for key in ("items", "data", "list"):
value = inner.get(key)
if isinstance(value, list):
return value, int(inner.get("total") or len(value))
return [], 0
def list_remote_accounts(server: dict) -> list[dict]:
"""Return a flat list of OpenAI OAuth accounts from a sub2api server."""
base_url = _clean(server.get("base_url"))
if not base_url:
return []
headers = _auth_headers(server)
group_id = _clean(server.get("group_id"))
session = Session(verify=True)
items: list[dict] = []
try:
page = 1
while True:
params: dict[str, object] = {
"platform": "openai",
"type": "oauth",
"page": page,
"page_size": 200,
}
if group_id:
params["group"] = group_id
response = session.get(
f"{base_url.rstrip('/')}/api/v1/admin/accounts",
headers=headers,
params=params,
timeout=30,
)
if not response.ok:
raise RuntimeError(f"sub2api list failed: HTTP {response.status_code} {response.text[:200]}")
payload = response.json()
data, total = _extract_paged_items(payload)
if not data:
break
for account in data:
if not isinstance(account, dict):
continue
credentials = account.get("credentials") if isinstance(account.get("credentials"), dict) else {}
account_id = account.get("id")
if account_id is None:
continue
items.append({
"id": str(account_id),
"name": _clean(account.get("name")),
"email": _clean(credentials.get("email")) or _clean(account.get("name")),
"plan_type": _clean(credentials.get("plan_type")),
"status": _clean(account.get("status")),
"expires_at": _clean(credentials.get("expires_at")),
"has_refresh_token": bool(_clean(credentials.get("refresh_token"))),
})
if page * 200 >= total or len(data) < 200:
break
page += 1
finally:
session.close()
return items
def list_remote_groups(server: dict) -> list[dict]:
"""Return OpenAI account groups from a sub2api server."""
base_url = _clean(server.get("base_url"))
if not base_url:
return []
headers = _auth_headers(server)
session = Session(verify=True)
items: list[dict] = []
try:
page = 1
while True:
response = session.get(
f"{base_url.rstrip('/')}/api/v1/admin/groups",
headers=headers,
params={
"page": page,
"page_size": 200,
},
timeout=30,
)
if not response.ok:
raise RuntimeError(f"sub2api groups failed: HTTP {response.status_code} {response.text[:200]}")
payload = response.json()
data, total = _extract_paged_items(payload)
if not data:
break
for group in data:
if not isinstance(group, dict):
continue
group_id = group.get("id")
if group_id is None:
continue
items.append({
"id": str(group_id),
"name": _clean(group.get("name")),
"description": _clean(group.get("description")),
"platform": _clean(group.get("platform")),
"status": _clean(group.get("status")),
"account_count": int(group.get("account_count") or 0),
"active_account_count": int(group.get("active_account_count") or 0),
})
if page * 200 >= total or len(data) < 200:
break
page += 1
finally:
session.close()
return items
def _fetch_access_tokens_for_accounts(server: dict, account_ids: list[str]) -> tuple[list[str], list[dict]]:
"""Return exported access tokens and per-account errors from sub2api."""
base_url = _clean(server.get("base_url"))
headers = _auth_headers(server)
ids = [_clean(item) for item in account_ids if _clean(item)]
if not ids:
return [], []
session = Session(verify=True)
try:
response = session.get(
f"{base_url.rstrip('/')}/api/v1/admin/accounts/data",
headers=headers,
params={"ids": ",".join(ids), "timezone": "Asia/Shanghai"},
timeout=30,
)
if not response.ok:
raise RuntimeError(f"HTTP {response.status_code}")
payload = response.json()
finally:
session.close()
data = _unwrap_envelope(payload)
accounts = data.get("accounts") if isinstance(data, dict) else None
if not isinstance(accounts, list):
raise RuntimeError("invalid export payload")
tokens: list[str] = []
errors: list[dict] = []
for account in accounts:
if not isinstance(account, dict):
continue
credentials = account.get("credentials") if isinstance(account.get("credentials"), dict) else {}
token = _extract_access_token(credentials)
account_id = _clean(account.get("id")) or _clean(credentials.get("chatgpt_account_id")) or _clean(account.get("name"))
if token:
tokens.append(token)
else:
errors.append({"name": account_id or _clean(account.get("name")) or "unknown", "error": "missing access_token"})
if len(accounts) < len(ids):
errors.append({"name": ",".join(ids), "error": f"exported {len(accounts)}/{len(ids)} accounts"})
return tokens, errors
class Sub2APIImportService:
def __init__(self, sub2api_config: Sub2APIConfig):
self._config = sub2api_config
def start_import(self, server: dict, account_ids: list[str]) -> dict:
ids = [_clean(item) for item in account_ids if _clean(item)]
if not ids:
raise ValueError("account ids is required")
server_id = _clean(server.get("id"))
job = {
"job_id": uuid.uuid4().hex,
"status": "pending",
"created_at": _now_iso(),
"updated_at": _now_iso(),
"total": len(ids),
"completed": 0,
"added": 0,
"skipped": 0,
"refreshed": 0,
"failed": 0,
"errors": [],
}
saved = self._config.set_import_job(server_id, job)
if saved is None:
raise ValueError("server not found")
thread = threading.Thread(
target=self._run_import,
args=(server_id, server, ids),
name=f"sub2api-import-{server_id}",
daemon=True,
)
thread.start()
return dict(saved.get("import_job") or job)
def _update_job(self, server_id: str, **updates) -> None:
current = self._config.get_import_job(server_id)
if current is None:
return
next_job = {**current, **updates, "updated_at": _now_iso()}
self._config.set_import_job(server_id, next_job)
def _append_error(self, server_id: str, account_id: str, message: str) -> None:
current = self._config.get_import_job(server_id)
if current is None:
return
errors = list(current.get("errors") or [])
errors.append({"name": account_id, "error": message})
self._update_job(server_id, errors=errors, failed=len(errors))
def _run_import(self, server_id: str, server: dict, account_ids: list[str]) -> None:
self._update_job(server_id, status="running")
try:
tokens, errors = _fetch_access_tokens_for_accounts(server, account_ids)
except Exception as exc:
message = str(exc) or "unknown error"
for account_id in account_ids:
self._append_error(server_id, account_id, message)
tokens = []
else:
for error in errors:
self._append_error(server_id, _clean(error.get("name")), _clean(error.get("error")) or "unknown error")
current = self._config.get_import_job(server_id) or {}
self._update_job(
server_id,
completed=len(account_ids),
failed=len(current.get("errors") or []),
)
if not tokens:
current = self._config.get_import_job(server_id) or {}
self._update_job(
server_id,
status="failed",
completed=int(current.get("total") or 0),
failed=len(current.get("errors") or []),
)
return
add_result = account_service.add_accounts(tokens, source_type="codex")
refresh_result = account_service.refresh_accounts(tokens)
current = self._config.get_import_job(server_id) or {}
self._update_job(
server_id,
status="completed",
completed=len(account_ids),
added=int(add_result.get("added") or 0),
skipped=int(add_result.get("skipped") or 0),
refreshed=int(refresh_result.get("refreshed") or 0),
failed=len(current.get("errors") or []),
)
sub2api_config = Sub2APIConfig(SUB2API_CONFIG_FILE)
sub2api_import_service = Sub2APIImportService(sub2api_config)
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
import json
import re
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from utils.helper import UpstreamHTTPError
@dataclass(frozen=True)
class UploadLimit:
blocked_until: datetime
reason: str
class UploadUnavailableError(RuntimeError):
def __init__(self, blocked_until: datetime | None = None, *, attempts_exhausted: bool = False):
message = (
"多个账号上传受限,本次自动切换已达到上限,请稍后重试。"
if attempts_exhausted else "当前没有可用于参考图上传的账号,请等待上传额度恢复或添加可用账号。"
)
if blocked_until:
message += f" 已知上传限制最早预计恢复时间:{blocked_until.astimezone().strftime('%Y-%m-%d %H:%M:%S %Z')}。"
super().__init__(message)
def parse_upload_limit(error: Exception, now: datetime | None = None) -> UploadLimit | None:
"""Recognize file quota failures only at the upstream upload allocation endpoint."""
if not isinstance(error, UpstreamHTTPError) or error.status_code != 429 or error.context != "/backend-api/files":
return None
body = error.body
if isinstance(body, str):
try:
body = json.loads(body)
except (ValueError, TypeError):
return None
if not isinstance(body, dict):
return None
detail = body.get("detail") or body.get("error") or body
if not isinstance(detail, dict):
return None
reason = str(detail.get("message") or "文件上传额度已用尽")
codes = {str(detail.get(key) or "").lower() for key in ("code", "error_code", "type")}
if not codes.intersection({"throttled", "file_upload_limit_exceeded", "upload_limit_exceeded"}) and not (
"上传上限" in reason or "upload limit" in reason.lower()
):
return None
now = now or datetime.now(timezone.utc)
for key in ("retry_at", "reset_at"):
value = detail.get(key)
if value:
try:
reset = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
if reset.tzinfo and reset > now:
return UploadLimit(reset, reason)
except (ValueError, TypeError):
pass
if error.retry_after is not None and error.retry_after > 0:
seconds = min(error.retry_after, 365 * 86400)
else:
units = {"小时": 3600, "分钟": 60, "秒": 1, "天": 86400,
"day": 86400, "hour": 3600, "minute": 60, "second": 1}
matches = re.findall(r"(\d+(?:\.\d+)?)\s*(小时|分钟|秒|天|days?|hours?|minutes?|seconds?)", reason.lower())
seconds = sum(float(number) * units[unit.rstrip("s")] for number, unit in matches)
# Unknown reset time: temporary cooling, never a fabricated daily reset.
seconds = min(seconds, 365 * 86400) if seconds > 0 else 15 * 60
return UploadLimit(now + timedelta(seconds=max(1, seconds)), reason)