diff --git a/README.md b/README.md index f49b99c..2da7d56 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Python 3.12 / Django 5.2 LTS + DRF / django-admin / 用户端 Django 模板 SSR ## 当前状态 -Phase 0 地基与审核修补已完成:Django + DRF 骨架、自定义 User、MySQL 8.4 配置、django-admin smoke test、解释器版本断言和 `.env.example` 均已落地。下一步是 T-101 Provider 适配器层。详见 [`docs/current-state.md`](docs/current-state.md)。 +Phase 1 已完成 T-101:Provider 适配器层、`cmbot` AI 调用逻辑迁移骨架和 mock 单测已落地。下一步是 T-102 AiModel + ModelAlias 数据模型与别名解析。详见 [`docs/current-state.md`](docs/current-state.md)。 > ⚠️ 涉及资金/点数。改动充值、扣费、退款、对账相关代码前,先读 [`docs/05-coding-rules.md`](docs/05-coding-rules.md) 第 8 节与 [`docs/04-architecture.md`](docs/04-architecture.md) 第四节计费时序。 diff --git a/apps/ai/providers/__init__.py b/apps/ai/providers/__init__.py new file mode 100644 index 0000000..532322d --- /dev/null +++ b/apps/ai/providers/__init__.py @@ -0,0 +1,26 @@ +from .base import ( + AiCapabilityError, + AiProviderConfigError, + AiProviderError, + AiResponseParseError, + ImageGenerationResult, + Provider, + ResolvedModel, + TextGenerationResult, +) +from .registry import default_registry, get_provider, register_provider, resolve_api_type + +__all__ = [ + "AiCapabilityError", + "AiProviderConfigError", + "AiProviderError", + "AiResponseParseError", + "ImageGenerationResult", + "Provider", + "ResolvedModel", + "TextGenerationResult", + "default_registry", + "get_provider", + "register_provider", + "resolve_api_type", +] diff --git a/apps/ai/providers/base.py b/apps/ai/providers/base.py new file mode 100644 index 0000000..d35eb51 --- /dev/null +++ b/apps/ai/providers/base.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Mapping, Protocol + + +class AiProviderError(RuntimeError): + """Base error for AI provider failures.""" + + +class AiProviderConfigError(AiProviderError, ValueError): + """Raised when model/provider configuration is invalid.""" + + +class AiCapabilityError(AiProviderError): + """Raised when a provider cannot perform the requested capability.""" + + +class AiResponseParseError(AiProviderError): + """Raised when a provider response cannot be parsed into the expected result.""" + + +@dataclass(frozen=True) +class ResolvedModel: + """Runtime model config resolved from future AiModel/ModelAlias records.""" + + name: str + url: str + model: str + api_key: str + api_type: str = "auto" + timeout_seconds: int = 0 + connect_timeout_seconds: int = 30 + extra_body: dict[str, Any] = field(default_factory=dict) + capabilities: frozenset[str] = field(default_factory=frozenset) + + @classmethod + def from_mapping(cls, data: Mapping[str, Any]) -> "ResolvedModel": + raw_capabilities = data.get("capabilities") or () + if isinstance(raw_capabilities, str): + capabilities = frozenset( + item.strip() for item in raw_capabilities.split(",") if item.strip() + ) + else: + capabilities = frozenset(str(item) for item in raw_capabilities) + + return cls( + name=str(data.get("name") or data.get("model") or ""), + url=str(data.get("url") or ""), + model=str(data.get("model") or ""), + api_key=str(data.get("api_key") or ""), + api_type=str(data.get("api_type") or "auto"), + timeout_seconds=_to_int(data.get("timeout_seconds"), 0), + connect_timeout_seconds=_to_int(data.get("connect_timeout_seconds"), 30), + extra_body=dict(data.get("extra_body") or {}), + capabilities=capabilities, + ) + + +@dataclass(frozen=True) +class TextGenerationResult: + text: str + titles: tuple[str, ...] + model_used: str + raw: Mapping[str, Any] + + +@dataclass(frozen=True) +class ImageGenerationResult: + image: bytes + model_used: str + raw: Mapping[str, Any] + + +class Provider(Protocol): + def capabilities(self) -> set[str]: + ... + + def generate_text( + self, + prompt: str, + model: ResolvedModel, + *, + image: bytes | None = None, + image_mime_type: str = "image/png", + resolution: str = "1K", + parameters: Mapping[str, Any] | None = None, + ) -> TextGenerationResult: + ... + + def generate_image( + self, + prompt: str, + model: ResolvedModel, + *, + image: bytes | None = None, + image_mime_type: str = "image/png", + image_filename: str = "image.png", + resolution: str = "1K", + aspect_ratio: str = "1:1", + parameters: Mapping[str, Any] | None = None, + ) -> ImageGenerationResult: + ... + + +def validate_model_config(model: ResolvedModel) -> None: + errors = [] + if not model.url.strip(): + errors.append("missing url") + if not model.model.strip(): + errors.append("missing model") + if not model.api_key.strip(): + errors.append("missing api_key") + if model.timeout_seconds < 0: + errors.append("timeout_seconds must be >= 0") + if model.connect_timeout_seconds <= 0: + errors.append("connect_timeout_seconds must be > 0") + if errors: + raise AiProviderConfigError("; ".join(errors)) + + +def _to_int(value: Any, default: int) -> int: + if value is None or value == "": + return default + try: + return int(value) + except (TypeError, ValueError): + return default diff --git a/apps/ai/providers/openai_compatible.py b/apps/ai/providers/openai_compatible.py new file mode 100644 index 0000000..997862f --- /dev/null +++ b/apps/ai/providers/openai_compatible.py @@ -0,0 +1,376 @@ +from __future__ import annotations + +from typing import Any, Mapping + +import requests + +from .base import ( + AiCapabilityError, + AiResponseParseError, + ImageGenerationResult, + ResolvedModel, + TextGenerationResult, + validate_model_config, +) +from .utils import ( + API_CHAT, + API_GEMINI, + API_IMAGES, + API_IMAGES_EDITS, + extract_image_from_response, + extract_text_from_response, + extract_titles_from_response, + image_bytes_to_data_url, + normalize_api_url, + request_timeout, + resolution_to_size, + split_data_url, +) + + +class BaseHttpProvider: + def __init__(self, session: requests.Session | None = None): + self.session = session or requests.Session() + if hasattr(self.session, "trust_env"): + self.session.trust_env = False + + def _headers(self, model: ResolvedModel, *, json: bool = False) -> dict[str, str]: + headers = {"Authorization": f"Bearer {model.api_key}"} + if json: + headers["Content-Type"] = "application/json" + return headers + + def _timeout(self, model: ResolvedModel, resolution: str) -> tuple[int, int]: + return request_timeout( + model.connect_timeout_seconds, + model.timeout_seconds, + resolution, + ) + + def _read_timeout(self, model: ResolvedModel, resolution: str) -> int: + return self._timeout(model, resolution)[1] + + +class ChatCompletionsProvider(BaseHttpProvider): + def capabilities(self) -> set[str]: + return {"text", "image", "vision"} + + def generate_text( + self, + prompt: str, + model: ResolvedModel, + *, + image: bytes | None = None, + image_mime_type: str = "image/png", + resolution: str = "1K", + parameters: Mapping[str, Any] | None = None, + ) -> TextGenerationResult: + validate_model_config(model) + url = normalize_api_url(model.url, API_CHAT) + payload = build_chat_text_payload( + model, + prompt, + image=image, + image_mime_type=image_mime_type, + parameters=parameters, + ) + response = self.session.post( + url, + headers=self._headers(model, json=True), + json=payload, + timeout=self._timeout(model, resolution), + ) + response.raise_for_status() + raw = response.json() + titles = extract_titles_from_response(raw) + text = extract_text_from_response(raw) + if not text: + raise AiResponseParseError("AI response did not contain text") + return TextGenerationResult(text=text, titles=titles, model_used=model.model, raw=raw) + + def generate_image( + self, + prompt: str, + model: ResolvedModel, + *, + image: bytes | None = None, + image_mime_type: str = "image/png", + image_filename: str = "image.png", + resolution: str = "1K", + aspect_ratio: str = "1:1", + parameters: Mapping[str, Any] | None = None, + ) -> ImageGenerationResult: + validate_model_config(model) + url = normalize_api_url(model.url, API_CHAT) + payload = build_chat_image_payload( + model, + prompt, + image=image, + image_mime_type=image_mime_type, + parameters=parameters, + ) + response = self.session.post( + url, + headers=self._headers(model, json=True), + json=payload, + timeout=self._timeout(model, resolution), + ) + response.raise_for_status() + raw = response.json() + image_bytes = extract_image_from_response( + raw, + session=self.session, + timeout=self._read_timeout(model, resolution), + ) + if not image_bytes: + raise AiResponseParseError("AI response did not contain an image") + return ImageGenerationResult(image=image_bytes, model_used=model.model, raw=raw) + + +class GeminiProvider(ChatCompletionsProvider): + def generate_text( + self, + prompt: str, + model: ResolvedModel, + *, + image: bytes | None = None, + image_mime_type: str = "image/png", + resolution: str = "1K", + parameters: Mapping[str, Any] | None = None, + ) -> TextGenerationResult: + validate_model_config(model) + url = normalize_api_url(model.url, API_GEMINI).replace("{model}", model.model) + payload = build_gemini_payload( + model, + prompt, + image=image, + image_mime_type=image_mime_type, + response_modalities=["TEXT"], + parameters=parameters, + ) + response = self.session.post( + url, + headers=self._headers(model, json=True), + json=payload, + timeout=self._timeout(model, resolution), + ) + response.raise_for_status() + raw = response.json() + titles = extract_titles_from_response(raw) + text = extract_text_from_response(raw) + if not text: + raise AiResponseParseError("AI response did not contain text") + return TextGenerationResult(text=text, titles=titles, model_used=model.model, raw=raw) + + def generate_image( + self, + prompt: str, + model: ResolvedModel, + *, + image: bytes | None = None, + image_mime_type: str = "image/png", + image_filename: str = "image.png", + resolution: str = "1K", + aspect_ratio: str = "1:1", + parameters: Mapping[str, Any] | None = None, + ) -> ImageGenerationResult: + validate_model_config(model) + url = normalize_api_url(model.url, API_GEMINI).replace("{model}", model.model) + payload = build_gemini_payload( + model, + prompt, + image=image, + image_mime_type=image_mime_type, + response_modalities=["TEXT", "IMAGE"], + parameters=parameters, + ) + response = self.session.post( + url, + headers=self._headers(model, json=True), + json=payload, + timeout=self._timeout(model, resolution), + ) + response.raise_for_status() + raw = response.json() + image_bytes = extract_image_from_response( + raw, + session=self.session, + timeout=self._read_timeout(model, resolution), + ) + if not image_bytes: + raise AiResponseParseError("AI response did not contain an image") + return ImageGenerationResult(image=image_bytes, model_used=model.model, raw=raw) + + +class ImagesGenerationProvider(BaseHttpProvider): + def capabilities(self) -> set[str]: + return {"image"} + + def generate_text(self, *args: Any, **kwargs: Any) -> TextGenerationResult: + raise AiCapabilityError("images generation provider cannot generate text") + + def generate_image( + self, + prompt: str, + model: ResolvedModel, + *, + image: bytes | None = None, + image_mime_type: str = "image/png", + image_filename: str = "image.png", + resolution: str = "1K", + aspect_ratio: str = "1:1", + parameters: Mapping[str, Any] | None = None, + ) -> ImageGenerationResult: + validate_model_config(model) + url = normalize_api_url(model.url, API_IMAGES) + payload: dict[str, Any] = { + "model": model.model, + "prompt": prompt, + "aspect_ratio": aspect_ratio, + "resolution": resolution, + "n": 1, + } + if image is not None: + payload["image_urls"] = [image_bytes_to_data_url(image, image_mime_type)] + apply_extra_body(payload, model, parameters) + response = self.session.post( + url, + headers=self._headers(model, json=True), + json=payload, + timeout=self._timeout(model, resolution), + ) + response.raise_for_status() + raw = response.json() + image_bytes = extract_image_from_response( + raw, + session=self.session, + timeout=self._read_timeout(model, resolution), + ) + if not image_bytes: + raise AiResponseParseError("AI response did not contain an image") + return ImageGenerationResult(image=image_bytes, model_used=model.model, raw=raw) + + +class ImagesEditsProvider(BaseHttpProvider): + def capabilities(self) -> set[str]: + return {"image", "vision"} + + def generate_text(self, *args: Any, **kwargs: Any) -> TextGenerationResult: + raise AiCapabilityError("images edits provider cannot generate text") + + def generate_image( + self, + prompt: str, + model: ResolvedModel, + *, + image: bytes | None = None, + image_mime_type: str = "image/png", + image_filename: str = "image.png", + resolution: str = "1K", + aspect_ratio: str = "1:1", + parameters: Mapping[str, Any] | None = None, + ) -> ImageGenerationResult: + validate_model_config(model) + if image is None: + raise AiCapabilityError("images edits provider requires an input image") + + url = normalize_api_url(model.url, API_IMAGES_EDITS) + data: dict[str, Any] = { + "model": model.model, + "prompt": prompt, + "n": "1", + "size": resolution_to_size(resolution), + } + apply_extra_body(data, model, parameters) + files = {"image": (image_filename, image, image_mime_type)} + response = self.session.post( + url, + headers=self._headers(model), + data=data, + files=files, + timeout=self._timeout(model, resolution), + ) + response.raise_for_status() + raw = response.json() + image_bytes = extract_image_from_response( + raw, + session=self.session, + timeout=self._read_timeout(model, resolution), + ) + if not image_bytes: + raise AiResponseParseError("AI response did not contain an image") + return ImageGenerationResult(image=image_bytes, model_used=model.model, raw=raw) + + +def build_chat_text_payload( + model: ResolvedModel, + prompt: str, + *, + image: bytes | None = None, + image_mime_type: str = "image/png", + parameters: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + content: list[dict[str, Any]] = [{"type": "text", "text": prompt}] + if image is not None: + content.append( + { + "type": "image_url", + "image_url": {"url": image_bytes_to_data_url(image, image_mime_type)}, + } + ) + payload: dict[str, Any] = { + "model": model.model, + "messages": [{"role": "user", "content": content}], + "stream": False, + } + apply_extra_body(payload, model, parameters) + return payload + + +def build_chat_image_payload( + model: ResolvedModel, + prompt: str, + *, + image: bytes | None = None, + image_mime_type: str = "image/png", + parameters: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + return build_chat_text_payload( + model, + prompt, + image=image, + image_mime_type=image_mime_type, + parameters=parameters, + ) + + +def build_gemini_payload( + model: ResolvedModel, + prompt: str, + *, + image: bytes | None = None, + image_mime_type: str = "image/png", + response_modalities: list[str], + parameters: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + parts: list[dict[str, Any]] = [{"text": prompt}] + if image is not None: + data_url = image_bytes_to_data_url(image, image_mime_type) + mime_type, data = split_data_url(data_url) + parts.append({"inlineData": {"mimeType": mime_type, "data": data}}) + payload: dict[str, Any] = { + "contents": [{"parts": parts}], + "generationConfig": {"responseModalities": response_modalities}, + } + apply_extra_body(payload, model, parameters) + return payload + + +def apply_extra_body( + payload: dict[str, Any], + model: ResolvedModel, + parameters: Mapping[str, Any] | None = None, +) -> None: + payload.update(model.extra_body) + if parameters: + payload.update(dict(parameters)) diff --git a/apps/ai/providers/registry.py b/apps/ai/providers/registry.py new file mode 100644 index 0000000..6cef1c0 --- /dev/null +++ b/apps/ai/providers/registry.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +from .base import AiProviderConfigError, Provider +from .openai_compatible import ( + ChatCompletionsProvider, + GeminiProvider, + ImagesEditsProvider, + ImagesGenerationProvider, +) +from .utils import ( + API_AUTO, + API_CHAT, + API_GEMINI, + API_IMAGES, + API_IMAGES_EDITS, + detect_api_type, +) + + +class ProviderRegistry: + def __init__(self) -> None: + self._providers: dict[str, Provider] = {} + + def register(self, api_type: str, provider: Provider) -> None: + self._providers[api_type] = provider + + def resolve_api_type(self, api_type: str, url: str = "") -> str: + return detect_api_type(url, api_type) if api_type == API_AUTO else api_type + + def get(self, api_type: str, url: str = "") -> Provider: + resolved_api_type = self.resolve_api_type(api_type, url) + try: + return self._providers[resolved_api_type] + except KeyError as exc: + raise AiProviderConfigError( + f"no provider registered for api_type: {resolved_api_type}" + ) from exc + + +default_registry = ProviderRegistry() +default_registry.register(API_CHAT, ChatCompletionsProvider()) +default_registry.register(API_GEMINI, GeminiProvider()) +default_registry.register(API_IMAGES, ImagesGenerationProvider()) +default_registry.register(API_IMAGES_EDITS, ImagesEditsProvider()) + + +def register_provider(api_type: str, provider: Provider) -> None: + default_registry.register(api_type, provider) + + +def resolve_api_type(api_type: str, url: str = "") -> str: + return default_registry.resolve_api_type(api_type, url) + + +def get_provider(api_type: str, url: str = "") -> Provider: + return default_registry.get(api_type, url) diff --git a/apps/ai/providers/utils.py b/apps/ai/providers/utils.py new file mode 100644 index 0000000..3e4e844 --- /dev/null +++ b/apps/ai/providers/utils.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +import base64 +import binascii +import re +from pathlib import Path +from typing import Any, Iterable +from urllib.parse import urljoin, urlparse + +import requests + +from .base import AiProviderConfigError, AiResponseParseError + +API_AUTO = "auto" +API_CHAT = "chat" +API_GEMINI = "gemini" +API_IMAGES = "images" +API_IMAGES_EDITS = "images_edits" + +SUPPORTED_API_TYPES = { + API_AUTO, + API_CHAT, + API_GEMINI, + API_IMAGES, + API_IMAGES_EDITS, +} + +RESOLUTION_TIMEOUTS = {"512": 180, "1K": 240, "2K": 360, "4K": 600} +BASE64_KEYS = {"image_base64", "base64", "b64_json", "data"} +IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"} + +TITLE_LEAD = re.compile(r"^\s*(?:\d+\s*[\.\)、::]|[-*•])\s*") +TITLE_CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩" +TITLE_QUOTES = "\"'「」『』“”‘’" +TITLE_SPLIT = re.compile(r"[,,\r\n]+") + + +def resolution_timeout(resolution: str, default: int = 240) -> int: + return RESOLUTION_TIMEOUTS.get(str(resolution).strip().upper(), default) + + +def request_timeout(connect_timeout: int, read_timeout: int, resolution: str) -> tuple[int, int]: + resolved_read_timeout = read_timeout if read_timeout > 0 else resolution_timeout(resolution) + return connect_timeout, resolved_read_timeout + + +def detect_api_type(url: str, api_type: str = API_AUTO) -> str: + if api_type and api_type != API_AUTO: + if api_type not in SUPPORTED_API_TYPES: + raise AiProviderConfigError(f"unsupported api_type: {api_type}") + return api_type + + lower_url = str(url).lower() + if "generatecontent" in lower_url or "gemini" in lower_url: + return API_GEMINI + if "/images/edits" in lower_url: + return API_IMAGES_EDITS + if "/images" in lower_url: + return API_IMAGES + return API_CHAT + + +def normalize_api_url(url: str, api_type: str) -> str: + raw = str(url).strip() + if not raw: + return raw + + endpoint = { + API_CHAT: "chat/completions", + API_IMAGES: "images/generations", + API_IMAGES_EDITS: "images/edits", + }.get(api_type) + + lower_path = urlparse(raw).path.lower().rstrip("/") + if api_type == API_GEMINI: + if "generatecontent" in lower_path: + return raw + return join_url(raw, "v1beta/models/{model}:generateContent") + + if endpoint is None: + return raw + if lower_path.endswith("/" + endpoint): + return raw + if lower_path.endswith("/v1"): + return join_url(raw, endpoint) + return join_url(raw, "v1/" + endpoint) + + +def join_url(base_url: str, suffix: str) -> str: + base = str(base_url).rstrip("/") + "/" + return urljoin(base, suffix) + + +def image_bytes_to_data_url(image: bytes, mime_type: str = "image/png") -> str: + encoded = base64.b64encode(image).decode("ascii") + return f"data:{mime_type};base64,{encoded}" + + +def split_data_url(data_url: str) -> tuple[str, str]: + prefix, encoded = data_url.split(",", 1) + mime_type = prefix[len("data:") :].split(";", 1)[0] + return mime_type, encoded + + +def decode_image_data_url(data_url: str) -> bytes: + marker = ";base64," + if marker not in data_url: + raise AiResponseParseError("unsupported data URL image format") + return base64.b64decode(data_url.split(marker, 1)[1]) + + +def resolution_to_size(resolution: str) -> str: + mapping = { + "512": "512x512", + "512px": "512x512", + "1K": "1024x1024", + "2K": "2048x2048", + "4K": "4096x4096", + } + return mapping.get(str(resolution), str(resolution)) + + +def extract_image_from_response( + data: Any, + *, + session: requests.Session | None = None, + timeout: int = 60, +) -> bytes | None: + for key, value in walk_json_items(data): + if not isinstance(value, str): + continue + text = value.strip() + if text.startswith("data:image/"): + return decode_image_data_url(text) + if key and key.lower() in BASE64_KEYS and looks_like_base64(text): + try: + return base64.b64decode(text) + except (TypeError, ValueError, binascii.Error): + pass + + for _key, value in walk_json_items(data): + if isinstance(value, str) and is_image_url(value): + return download_image_url(value, session=session, timeout=timeout) + + return None + + +def download_image_url( + url: str, + *, + session: requests.Session | None = None, + timeout: int = 60, +) -> bytes: + client = session or requests.Session() + if hasattr(client, "trust_env"): + client.trust_env = False + response = client.get(url, timeout=timeout) + response.raise_for_status() + return response.content + + +def extract_titles_from_response(data: Any) -> tuple[str, ...]: + return tuple(clean_titles(extract_raw_text(data))) + + +def extract_text_from_response(data: Any) -> str: + titles = extract_titles_from_response(data) + return titles[0] if titles else "" + + +def extract_raw_text(data: Any) -> str: + if not isinstance(data, dict): + return "" + + choices = data.get("choices") + if isinstance(choices, list) and choices and isinstance(choices[0], dict): + message = choices[0].get("message") + if isinstance(message, dict): + text = content_to_text(message.get("content")) + if text.strip(): + return text + legacy = choices[0].get("text") + if isinstance(legacy, str) and legacy.strip(): + return legacy + + candidates = data.get("candidates") + if isinstance(candidates, list) and candidates and isinstance(candidates[0], dict): + content = candidates[0].get("content") + if isinstance(content, dict) and isinstance(content.get("parts"), list): + texts = [ + part.get("text") + for part in content["parts"] + if isinstance(part, dict) and isinstance(part.get("text"), str) + ] + joined = "\n".join(text for text in texts if text) + if joined.strip(): + return joined + + return "" + + +def content_to_text(content: Any) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + texts = [] + for part in content: + if isinstance(part, dict) and isinstance(part.get("text"), str): + texts.append(part["text"]) + elif isinstance(part, str): + texts.append(part) + return "\n".join(texts) + return "" + + +def clean_titles(text: str) -> list[str]: + if not text: + return [] + out = [] + for piece in TITLE_SPLIT.split(str(text)): + cleaned = clean_title_line(piece) + if cleaned: + out.append(cleaned) + return out + + +def clean_title_line(line: str) -> str: + stripped = line.strip() + if not stripped: + return "" + stripped = TITLE_LEAD.sub("", stripped) + stripped = stripped.lstrip(TITLE_CIRCLED).strip() + stripped = stripped.strip(TITLE_QUOTES).strip() + return stripped + + +def walk_json_items(value: Any, key: str | None = None) -> Iterable[tuple[str | None, Any]]: + yield key, value + if isinstance(value, dict): + for child_key, child_value in value.items(): + yield from walk_json_items(child_value, str(child_key)) + elif isinstance(value, list): + for child_value in value: + yield from walk_json_items(child_value, key) + + +def looks_like_base64(text: str) -> bool: + if len(text) < 8: + return False + try: + base64.b64decode(text, validate=True) + return True + except (TypeError, ValueError, binascii.Error): + return False + + +def is_image_url(text: str) -> bool: + parsed = urlparse(text.strip()) + if parsed.scheme.lower() not in ("http", "https"): + return False + suffix = Path(parsed.path).suffix.lower() + return suffix in IMAGE_EXTENSIONS diff --git a/apps/ai/tests.py b/apps/ai/tests.py index 7ce503c..b9e1cb7 100644 --- a/apps/ai/tests.py +++ b/apps/ai/tests.py @@ -1,3 +1,177 @@ -from django.test import TestCase +import base64 -# Create your tests here. +from django.test import SimpleTestCase + +from apps.ai.providers import AiCapabilityError, ResolvedModel, get_provider, resolve_api_type +from apps.ai.providers.openai_compatible import ChatCompletionsProvider, ImagesEditsProvider + + +class FakeResponse: + def __init__(self, payload, content=b""): + self.payload = payload + self.content = content + + def json(self): + return self.payload + + def raise_for_status(self): + return None + + +class FakeSession: + def __init__(self, *responses): + self.responses = list(responses) + self.posts = [] + self.gets = [] + self.trust_env = True + + def post(self, url, **kwargs): + self.posts.append({"url": url, **kwargs}) + return self.responses.pop(0) + + def get(self, url, **kwargs): + self.gets.append({"url": url, **kwargs}) + return self.responses.pop(0) + + +class ProviderRegistryTests(SimpleTestCase): + def test_auto_api_type_resolves_chat_provider_from_url(self): + url = "https://api.vectorengine.ai/v1/chat/completions" + + self.assertEqual(resolve_api_type("auto", url), "chat") + self.assertIsInstance(get_provider("auto", url), ChatCompletionsProvider) + + +class ChatCompletionsProviderTests(SimpleTestCase): + def test_generate_text_builds_chat_payload_and_cleans_titles(self): + session = FakeSession( + FakeResponse( + { + "choices": [ + {"message": {"content": "1. Red Dress\n2. Blue Coat"}} + ] + } + ) + ) + provider = ChatCompletionsProvider(session=session) + model = ResolvedModel( + name="GPT-5.5 text", + url="https://api.vectorengine.ai/v1", + model="gpt-5.5", + api_key="test-key", + api_type="chat", + ) + + result = provider.generate_text( + "Generate titles", + model, + parameters={"temperature": 0.2}, + ) + + self.assertEqual(result.text, "Red Dress") + self.assertEqual(result.titles, ("Red Dress", "Blue Coat")) + request = session.posts[0] + self.assertEqual( + request["url"], + "https://api.vectorengine.ai/v1/chat/completions", + ) + self.assertEqual(request["headers"]["Authorization"], "Bearer test-key") + self.assertEqual(request["json"]["model"], "gpt-5.5") + self.assertFalse(request["json"]["stream"]) + self.assertEqual(request["json"]["temperature"], 0.2) + + def test_generate_image_parses_chat_multimodal_data_url(self): + generated = b"generated-image" + encoded = base64.b64encode(generated).decode("ascii") + session = FakeSession( + FakeResponse( + { + "choices": [ + { + "message": { + "content": [ + {"type": "text", "text": "done"}, + { + "type": "image_url", + "image_url": { + "url": f"data:image/png;base64,{encoded}" + }, + }, + ] + } + } + ] + } + ) + ) + provider = ChatCompletionsProvider(session=session) + model = ResolvedModel( + name="Nano Banana 2", + url="https://api.vectorengine.ai/v1/chat/completions", + model="gemini-3.1-flash-image-preview", + api_key="test-key", + api_type="auto", + ) + + result = provider.generate_image( + "Generate product image", + model, + image=b"input-image", + image_mime_type="image/jpeg", + ) + + self.assertEqual(result.image, generated) + content = session.posts[0]["json"]["messages"][0]["content"] + self.assertEqual(content[0], {"type": "text", "text": "Generate product image"}) + self.assertTrue(content[1]["image_url"]["url"].startswith("data:image/jpeg;base64,")) + + +class ImagesEditsProviderTests(SimpleTestCase): + def test_generate_image_builds_multipart_request_and_parses_base64(self): + generated = b"edited-image" + encoded = base64.b64encode(generated).decode("ascii") + session = FakeSession(FakeResponse({"data": [{"b64_json": encoded}]})) + provider = ImagesEditsProvider(session=session) + model = ResolvedModel( + name="GPT Image 2", + url="https://api.vectorengine.ai/v1/images/edits", + model="gpt-image-2", + api_key="test-key", + api_type="images_edits", + ) + + result = provider.generate_image( + "Replace background", + model, + image=b"source-image", + image_mime_type="image/png", + image_filename="source.png", + resolution="1K", + ) + + self.assertEqual(result.image, generated) + request = session.posts[0] + self.assertEqual(request["url"], "https://api.vectorengine.ai/v1/images/edits") + self.assertEqual(request["headers"]["Authorization"], "Bearer test-key") + self.assertEqual(request["data"]["model"], "gpt-image-2") + self.assertEqual(request["data"]["size"], "1024x1024") + self.assertEqual( + request["files"]["image"], + ("source.png", b"source-image", "image/png"), + ) + + def test_images_edits_requires_input_image(self): + provider = ImagesEditsProvider(session=FakeSession()) + model = ResolvedModel( + name="GPT Image 2", + url="https://api.vectorengine.ai/v1/images/edits", + model="gpt-image-2", + api_key="test-key", + api_type="images_edits", + ) + + with self.assertRaises(AiCapabilityError): + provider.generate_image("Replace background", model) + + with self.assertRaises(AiCapabilityError): + provider.generate_text("Generate title", model) diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index bb1f1e7..166d577 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -38,12 +38,12 @@ ## 当前阶段 -当前项目处于:**Phase 1 准备开始**。T-001~T-004 已完成并通过标准验证,下一步进入 T-101 Provider 适配器层。 +当前项目处于:**Phase 1**。T-101 Provider 适配器层已完成并通过 mock 单测,下一步进入 T-102 AiModel + ModelAlias 数据模型与别名解析。 优先路径: 1. Phase 0:Django 骨架可运行、**自定义 User 模型在首次迁移前定好**、django-admin 可登录;T-004 审核修补项已完成。 -2. Phase 1:最高风险功能原型 —— 移植 `cmbot` 的 AI 调用并在服务端跑通一次标题/图片生成。 +2. Phase 1:最高风险功能原型 —— T-101 已移植 `cmbot` 的 AI 调用 provider 层;下一步 T-102/T-104 完成模型配置、别名解析并跑通一次标题/图片生成。 3. Phase 2:计费核心 —— User/UserWallet/ApiKey 模型 + 点数扣减(并发安全,锁 Wallet 行)+ 计费规则 + 调用记录。 4. Phase 3:对外 API 与充值 —— Key 鉴权、生成接口、余额查询、充值回调、扫码下单与轮询。 5. Phase 4:用户端(Django 模板 SSR)—— 注册登录、API Key 管理、个人中心/记录页、充值页。 diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index d22ed1a..8f94c34 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -13,7 +13,7 @@ | 运营后台 | django-admin | 已定 | 近零代码即得用户/点数/记录的增删改查与检索,省 80% 后台工作量 | | 用户端 | Django 模板 SSR + Bootstrap 5 + django-allauth + crispy-forms | 已定 | 自助注册/登录/充值/API Key 管理/记录页;allauth 出注册登录邮箱验证,crispy + 现成 Bootstrap 模板出页面,单体不引前端框架 | | 后台美化 | django-unfold 或 simpleui | 待定 | 仅外观,MVP 可先用原生 admin,后期按需引入 | -| AI 上游对接 | **Provider 适配器层**(按 `api_type` 注册)+ **能力别名** 映射 | 已定 | 对外只暴露 `generate text/image` 两接口与别名;换供应商改后台映射,不动对外契约。移植 `cmbot` 的调用逻辑到各适配器。当前 3 模型机制不同:文本 chat、`nano-banana2` chat 多模态返图、`gpt-image-2` images/edits 改图(详见 `04` 3.1) | +| AI 上游对接 | **Provider 适配器层**(按 `api_type` 注册)+ **能力别名** 映射 + `requests` HTTP 客户端 | 已定 | 对外只暴露 `generate text/image` 两接口与别名;换供应商改后台映射,不动对外契约。移植 `cmbot` 的调用逻辑到各适配器。当前 3 模型机制不同:文本 chat、`nano-banana2` chat 多模态返图、`gpt-image-2` images/edits 改图(详见 `04` 3.1) | | 供应商密钥存储 | 应用层对称加密(如 `cryptography` Fernet)或 KMS | 待定 | `AiModel.api_key` 加密入库、admin 脱敏不回显;加密主密钥走环境变量,配置清单见 `env.md` | | 图片结果存储 | 对象存储(S3 兼容 / 本地存储)返回 URL | 待定 | 同步响应默认返回 `image_url`,避免大 base64 进响应体 | | 配置变更审计 | django-admin LogEntry 或自建审计表 | 待定 | 模型/别名/密钥变更留痕,与「账目对得上」一致 | diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 6ca0cf9..3c0f09f 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -31,7 +31,7 @@ | ID | 任务 | 依赖 | 验收要点 | 状态 | | --- | --- | --- | --- | --- | -| T-101 | Provider 适配器层 + 移植 cmbot 调用 | T-002 | 定义 `Provider` 接口(`capabilities`/`generate_text`/`generate_image`),按 `api_type` 注册;把 `ai_text_service.py`/`ai_image_service.py` 搬进 `apps/ai/providers/` 并去除桌面依赖;**注意 3 模型机制不同(chat / chat 多模态返图 nano-banana2 / images_edits 改图 gpt-image-2,见 `04` 3.1),两图片模型非标准生成需分别解析、首次对接抓真实响应**;mock 上游单测验证解析与适配器选取 | TODO | +| T-101 | Provider 适配器层 + 移植 cmbot 调用 | T-002 | 定义 `Provider` 接口(`capabilities`/`generate_text`/`generate_image`),按 `api_type` 注册;把 `ai_text_service.py`/`ai_image_service.py` 搬进 `apps/ai/providers/` 并去除桌面依赖;**注意 3 模型机制不同(chat / chat 多模态返图 nano-banana2 / images_edits 改图 gpt-image-2,见 `04` 3.1),两图片模型非标准生成需分别解析、首次对接抓真实响应**;mock 上游单测验证解析与适配器选取 | DONE | | T-102 | AiModel + ModelAlias 模型 + 别名解析 | T-101 | AiModel 含 `capabilities`、`api_key` **加密存储**(admin 脱敏不回显);ModelAlias 映射别名→模型;`resolve_alias()` 能解析并按能力校验;配置迁移自 `ai_models.json`;后台改配置运行时热生效 | TODO | | T-103 | 配置变更审计 | T-102 | AiModel/ModelAlias/密钥的后台变更留痕(谁、何时、改了什么);可在 admin 查看 | TODO | | T-104 | 跑通一次真实/录制的标题或图片生成 | T-102 | 用别名 + 最小输入跑通一次生成,结论写入 `progress.md`(含耗时,验证图片同步可行性与超时配置) | TODO | diff --git a/docs/api.md b/docs/api.md index 798da3d..de8a7df 100644 --- a/docs/api.md +++ b/docs/api.md @@ -193,16 +193,22 @@ class Provider(Protocol): def capabilities(self) -> set[str]: ... # {"text","image","vision"} def generate_text(self, prompt: str, model: ResolvedModel, image: bytes | None = None, + image_mime_type: str = "image/png", resolution: str = "1K", - parameters: dict | None = None) -> list[str]: ... - def generate_image(self, prompt: str, model: ResolvedModel, image: bytes, + parameters: dict | None = None) -> TextGenerationResult: ... + def generate_image(self, prompt: str, model: ResolvedModel, + image: bytes | None = None, + image_mime_type: str = "image/png", + image_filename: str = "image.png", resolution: str = "1K", aspect_ratio: str = "1:1", - parameters: dict | None = None) -> bytes | str: ... + parameters: dict | None = None) -> ImageGenerationResult: ... ``` 要点: - `ResolvedModel` 来自数据库 AiModel(形状同 `cmbot/config/ai_models.json`,外加 `capabilities`;`api_key` 在库中加密,使用时解密,不落明文)。 +- T-101 先用 `ResolvedModel` dataclass 承接配置,不建数据库表;T-102 再把它接到 AiModel/ModelAlias。 +- `TextGenerationResult` 包含 `text`、清洗后的 `titles`、`model_used`、`raw`;`ImageGenerationResult` 包含图片 bytes、`model_used`、`raw`。图片落对象存储并返回 URL 属 T-302 之后的 API 编排职责。 - 适配器按 `api_type`(`chat`/`gemini`/`images`/`images_edits`/`auto`)从注册表选取,新增供应商 = 新增一个适配器,不改对外接口。 - `parameters` 为供应商特有参数透传;适配器负责把统一入参翻译成各家上游格式。 - 配置热生效:每次调用读当前 AiModel/ModelAlias,后台改动及时反映(或带缓存失效)。 diff --git a/docs/current-state.md b/docs/current-state.md index b23e802..dea7dc5 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -12,10 +12,10 @@ ## 当前快照 - 日期:2026-07-02 -- 阶段:Phase 0 地基与审核修补已完成;下一步进入 Phase 1 的 T-101 -- 技术栈:系统 Python 3.12.3 + Django 5.2.15 + DRF 3.16.1 + PyMySQL 1.1.3 + cryptography 46.0.7 + django-admin;MySQL 8.4 已接入 settings;用户端(模板 SSR/Bootstrap/allauth) 后续任务落地;详见 `03-tech-stack.md` -- 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml` -- 测试:`makemigrations --check` 通过;`migrate` 通过;`manage.py check` 通过;`manage.py test` 通过(2 条 admin smoke tests);`./init.ps1` 通过 +- 阶段:Phase 1,T-101 已完成;下一步 T-102 +- 技术栈:系统 Python 3.12.3 + Django 5.2.15 + DRF 3.16.1 + PyMySQL 1.1.3 + cryptography 46.0.7 + requests 2.34.2 + django-admin;MySQL 8.4 已接入 settings;用户端(模板 SSR/Bootstrap/allauth) 后续任务落地;详见 `03-tech-stack.md` +- 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器) +- 测试:`manage.py test` 通过(7 tests);`manage.py check` 通过;`makemigrations --check` 通过;`compileall apps` 通过;`./init.ps1` 通过 - 数据:AI 上游调用与模型配置参考 `D:\chengma\cmbot`(`src/services/ai_text_service.py`、`ai_image_service.py`、`config/ai_models.json`) - 标准启动路径:Windows 用 `./init.ps1`;Unix/WSL 用 `./init.sh` - 标准验证路径:Windows 用 `py -3.12 manage.py check` / `py -3.12 manage.py test` @@ -31,9 +31,9 @@ | `AGENTS.md` / `CLAUDE.md` | 已有 | 仓库级入口 | | `progress.md` | 已有 | 执行流水,已记录多轮文档决策;后续任务继续追加 | | `init.sh` / `init.ps1` | 已有 | 启动验证入口,已固定系统 Python 3.12 命令,并校验解释器版本 `>=3.12,<3.14` | -| `requirements.txt` / `pyproject.toml` | 已有 | `requirements.txt` 管运行依赖;`pyproject.toml` 落地 `requires-python` | +| `requirements.txt` / `pyproject.toml` | 已有 | `requirements.txt` 管运行依赖;`pyproject.toml` 落地 `requires-python`;T-101 新增 `requests` | | `config/`(Django 工程) | 已有 | T-001 创建,含 settings / urls / wsgi / asgi | -| `apps/`(users/portal/billing/ai/api) | 已有 | T-002 创建;`apps/users` 已定义自定义 `User`;T-003 已注册 admin 与 admin smoke test;T-004 已给 `User.email` 加唯一约束 | +| `apps/`(users/portal/billing/ai/api) | 已有 | T-002 创建;`apps/users` 已定义自定义 `User`;T-003 已注册 admin 与 admin smoke test;T-004 已给 `User.email` 加唯一约束;T-101 已新增 `apps/ai/providers` | | `manage.py` | 已有 | T-001 创建 | | `tests/` | 待建 | 随各任务补充 | @@ -41,10 +41,10 @@ 任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史执行记录见 [`../progress.md`](../progress.md)。 -- 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补。 +- 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用。 - 正在进行:无。 - 当前 blocker:无。 -- 下一个可领取任务:**T-101 Provider 适配器层 + 移植 cmbot 调用**。 +- 下一个可领取任务:**T-102 AiModel + ModelAlias 模型 + 别名解析**。 ## 当前可运行内容 @@ -62,14 +62,14 @@ python3.12 manage.py test python3.12 manage.py runserver ``` -当前骨架可运行。T-002 已在首次迁移前创建自定义 User,并按 `env.md` 接入 MySQL 8.4 / utf8mb4;远程 MySQL 已完成 Django 初始迁移。T-003 已接通 django-admin,标准测试可创建/销毁 `test_cmhub` 测试库并通过。T-004 已应用 `users.0002_alter_user_email`,`user.email` 已有唯一索引。 +当前骨架可运行。T-002 已在首次迁移前创建自定义 User,并按 `env.md` 接入 MySQL 8.4 / utf8mb4;远程 MySQL 已完成 Django 初始迁移。T-003 已接通 django-admin,标准测试可创建/销毁 `test_cmhub` 测试库并通过。T-004 已应用 `users.0002_alter_user_email`,`user.email` 已有唯一索引。T-101 的 AI provider 层只做 HTTP 调用与响应解析,不做数据库模型、别名解析或计费;这些从 T-102/T-201/T-302 继续。 ## 开始编码前检查 1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。 2. 读 `docs/00-ai-start-here.md`。 3. 读 `docs/05-coding-rules.md`(尤其第 8 节资金安全)。 -4. 在 `docs/06-tasks.md` 取第一个 `TODO` 且依赖均 `DONE` 的任务(当前为 T-101)。 +4. 在 `docs/06-tasks.md` 取第一个 `TODO` 且依赖均 `DONE` 的任务(当前为 T-102)。 5. 将该任务状态改为 `DOING`。 ## 维护规则 diff --git a/progress.md b/progress.md index ffc9624..3098a67 100644 --- a/progress.md +++ b/progress.md @@ -266,3 +266,26 @@ - 阻塞:无。 - 决策:保留 `requirements.txt` 作为运行依赖来源,`pyproject.toml` 只承载 Python 版本元数据;`.env.example` 使用占位符,不提交 `.env`。 - 下一步:领取 T-101 Provider 适配器层 + 移植 cmbot 调用。 + +## 2026-07-02 T-101 Provider 适配器层 + 移植 cmbot 调用 + +- 状态:DONE +- 变更: + - 新增 `apps/ai/providers/`:`ResolvedModel`、`Provider` 协议、结果对象、错误类型、注册表、`chat`/`gemini`/`images`/`images_edits` 适配器。 + - 从 `D:\chengma\cmbot\src\services\ai_text_service.py` / `ai_image_service.py` 移植纯 HTTP 与响应解析逻辑:URL 归一化、`api_type=auto` 识别、分辨率超时、chat/gemini payload、images/edits multipart、标题清洗、图片 data URL/base64/URL 解析。 + - 服务端接口改为 bytes 输入,不依赖桌面端本地路径、GUI、线程或 Qt;HTTP session 可注入,便于测试。 + - `requirements.txt` 新增 `requests>=2.32,<3`。 + - `apps/ai/tests.py` 新增 5 条 mock 单测,覆盖 provider 选择、chat 文本请求构造和标题解析、chat 多模态返图解析、images/edits multipart 请求构造和 base64 图片解析、能力不支持错误。 + - 同步 `docs/api.md`、`docs/03-tech-stack.md`、`docs/current-state.md`、`docs/06-tasks.md`、`README.md`、`docs/00-ai-start-here.md`。 +- 验证: + - 脱敏读取 `D:\chengma\cmbot\config\ai_models.json`:顶层为 `models` 列表,共 3 个模型;仅打印非密钥字段,未暴露真实 key。 + - `py -3.12 -m pip install -r requirements.txt`:通过,安装 `requests 2.34.2` 及依赖。 + - `py -3.12 manage.py test apps.ai`:通过,5 tests OK。 + - `py -3.12 manage.py test`:通过,7 tests OK。 + - `py -3.12 manage.py check`:通过,0 issues。 + - `py -3.12 manage.py makemigrations --check`:通过,No changes detected。 + - `py -3.12 -m compileall apps`:通过。 + - `./init.ps1`:通过,依赖同步含 `requests`,基础检查正常。 +- 阻塞:无。 +- 决策:T-101 不创建 AiModel/ModelAlias 数据表、不做别名解析数据库读取、不接计费;先用 `ResolvedModel` dataclass 承接后续 T-102 的数据库模型。 +- 下一步:领取 T-102 AiModel + ModelAlias 模型 + 别名解析。 diff --git a/requirements.txt b/requirements.txt index c454a59..80dc906 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ Django>=5.2,<5.3 djangorestframework>=3.16,<3.17 PyMySQL>=1.1,<1.2 cryptography>=42,<47 +requests>=2.32,<3