diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6bb39a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# 虚拟环境与构建产物 +.venv/ +__pycache__/ +*.py[cod] +*.egg-info/ +build/ +dist/ + +# 测试缓存 +.pytest_cache/ +.coverage + +# 本地密钥/会话数据(config.toml 权限 0600,不入库) +.quercus/ + +# 系统与编辑器 +.DS_Store +.idea/ diff --git a/core/pyproject.toml b/core/pyproject.toml new file mode 100644 index 0000000..cc3b49c --- /dev/null +++ b/core/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "quercus-core" +version = "0.1.0" +description = "Quercus AI 剪辑助手 — 宿主无关的 AI 核心(LLMProvider / Agent / 工具 schema / 会话)" +requires-python = ">=3.11" +dependencies = [ + "httpx>=0.27,<1.0", + "anthropic>=0.40,<1.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/quercus_core"] diff --git a/core/src/quercus_core/__init__.py b/core/src/quercus_core/__init__.py new file mode 100644 index 0000000..52c539e --- /dev/null +++ b/core/src/quercus_core/__init__.py @@ -0,0 +1,3 @@ +"""Quercus AI 剪辑助手 — 宿主无关的 AI 核心(P1 范围)。""" + +__version__ = "0.1.0" diff --git a/core/src/quercus_core/agent/__init__.py b/core/src/quercus_core/agent/__init__.py new file mode 100644 index 0000000..81818c8 --- /dev/null +++ b/core/src/quercus_core/agent/__init__.py @@ -0,0 +1,4 @@ +"""Agent 编排:对话 loop、工具派发、确认门、视觉闭环。""" +from quercus_core.agent.loop import AgentLoop, AgentResult, ToolExecutor + +__all__ = ["AgentLoop", "AgentResult", "ToolExecutor"] diff --git a/core/src/quercus_core/agent/loop.py b/core/src/quercus_core/agent/loop.py new file mode 100644 index 0000000..f222908 --- /dev/null +++ b/core/src/quercus_core/agent/loop.py @@ -0,0 +1,132 @@ +"""Agent 编排:同步对话 loop + 工具派发 + 确认门 + 视觉闭环。 + +流程:user 消息 → provider → 有 tool_calls 则整段包成 ActionBatch 进待确认 +清单 → 经注入的 confirm 回调批准后执行变更类工具(只读工具直接执行)→ +工具结果(可含 PNG 帧图)回喂 provider → 直到无 tool_call。 +LLM 看不到事务/快照细节(计划文档 §3.2–3.3)。 +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, Protocol +from uuid import uuid4 + +from quercus_core.providers.base import AssistantTurn, Message, Role, ToolCall, ToolSchema +from quercus_core.session.log import SessionLog +from quercus_core.tools.schemas import TOOLS, tool_read_only +from quercus_core.types import Action, ActionBatch, ToolResult + + +class ToolExecutor(Protocol): + """工具执行协议。P1 不提供真实宿主实现,测试用内存 stub。""" + + def execute(self, action: Action) -> ToolResult: ... + + +@dataclass +class AgentResult: + final_text: str + turns: int + tool_calls_executed: int + + +class AgentLoop: + def __init__( + self, + *, + provider, + executor: ToolExecutor, + confirm: Callable[[ActionBatch], bool] | None = None, + session: SessionLog | None = None, + tool_schemas: list[ToolSchema] | None = None, + max_turns: int = 12, + ) -> None: + self._provider = provider + self._executor = executor + self._confirm = confirm # None => 默认全开(计划文档 P1.3 "默认全开") + self._session = session + self._schemas = list(tool_schemas) if tool_schemas is not None else list(TOOLS.values()) + self._max_turns = max_turns + # 供测试/审计使用 + self.batch_decisions: list[tuple[ActionBatch, bool]] = [] + + def run(self, user_text: str) -> AgentResult: + """执行一轮对话,返回最终助手文本与统计。""" + messages: list[Message] = [Message(role=Role.USER, text=user_text)] + if self._session: + self._session.log_user(user_text) + + executed = 0 + for turn_no in range(1, self._max_turns + 1): + turn = self._provider.generate(messages, tools=self._schemas) + if self._session: + self._session.log_assistant(turn) + if not turn.tool_calls: + messages.append(Message(role=Role.ASSISTANT, text=turn.text)) + return AgentResult(final_text=turn.text, turns=turn_no, tool_calls_executed=executed) + + messages.append( + Message(role=Role.ASSISTANT, text=turn.text, tool_calls=turn.tool_calls) + ) + batch = self._build_batch(turn.tool_calls) + approved = self._approve(batch) + + for action in batch.actions: + result = self._dispatch(action, approved=approved) + if self._session: + self._session.log_tool_result(result) + messages.append( + Message( + role=Role.TOOL, + text=result.summary, + images=result.images, + tool_call_id=action.call_id, + name=action.tool, + ) + ) + executed += 1 + + # 达到 max_turns 仍未结束 + return AgentResult(final_text="", turns=self._max_turns, tool_calls_executed=executed) + + # ---- 内部 ---- + + @staticmethod + def _build_batch(calls: tuple[ToolCall, ...]) -> ActionBatch: + actions = [ + Action(tool=tc.name, params=dict(tc.arguments), call_id=tc.id) for tc in calls + ] + label = "、".join(sorted({a.tool for a in actions})) + return ActionBatch(label=f"AI 动作:{label}", actions=actions, id=uuid4().hex) + + def _approve(self, batch: ActionBatch) -> bool: + """确认门:整段 ActionBatch 经 confirm 回调批准后才执行变更类工具。 + + 纯只读批次无需确认;未注入 confirm 回调时默认全开。 + """ + if all(tool_read_only(a.tool) for a in batch.actions): + approved = True + elif self._confirm is None: + approved = True + else: + approved = bool(self._confirm(batch)) + self.batch_decisions.append((batch, approved)) + if self._session: + self._session.log_batch_decision(batch, approved) + return approved + + def _dispatch(self, action: Action, *, approved: bool) -> ToolResult: + tool = TOOLS.get(action.tool) + if tool is None: + return ToolResult( + tool=action.tool, ok=False, summary=f"未知工具 {action.tool!r}" + ) + if tool.read_only: + return self._executor.execute(action) # 只读工具可直接执行 + if not approved: + return ToolResult( + tool=action.tool, + ok=False, + summary=f"{action.tool} 未执行:动作被用户拒绝(已进入待确认清单)", + ) + return self._executor.execute(action) diff --git a/core/src/quercus_core/config.py b/core/src/quercus_core/config.py new file mode 100644 index 0000000..47f389d --- /dev/null +++ b/core/src/quercus_core/config.py @@ -0,0 +1,126 @@ +"""配置与密钥管理。 + +- 配置文件:``~/.quercus/config.toml``(权限 0600,不入库)。 +- 环境变量仅作 CI/无头覆盖,优先级:环境变量 > 配置文件。 +- 密钥绝不写日志、绝不写任何宿主侧文件(设计铁律 4)。 +- 无 key 时优雅降级:provider 注册表报告 unavailable(见 providers/)。 +""" +from __future__ import annotations + +import json +import os +import tomllib +from dataclasses import dataclass +from pathlib import Path + +# 环境变量名(计划文档 P1 交付项 2)。 +ENV_ANTHROPIC_API_KEY = "QUERCUS_ANTHROPIC_API_KEY" +ENV_OPENAI_API_KEY = "QUERCUS_OPENAI_API_KEY" +ENV_OPENAI_BASE_URL = "QUERCUS_OPENAI_BASE_URL" + +# QUERCUS_HOME 仅用于测试/CI 覆盖 ~ 的解析,与配置密钥无关。 +ENV_QUERCUS_HOME = "QUERCUS_HOME" + +# 配置文件键(与 pyproject 无关,纯用户配置)。 +_KEY_ANTHROPIC = "anthropic_api_key" +_KEY_OPENAI = "openai_api_key" +_KEY_OPENAI_BASE_URL = "openai_base_url" + +_CONFIG_KEYS = (_KEY_ANTHROPIC, _KEY_OPENAI, _KEY_OPENAI_BASE_URL) + +# 环境变量 -> 配置键 的覆盖表(保持唯一来源)。 +_ENV_OVERRIDES = { + ENV_ANTHROPIC_API_KEY: _KEY_ANTHROPIC, + ENV_OPENAI_API_KEY: _KEY_OPENAI, + ENV_OPENAI_BASE_URL: _KEY_OPENAI_BASE_URL, +} + + +@dataclass +class Config: + """合并后的配置;密钥字段可为 None 表示未配置。""" + + anthropic_api_key: str | None = None + openai_api_key: str | None = None + openai_base_url: str | None = None + path: Path | None = None # 来源文件(若存在) + + @property + def has_anthropic_key(self) -> bool: + return bool(self.anthropic_api_key) + + @property + def has_openai_key(self) -> bool: + return bool(self.openai_api_key) + + +def config_home() -> Path: + """``~/.quercus`` 目录(QUERCUS_HOME 仅供测试覆盖 HOME)。""" + base = os.environ.get(ENV_QUERCUS_HOME) or os.path.expanduser("~") + return Path(base) / ".quercus" + + +def default_config_path() -> Path: + return config_home() / "config.toml" + + +def default_sessions_dir() -> Path: + return config_home() / "sessions" + + +def _enforce_0600(path: Path) -> None: + """把文件权限收敛为 0600(读与写都做,自愈)。""" + path.chmod(0o600) + + +def load_config( + path: Path | None = None, env: dict[str, str] | None = None +) -> Config: + """读取配置并叠加环境变量覆盖(环境变量 > 配置文件)。""" + path = Path(path) if path else default_config_path() + cfg = Config(path=path) + if path.exists(): + data = tomllib.loads(path.read_text(encoding="utf-8")) + cfg.anthropic_api_key = data.get(_KEY_ANTHROPIC) + cfg.openai_api_key = data.get(_KEY_OPENAI) + cfg.openai_base_url = data.get(_KEY_OPENAI_BASE_URL) + _enforce_0600(path) + env = os.environ if env is None else env + for env_key, cfg_key in _ENV_OVERRIDES.items(): + value = env.get(env_key) + if value: + setattr(cfg, cfg_key, value) + return cfg + + +def _dump_toml(cfg: Config) -> str: + """最小 TOML 写入器:扁平字符串键;None 值不落盘(读取时缺省为 None)。 + + TOML 无 null 字面量,故跳过未配置的键。 + """ + lines = ["# Quercus 配置(权限 0600)。环境变量可覆盖以下键。"] + for key in _CONFIG_KEYS: + value = getattr(cfg, key) + if value is not None: + lines.append(f"{key} = {json.dumps(value)}") + return "\n".join(lines) + "\n" + + +def save_config(cfg: Config, path: Path | None = None) -> Path: + """写入配置(目录自建、权限 0600)。""" + path = Path(path) if path else default_config_path() + path.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(_dump_toml(cfg)) + _enforce_0600(path) + return path + + +def config_summary(cfg: Config) -> dict[str, str | bool]: + """只报告"配了哪些 provider",绝不泄露密钥值(用于日志/面板状态)。""" + return { + "anthropic_configured": cfg.has_anthropic_key, + "openai_configured": cfg.has_openai_key, + "openai_base_url": cfg.openai_base_url if cfg.openai_base_url else "(默认)", + } diff --git a/core/src/quercus_core/host/__init__.py b/core/src/quercus_core/host/__init__.py new file mode 100644 index 0000000..0660338 --- /dev/null +++ b/core/src/quercus_core/host/__init__.py @@ -0,0 +1,4 @@ +"""HostAdapter 抽象接口(§3.1)。P1 只定义 ABC,不提供实现。""" +from quercus_core.host.adapter import HostAdapter + +__all__ = ["HostAdapter"] diff --git a/core/src/quercus_core/host/adapter.py b/core/src/quercus_core/host/adapter.py new file mode 100644 index 0000000..89f0cd6 --- /dev/null +++ b/core/src/quercus_core/host/adapter.py @@ -0,0 +1,119 @@ +"""HostAdapter 抽象基类(计划文档 §3.1 接口形状)。 + +P1 只定义方法签名与 docstring,不含实现;MockHostAdapter 与真实宿主 +适配层(Resolve / Premiere / Oak)属 P2+。方法族刻意对齐 OPP/1 §8, +使未来 Oak 适配层只是 oakxp SDK 的薄封装。 +""" +from __future__ import annotations + +from abc import ABC, abstractmethod + +from quercus_core.types import ( + ActionBatch, + Capability, + Limits, + PngBytes, + Rational, + Size, + TimeRange, +) +from quercus_core.host.types import ( + BatchResult, + FootageId, + JobHandle, + Levels, + MediaInfo, + PlaybackState, + ProjectOverview, + SeqId, + Target, + Timeline, +) + + +class HostAdapter(ABC): + """core 操作宿主的唯一边界(设计铁律 1:协议/接口是唯一边界)。""" + + # ---- 会话与能力 ---- + + @abstractmethod + def capabilities(self) -> set[Capability]: + """返回宿主能力位集合(如 PROJECT_READ / TIMELINE_EDIT)。""" + + @abstractmethod + def limits(self) -> Limits: + """自报资源预算:取帧分辨率/速率上限、轮询间隔、快照数量上限。""" + + # ---- 工程 / 媒体 / 时间线 ---- + + @abstractmethod + def open_project(self, path: str) -> None: + """打开(或新建)工程。""" + + @abstractmethod + def save_project(self, path: str | None = None) -> None: + """保存工程,可另存到 path。""" + + @abstractmethod + def get_project_overview(self) -> ProjectOverview: + """一次返回工程概览(序列/轨道/块树)。""" + + @abstractmethod + def probe_media(self, path: str) -> MediaInfo: + """探测媒体文件元数据。""" + + @abstractmethod + def import_footage(self, paths: list[str]) -> list[FootageId]: + """导入素材到媒体池,返回不透明 id。""" + + @abstractmethod + def get_timeline_structure(self, seq: SeqId) -> Timeline: + """获取时间线结构。""" + + # ---- 取帧(视觉闭环生死通路) ---- + + @abstractmethod + def get_frame(self, target: Target, time: Rational, max_size: Size) -> PngBytes: + """按时间点取一帧,返回 PNG 字节(core 不解析像素,只流转)。""" + + @abstractmethod + def get_thumbnails( + self, target: Target, range: TimeRange, count: int + ) -> list[PngBytes]: + """在时间范围内等间隔采样多帧(contact sheet 输入)。""" + + @abstractmethod + def get_audio_levels(self, seq: SeqId, range: TimeRange, resolution: int) -> Levels: + """获取时间段内音频电平数据。""" + + # ---- 回放 / 导出 ---- + + @abstractmethod + def play(self, speed: float = 1.0) -> None: + """开始回放(可指定倍速)。""" + + @abstractmethod + def pause(self) -> None: + """暂停回放。""" + + @abstractmethod + def seek(self, time: Rational) -> None: + """移动播放头。""" + + @abstractmethod + def get_state(self) -> PlaybackState: + """读取回放状态(播放中/播放头位置)。""" + + @abstractmethod + def export(self, seq: SeqId, output: str, preset: str) -> JobHandle: + """启动导出任务,进度经回调上报。""" + + # ---- 编辑(ActionBatch 语义,计划文档 §3.3) ---- + + @abstractmethod + def execute(self, batch: ActionBatch) -> BatchResult: + """执行整段 ActionBatch(宿主有事务用事务,无事务用快照+补偿)。""" + + @abstractmethod + def undo_last(self) -> None: + """撤销上一个 AI 动作(仅用户明确要求时)。""" diff --git a/core/src/quercus_core/host/types.py b/core/src/quercus_core/host/types.py new file mode 100644 index 0000000..9c25a8d --- /dev/null +++ b/core/src/quercus_core/host/types.py @@ -0,0 +1,110 @@ +"""HostAdapter 接口依赖的宿主侧领域类型(§3.1)。 + +P1 只定义形状;MockHostAdapter 与真实适配层属 P2/P3+。 +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +from quercus_core.types import EntityId, PngBytes, Rational, TimeRange, ToolResult + +# 素材 / 序列的不透明 id。 +FootageId = EntityId +SeqId = EntityId + + +@dataclass +class ProjectOverview: + """工程概览:一次返回序列/轨道/块树,供 LLM 建立上下文。""" + + project_id: str + name: str + fps: float + timeline_ids: list[SeqId] = field(default_factory=list) + duration: Rational | None = None + + +@dataclass +class MediaInfo: + """媒体探测结果。""" + + path: str + duration: Rational | None = None + width: int | None = None + height: int | None = None + fps: float | None = None + codec: str | None = None + + +@dataclass +class Clip: + """时间线上的一个片段(块)。""" + + id: EntityId + name: str + source: str | None = None + start: Rational = Rational(0) + duration: Rational = Rational(0) + in_point: Rational | None = None + out_point: Rational | None = None + + +@dataclass +class Track: + """时间线轨道。""" + + index: int + kind: str = "video" # video | audio | subtitle + clips: list[Clip] = field(default_factory=list) + + +@dataclass +class Timeline: + """时间线结构。""" + + id: SeqId + name: str + fps: float + duration: Rational = Rational(0) + tracks: list[Track] = field(default_factory=list) + + +@dataclass(frozen=True) +class Target: + """取帧目标:时间线或片段。""" + + kind: str # "timeline" | "clip" + id: str + + +@dataclass +class Levels: + """音频电平数据(等间隔采样,单位 dB)。""" + + values: list[float] = field(default_factory=list) + min_db: float = -60.0 + max_db: float = 0.0 + + +@dataclass(frozen=True) +class JobHandle: + """导出任务句柄;进度经回调上报。""" + + job_id: str + + +@dataclass +class BatchResult: + """ActionBatch 执行结果(编辑侧)。""" + + ok: bool + message: str = "" + results: list[ToolResult] = field(default_factory=list) + + +@dataclass +class PlaybackState: + """回放状态。""" + + playing: bool = False + position: Rational = Rational(0) diff --git a/core/src/quercus_core/providers/__init__.py b/core/src/quercus_core/providers/__init__.py new file mode 100644 index 0000000..5a132c5 --- /dev/null +++ b/core/src/quercus_core/providers/__init__.py @@ -0,0 +1,70 @@ +"""Provider 注册表:报告可用性(无 key 优雅降级)并按名创建 provider。""" +from __future__ import annotations + +from dataclasses import dataclass + +import httpx + +from quercus_core.config import Config +from quercus_core.providers.base import LLMProvider, ProviderUnavailable +from quercus_core.providers.claude import DEFAULT_CLAUDE_MODEL, ClaudeProvider +from quercus_core.providers.openai_compat import ( + DEFAULT_OPENAI_BASE_URL, + DEFAULT_OPENAI_MODEL, + OpenAICompatProvider, +) + +CLAUDE = "claude" +OPENAI_COMPAT = "openai_compat" + + +@dataclass(frozen=True) +class ProviderInfo: + """注册表条目:可用性 + 原因(原因不含密钥值)。""" + + name: str + available: bool + reason: str = "" + model: str = "" + + +def provider_status(config: Config) -> dict[str, ProviderInfo]: + """返回各 provider 的可用状态,供面板/CLI 优雅降级展示。""" + return { + CLAUDE: ProviderInfo( + name=CLAUDE, + available=config.has_anthropic_key, + reason="" if config.has_anthropic_key else f"{config.path or 'config'}: 未配置 QUERCUS_ANTHROPIC_API_KEY", + model=DEFAULT_CLAUDE_MODEL if config.has_anthropic_key else "", + ), + OPENAI_COMPAT: ProviderInfo( + name=OPENAI_COMPAT, + available=config.has_openai_key, + reason="" if config.has_openai_key else "未配置 QUERCUS_OPENAI_API_KEY", + model=DEFAULT_OPENAI_MODEL if config.has_openai_key else "", + ), + } + + +def create_provider( + name: str, + config: Config, + *, + http_client: httpx.Client | None = None, +) -> LLMProvider: + """按名创建 provider;未配置密钥时抛 ProviderUnavailable。""" + if name == CLAUDE: + if not config.has_anthropic_key: + raise ProviderUnavailable("claude: 未配置 QUERCUS_ANTHROPIC_API_KEY") + return ClaudeProvider( + api_key=config.anthropic_api_key or "", http_client=http_client + ) + if name == OPENAI_COMPAT: + if not config.has_openai_key: + raise ProviderUnavailable("openai_compat: 未配置 QUERCUS_OPENAI_API_KEY") + return OpenAICompatProvider( + api_key=config.openai_api_key or "", + base_url=config.openai_base_url or DEFAULT_OPENAI_BASE_URL, + client=http_client, + ) + raise ValueError(f"未知 provider: {name!r}(可选: {CLAUDE}, {OPENAI_COMPAT})") diff --git a/core/src/quercus_core/providers/base.py b/core/src/quercus_core/providers/base.py new file mode 100644 index 0000000..7c7b1ae --- /dev/null +++ b/core/src/quercus_core/providers/base.py @@ -0,0 +1,83 @@ +"""LLMProvider 抽象与消息/工具调用类型。 + +输入:消息(文本 + PNG 图片);输出:文本 + tool_calls(设计文档 §5.1)。 +""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any + +from quercus_core.types import PngBytes + + +class ProviderError(Exception): + """provider 调用失败(网络、协议、限额等)。""" + + +class ProviderUnavailable(ProviderError): + """provider 因缺少密钥等配置不可用。""" + + +# 消息角色常量(避免魔法串)。 +class Role: + USER = "user" + ASSISTANT = "assistant" + TOOL = "tool" + SYSTEM = "system" + + +@dataclass(frozen=True) +class Message: + """对话消息;``role="tool"`` 时经 tool_call_id 关联对应的 tool_call。 + + ``images`` 为 PNG 字节(core 内唯一的图片载体)。 + """ + + role: str + text: str = "" + images: tuple[PngBytes, ...] = () + tool_call_id: str | None = None # role="tool" 时必须指向某次 tool_call + tool_calls: tuple[ToolCall, ...] = () # role="assistant" 时携带已发出的 tool_call + name: str | None = None # 工具名(role="tool" 时) + + +@dataclass(frozen=True) +class ToolCall: + """模型发起的工具调用。""" + + id: str + name: str + arguments: dict[str, Any] = field(default_factory=dict) + + +@dataclass(frozen=True) +class ToolSchema: + """暴露给 LLM 的工具声明(name/description/JSON Schema parameters)。""" + + name: str + description: str + parameters: dict[str, Any] # JSON Schema 对象(有理秒用 {num, den}) + read_only: bool = False # 只读工具可自动执行,变更类进待确认清单 + + +@dataclass(frozen=True) +class AssistantTurn: + """provider 单次生成的回复:文本 + 若干 tool_calls。""" + + text: str = "" + tool_calls: tuple[ToolCall, ...] = () + + +class LLMProvider(ABC): + """LLM 后端统一接口。实现须为同步、可注入 HTTP 客户端(便于测试)。""" + + name: str = "provider" + + @abstractmethod + def generate( + self, + messages: list[Message], + tools: list[ToolSchema] | None = None, + ) -> AssistantTurn: + """给定消息历史(可含 PNG 图片)与工具声明,返回文本与 tool_calls。""" diff --git a/core/src/quercus_core/providers/claude.py b/core/src/quercus_core/providers/claude.py new file mode 100644 index 0000000..d7ea7ed --- /dev/null +++ b/core/src/quercus_core/providers/claude.py @@ -0,0 +1,128 @@ +"""Claude(anthropic SDK)后端:多模态图片消息 + tool use。""" +from __future__ import annotations + +import base64 +from typing import Any + +import httpx + +from quercus_core.providers.base import ( + AssistantTurn, + LLMProvider, + Message, + Role, + ToolCall, + ToolSchema, +) + +DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-5" + +try: + import anthropic +except ImportError: # pragma: no cover - 依赖缺失时给出明确错误 + anthropic = None # type: ignore[assignment] + + +class ClaudeProvider(LLMProvider): + """经 anthropic SDK 调用 Claude Messages API(图片用 base64 image 块)。""" + + name = "claude" + + def __init__( + self, + *, + api_key: str, + model: str = DEFAULT_CLAUDE_MODEL, + max_tokens: int = 4096, + http_client: httpx.Client | None = None, + ) -> None: + if anthropic is None: + raise ImportError("缺少依赖 anthropic,请先 uv pip install anthropic") + self._model = model + self._max_tokens = max_tokens + self._client = anthropic.Anthropic(api_key=api_key, http_client=http_client) + + def generate( + self, + messages: list[Message], + tools: list[ToolSchema] | None = None, + ) -> AssistantTurn: + system = "\n".join(m.text for m in messages if m.role == Role.SYSTEM) + kwargs: dict[str, Any] = { + "model": self._model, + "max_tokens": self._max_tokens, + "messages": self._to_anthropic_messages(messages), + } + if system: + kwargs["system"] = system + if tools: + kwargs["tools"] = [self._to_tool(t) for t in tools] + resp = self._client.messages.create(**kwargs) + text = "".join(b.text for b in resp.content if b.type == "text") + calls = tuple( + ToolCall(id=b.id, name=b.name, arguments=dict(b.input)) + for b in resp.content + if b.type == "tool_use" + ) + return AssistantTurn(text=text, tool_calls=calls) + + # ---- 转换 ---- + + def _to_tool(self, t: ToolSchema) -> dict[str, Any]: + return {"name": t.name, "description": t.description, "input_schema": t.parameters} + + @staticmethod + def _image_block(png: bytes) -> dict[str, Any]: + return { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": base64.b64encode(png).decode("ascii"), + }, + } + + def _to_anthropic_messages(self, messages: list[Message]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + pending_tool_results: list[dict[str, Any]] | None = None + + def flush() -> None: + nonlocal pending_tool_results + if pending_tool_results is not None: + out.append({"role": Role.USER, "content": pending_tool_results}) + pending_tool_results = None + + for m in messages: + if m.role == Role.SYSTEM: + continue + if m.role == Role.TOOL: + if pending_tool_results is None: + pending_tool_results = [] + content: list[dict[str, Any]] = [] + if m.text: + content.append({"type": "text", "text": m.text}) + for img in m.images: + content.append(self._image_block(img)) + pending_tool_results.append( + {"type": "tool_result", "tool_use_id": m.tool_call_id, "content": content} + ) + continue + flush() + if m.role == Role.USER: + blocks: list[dict[str, Any]] = [] + if m.text: + blocks.append({"type": "text", "text": m.text}) + for img in m.images: + blocks.append(self._image_block(img)) + out.append({"role": Role.USER, "content": blocks}) + elif m.role == Role.ASSISTANT: + blocks = [] + if m.text: + blocks.append({"type": "text", "text": m.text}) + for tc in m.tool_calls: + blocks.append( + {"type": "tool_use", "id": tc.id, "name": tc.name, "input": tc.arguments} + ) + out.append({"role": Role.ASSISTANT, "content": blocks}) + flush() + return out diff --git a/core/src/quercus_core/providers/openai_compat.py b/core/src/quercus_core/providers/openai_compat.py new file mode 100644 index 0000000..e453a24 --- /dev/null +++ b/core/src/quercus_core/providers/openai_compat.py @@ -0,0 +1,163 @@ +"""OpenAI 兼容 ``/v1/chat/completions`` 后端。 + +同一套通路覆盖:官方 OpenAI、自定义网关/企业代理、llama.cpp 本地 server +(OpenAI 兼容接口,不直接绑定 llama.cpp C API)。HTTP 层直接用 httpx, +不引入 openai SDK,保持依赖最小。 +""" +from __future__ import annotations + +import base64 +import json +from typing import Any + +import httpx + +from quercus_core.providers.base import ( + AssistantTurn, + LLMProvider, + Message, + ProviderError, + Role, + ToolCall, + ToolSchema, +) + +DEFAULT_OPENAI_MODEL = "gpt-4o" +DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1" + + +class OpenAICompatProvider(LLMProvider): + name = "openai_compat" + + def __init__( + self, + *, + api_key: str, + base_url: str = DEFAULT_OPENAI_BASE_URL, + model: str = DEFAULT_OPENAI_MODEL, + client: httpx.Client | None = None, + ) -> None: + self._api_key = api_key + self._base_url = base_url.rstrip("/") + self._model = model + # 外部注入的 client(如 MockTransport)自带 base_url,勿覆盖其配置。 + self._client = client or httpx.Client(timeout=60.0) + + @property + def endpoint(self) -> str: + return f"{self._base_url}/chat/completions" + + def generate( + self, + messages: list[Message], + tools: list[ToolSchema] | None = None, + ) -> AssistantTurn: + payload: dict[str, Any] = { + "model": self._model, + "messages": self._to_openai_messages(messages), + } + if tools: + payload["tools"] = [ + { + "type": "function", + "function": { + "name": t.name, + "description": t.description, + "parameters": t.parameters, + }, + } + for t in tools + ] + payload["tool_choice"] = "auto" + resp = self._client.post( + self.endpoint, + json=payload, + headers={"Authorization": f"Bearer {self._api_key}"}, + ) + if resp.status_code != 200: + raise ProviderError( + f"OpenAI 兼容端点返回 {resp.status_code}: {resp.text[:300]!r}" + ) + try: + data = resp.json() + choice = data["choices"][0] + message = choice.get("message", {}) + except (KeyError, IndexError, ValueError) as exc: + raise ProviderError(f"无法解析 OpenAI 兼容响应: {exc}") from exc + text = self._extract_text(message.get("content")) + calls = tuple(self._parse_tool_call(tc) for tc in message.get("tool_calls") or []) + return AssistantTurn(text=text, tool_calls=calls) + + # ---- 解析 ---- + + @staticmethod + def _extract_text(content: Any) -> str: + if content is None: + return "" + if isinstance(content, str): + return content + if isinstance(content, list): # 新规范:content 为 content-parts 列表 + parts = [p.get("text", "") for p in content if isinstance(p, dict)] + return "".join(parts) + return str(content) + + @staticmethod + def _parse_tool_call(tc: dict[str, Any]) -> ToolCall: + fn = tc.get("function", {}) or {} + raw = fn.get("arguments") or "{}" + try: + args = json.loads(raw) + except json.JSONDecodeError: + args = {} + return ToolCall(id=tc.get("id", ""), name=fn.get("name", ""), arguments=args) + + # ---- 转换 ---- + + @staticmethod + def _image_url_part(png: bytes) -> dict[str, Any]: + return { + "type": "image_url", + "image_url": { + "url": f"data:image/png;base64,{base64.b64encode(png).decode('ascii')}" + }, + } + + def _to_openai_messages(self, messages: list[Message]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for m in messages: + if m.role == Role.SYSTEM: + out.append({"role": Role.SYSTEM, "content": m.text}) + elif m.role == Role.USER: + parts: list[dict[str, Any]] = [] + if m.text: + parts.append({"type": "text", "text": m.text}) + for img in m.images: + parts.append(self._image_url_part(img)) + out.append({"role": Role.USER, "content": parts}) + elif m.role == Role.ASSISTANT: + msg: dict[str, Any] = {"role": Role.ASSISTANT, "content": m.text or None} + if m.tool_calls: + msg["tool_calls"] = [ + { + "id": tc.id, + "type": "function", + "function": { + "name": tc.name, + "arguments": json.dumps(tc.arguments), + }, + } + for tc in m.tool_calls + ] + out.append(msg) + elif m.role == Role.TOOL: + out.append( + {"role": Role.TOOL, "tool_call_id": m.tool_call_id, "content": m.text} + ) + # OpenAI tool 消息只允许文本;帧图以随后的 user 消息回喂, + # 维持视觉闭环(llama.cpp 等本地端点不支持图片时可忽略此步)。 + if m.images: + parts = [self._image_url_part(img) for img in m.images] + if m.text: + parts.insert(0, {"type": "text", "text": f"工具 {m.name} 返回的帧图"}) + out.append({"role": Role.USER, "content": parts}) + return out diff --git a/core/src/quercus_core/session/__init__.py b/core/src/quercus_core/session/__init__.py new file mode 100644 index 0000000..8f8bbc4 --- /dev/null +++ b/core/src/quercus_core/session/__init__.py @@ -0,0 +1,4 @@ +"""会话状态与回放日志。""" +from quercus_core.session.log import SessionEvent, SessionLog, frame_sha256, replay + +__all__ = ["SessionEvent", "SessionLog", "frame_sha256", "replay"] diff --git a/core/src/quercus_core/session/log.py b/core/src/quercus_core/session/log.py new file mode 100644 index 0000000..08cac30 --- /dev/null +++ b/core/src/quercus_core/session/log.py @@ -0,0 +1,117 @@ +"""会话状态与回放:事件 JSONL 落盘 + ``replay`` 读回(测试夹具同格式)。 + +- 落盘位置:``~/.quercus/sessions/.jsonl``。 +- 记录:user/assistant 消息、tool_call、tool_result 摘要、帧图 sha256 哈希 + (不落图片本体,计划文档 §7.5)。 +- 密钥绝不入日志;本模块只接收摘要文本,不接触配置密钥。 +""" +from __future__ import annotations + +import hashlib +import json +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path + +from quercus_core.config import default_sessions_dir +from quercus_core.providers.base import AssistantTurn +from quercus_core.types import ActionBatch, PngBytes, ToolResult + +_SUMMARY_MAX = 2000 + + +def frame_sha256(png: PngBytes) -> str: + """帧图 sha256 十六进制(仅记录哈希,不落图片本体)。""" + return hashlib.sha256(png).hexdigest() + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +@dataclass +class SessionEvent: + """一条会话事件。payload 为该事件的自有字段。""" + + session_id: str + kind: str + at: str + payload: dict = field(default_factory=dict) + + def to_dict(self) -> dict: + return {"session_id": self.session_id, "kind": self.kind, "at": self.at, **self.payload} + + @classmethod + def from_dict(cls, data: dict) -> SessionEvent: + reserved = {"session_id", "kind", "at"} + payload = {k: v for k, v in data.items() if k not in reserved} + return cls( + session_id=data["session_id"], + kind=data["kind"], + at=data["at"], + payload=payload, + ) + + +class SessionLog: + """追加式 JSONL 会话日志。""" + + def __init__(self, path: Path | str, session_id: str | None = None) -> None: + self.path = Path(path) + self.session_id = session_id or uuid.uuid4().hex + self.path.parent.mkdir(parents=True, exist_ok=True) + + @classmethod + def create_default(cls) -> SessionLog: + """在默认会话目录新建一个会话日志。""" + return cls(default_sessions_dir() / f"{uuid.uuid4().hex}.jsonl") + + # ---- 事件 ---- + + def _append(self, kind: str, **payload) -> None: + event = SessionEvent( + session_id=self.session_id, kind=kind, at=_now(), payload=payload + ) + with open(self.path, "a", encoding="utf-8") as f: + f.write(json.dumps(event.to_dict(), ensure_ascii=False) + "\n") + + def log_user(self, text: str) -> None: + self._append("user_message", text=text) + + def log_assistant(self, turn: AssistantTurn) -> None: + calls = [ + {"id": tc.id, "name": tc.name, "arguments": tc.arguments} + for tc in turn.tool_calls + ] + self._append("assistant_turn", text=turn.text, tool_calls=calls) + + def log_tool_result(self, result: ToolResult) -> None: + self._append( + "tool_result", + tool=result.tool, + ok=result.ok, + summary=result.summary[:_SUMMARY_MAX], + image_hashes=[frame_sha256(img) for img in result.images], + ) + + def log_batch_decision(self, batch: ActionBatch, approved: bool) -> None: + self._append( + "batch_decision", + batch_id=batch.id, + label=batch.label, + approved=approved, + action_count=len(batch.actions), + ) + + +def replay(path: Path | str) -> list[SessionEvent]: + """读回会话日志(每行一条 JSON 事件)。""" + events: list[SessionEvent] = [] + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + events.append(SessionEvent.from_dict(json.loads(line))) + return events diff --git a/core/src/quercus_core/tools/__init__.py b/core/src/quercus_core/tools/__init__.py new file mode 100644 index 0000000..5e0e652 --- /dev/null +++ b/core/src/quercus_core/tools/__init__.py @@ -0,0 +1,4 @@ +"""工具 schema 注册表与只读判定。""" +from quercus_core.tools.schemas import TOOLS, TOOL_LIST, get_tool, tool_read_only + +__all__ = ["TOOLS", "TOOL_LIST", "get_tool", "tool_read_only"] diff --git a/core/src/quercus_core/tools/schemas.py b/core/src/quercus_core/tools/schemas.py new file mode 100644 index 0000000..740f59f --- /dev/null +++ b/core/src/quercus_core/tools/schemas.py @@ -0,0 +1,330 @@ +"""工具 schema 注册表:约 26 个策展工具的 JSON Schema(设计文档 §3 表)。 + +每个工具标注 ``read_only``:只读工具(get_*/probe/list/scan/play/pause/seek) +可自动执行;变更类工具整段进"待确认清单"(计划文档 §3.2)。 +有理秒参数一律用 ``{num, den}`` 对象(决策 D5)。 +""" +from __future__ import annotations + +from quercus_core.providers.base import ToolSchema + +# 有理秒参数的 JSON Schema 片段。 +RATIONAL_SCHEMA = { + "type": "object", + "properties": {"num": {"type": "integer"}, "den": {"type": "integer"}}, + "required": ["num", "den"], + "additionalProperties": False, + "description": "有理秒,num/den", +} + +TIME_RANGE_SCHEMA = { + "type": "object", + "properties": {"start": RATIONAL_SCHEMA, "end": RATIONAL_SCHEMA}, + "required": ["start", "end"], + "additionalProperties": False, + "description": "闭区间时间段(有理秒)", +} + + +def _t( + name: str, + description: str, + parameters: dict, + read_only: bool, +) -> ToolSchema: + return ToolSchema( + name=name, + description=description, + parameters=parameters, + read_only=read_only, + ) + + +def _obj(properties: dict, required: list[str] | None = None) -> dict: + """构造 JSON Schema 对象:type=object + properties + required。""" + schema: dict = { + "type": "object", + "properties": properties, + "additionalProperties": False, + } + if required: + schema["required"] = required + return schema + + +TOOLS: dict[str, ToolSchema] = {} + + +def _reg(tool: ToolSchema) -> None: + TOOLS[tool.name] = tool + + +# ---- 工程 ---- +_reg(_t( + "open_project", + "打开(或新建)一个工程。变更类工具。", + _obj({"path": {"type": "string", "description": "工程路径;省略则为默认工程"}}, ["path"]), + read_only=False, +)) +_reg(_t( + "save_project", + "保存当前工程。变更类工具。", + _obj({"path": {"type": "string", "description": "另存路径(可选)"}}), + read_only=False, +)) +_reg(_t( + "get_project_overview", + "获取工程概览:序列/轨道/块树,供 LLM 建立上下文。只读。", + _obj({}), + read_only=True, +)) + +# ---- 媒体 ---- +_reg(_t( + "probe_media", + "探测媒体文件的时长/分辨率/帧率等信息。只读。", + _obj({"path": {"type": "string"}}, ["path"]), + read_only=True, +)) +_reg(_t( + "import_footage", + "把媒体文件导入工程媒体池。变更类工具。", + _obj({"paths": {"type": "array", "items": {"type": "string"}, "minItems": 1}}, ["paths"]), + read_only=False, +)) +_reg(_t( + "list_footage", + "列出媒体池中的素材。只读。", + _obj({}), + read_only=True, +)) + +# ---- 时间线 ---- +_reg(_t( + "add_track", + "向时间线添加轨道。变更类工具。", + _obj( + { + "type": {"type": "string", "enum": ["video", "audio", "subtitle"]}, + "index": {"type": "integer", "description": "目标轨道序号(可选)"}, + }, + ["type"], + ), + read_only=False, +)) +_reg(_t( + "place_clip", + "把素材放到指定轨道与时间点。变更类工具。", + _obj( + { + "clip_id": {"type": "string"}, + "track_index": {"type": "integer"}, + "time": RATIONAL_SCHEMA, + "in_point": RATIONAL_SCHEMA, + "out_point": RATIONAL_SCHEMA, + }, + ["clip_id", "track_index", "time"], + ), + read_only=False, +)) +_reg(_t( + "split_clip", + "在给定时间点把片段一分为二。变更类工具。", + _obj({"clip_id": {"type": "string"}, "time": RATIONAL_SCHEMA}, ["clip_id", "time"]), + read_only=False, +)) +_reg(_t( + "trim_clip", + "调整片段入点/出点或时长。变更类工具。", + _obj( + { + "clip_id": {"type": "string"}, + "in_point": RATIONAL_SCHEMA, + "out_point": RATIONAL_SCHEMA, + "duration": RATIONAL_SCHEMA, + }, + ["clip_id"], + ), + read_only=False, +)) +_reg(_t( + "move_clip", + "把片段移动到另一轨道/时间点。变更类工具。", + _obj( + { + "clip_id": {"type": "string"}, + "track_index": {"type": "integer"}, + "time": RATIONAL_SCHEMA, + }, + ["clip_id", "track_index", "time"], + ), + read_only=False, +)) +_reg(_t( + "ripple_delete", + "波纹删除片段(删除后闭合空隙)。变更类工具,破坏性操作。", + _obj( + { + "clip_ids": {"type": "array", "items": {"type": "string"}, "minItems": 1}, + "range": TIME_RANGE_SCHEMA, + }, + ["clip_ids"], + ), + read_only=False, +)) +_reg(_t( + "add_transition", + "为片段添加转场。变更类工具。", + _obj( + { + "clip_id": {"type": "string"}, + "transition_type": {"type": "string"}, + "duration": RATIONAL_SCHEMA, + }, + ["clip_id", "transition_type", "duration"], + ), + read_only=False, +)) +_reg(_t( + "add_marker", + "在时间线添加标记(可携带 AI 元数据)。变更类工具。", + _obj( + { + "time": RATIONAL_SCHEMA, + "label": {"type": "string"}, + "color": {"type": "string", "enum": ["red", "orange", "yellow", "green", "blue", "violet"]}, + "custom_data": {"type": "object"}, + }, + ["time"], + ), + read_only=False, +)) + +# ---- 效果 / 关键帧 ---- +_reg(_t( + "add_effect", + "为片段添加效果。变更类工具。", + _obj( + {"clip_id": {"type": "string"}, "effect_type": {"type": "string"}, "preset": {"type": "string"}}, + ["clip_id", "effect_type"], + ), + read_only=False, +)) +_reg(_t( + "set_param", + "设置效果参数(数值/字符串/布尔)。变更类工具。", + _obj( + { + "effect_id": {"type": "string"}, + "param": {"type": "string"}, + "value": {"oneOf": [{"type": "number"}, {"type": "string"}, {"type": "boolean"}]}, + }, + ["effect_id", "param", "value"], + ), + read_only=False, +)) +_reg(_t( + "set_keyframe", + "为效果参数在指定时间点打关键帧。变更类工具。", + _obj( + { + "effect_id": {"type": "string"}, + "param": {"type": "string"}, + "time": RATIONAL_SCHEMA, + "value": {"type": "number"}, + }, + ["effect_id", "param", "time", "value"], + ), + read_only=False, +)) +_reg(_t( + "list_effects", + "列出可用效果类型(或某片段已用效果)。只读。", + _obj({"clip_id": {"type": "string"}, "category": {"type": "string"}}), + read_only=True, +)) + +# ---- 取帧(视觉闭环口) ---- +_reg(_t( + "get_frame", + "按时间点取一帧,返回 PNG 图给模型看图判断。只读。", + _obj( + { + "time": RATIONAL_SCHEMA, + "max_size": { + "type": "object", + "properties": {"width": {"type": "integer"}, "height": {"type": "integer"}}, + "required": ["width", "height"], + "additionalProperties": False, + }, + "target": {"type": "string", "description": "目标对象 id(缺省为当前时间线)"}, + }, + ["time", "max_size"], + ), + read_only=True, +)) +_reg(_t( + "scan_timeline", + "在时间范围等间隔采样 n 帧,拼成 contact sheet 回喂。只读。", + _obj( + {"range": TIME_RANGE_SCHEMA, "count": {"type": "integer", "minimum": 1, "maximum": 64}}, + ["range", "count"], + ), + read_only=True, +)) +_reg(_t( + "get_audio_levels", + "获取时间段内的音频电平数据。只读。", + _obj({"range": TIME_RANGE_SCHEMA, "resolution": {"type": "integer", "minimum": 1}}), + read_only=True, +)) + +# ---- 回放 ---- +_reg(_t( + "play", + "播放(可指定倍速)。只读(不改动编辑内容)。", + _obj({"speed": {"type": "number", "description": "倍速,1.0 为正常"}}), + read_only=True, +)) +_reg(_t( + "pause", + "暂停回放。只读。", + _obj({}), + read_only=True, +)) +_reg(_t( + "seek", + "移动播放头到指定时间。只读。", + _obj({"time": RATIONAL_SCHEMA}, ["time"]), + read_only=True, +)) + +# ---- 导出 / 撤销 ---- +_reg(_t( + "export_render", + "按预设导出渲染。变更类工具(确认类)。", + _obj( + {"output": {"type": "string"}, "preset": {"type": "string"}}, + ["output"], + ), + read_only=False, +)) +_reg(_t( + "undo_last_action", + "撤销上一个 AI 动作(仅用户明确要求时)。变更类工具。", + _obj({}), + read_only=False, +)) + +# 保持注册顺序稳定(列表形态)。 +TOOL_LIST: list[ToolSchema] = list(TOOLS.values()) + + +def get_tool(name: str) -> ToolSchema | None: + return TOOLS.get(name) + + +def tool_read_only(name: str) -> bool: + """变更类工具是否需要确认门。未知工具保守视为变更类。""" + tool = TOOLS.get(name) + return tool.read_only if tool else False diff --git a/core/src/quercus_core/types.py b/core/src/quercus_core/types.py new file mode 100644 index 0000000..9efc40d --- /dev/null +++ b/core/src/quercus_core/types.py @@ -0,0 +1,231 @@ +"""核心类型:有理秒时间模型、不透明 id、能力与限制、ActionBatch。 + +全系统唯一时间表示是有理秒 ``Rational``(num/den),宿主侧的帧号/ticks +一律在适配层换算(计划文档 §2.3 决策 D5)。 +""" +from __future__ import annotations + +import math +from dataclasses import dataclass, field +from fractions import Fraction +from typing import Any, NewType + +# 帧图在 core 内始终以 PNG 字节流转;core 不依赖 Pillow。 +PngBytes = NewType("PngBytes", bytes) + +# 不透明字符串 id(对齐 OPP/1 §5),适配层维护 id ↔ 宿主对象映射。 +EntityId = NewType("EntityId", str) + + +@dataclass(frozen=True, eq=True) +class Rational: + """有理秒。构造后自动约分并保证分母为正,作为字典键安全。 + + 排序按数值(num1*den2 交叉相乘),非字段字典序。 + """ + + num: int + den: int = 1 + + def __post_init__(self) -> None: + if self.den == 0: + raise ValueError("Rational 分母不能为 0") + if self.num == 0: + object.__setattr__(self, "den", 1) + return + if self.den < 0: + object.__setattr__(self, "num", -self.num) + object.__setattr__(self, "den", -self.den) + g = math.gcd(abs(self.num), self.den) + if g > 1: + object.__setattr__(self, "num", self.num // g) + object.__setattr__(self, "den", self.den // g) + + @classmethod + def from_float(cls, seconds: float) -> Rational: + """从浮点秒构造(有限十进制即可精确)。""" + f = Fraction(seconds).limit_denominator(1_000_000) + return cls(f.numerator, f.denominator) + + def to_float(self) -> float: + return self.num / self.den + + def __lt__(self, other: object) -> bool: + """按数值比较(num1*den2 < num2*den1)。""" + if not isinstance(other, Rational): + return NotImplemented + return self.num * other.den < other.num * self.den + + def __le__(self, other: object) -> bool: + """按数值比较(<=)。``>=``/``>`` 经反射由 ``__le__``/``__lt__`` 派生。""" + if not isinstance(other, Rational): + return NotImplemented + return self.num * other.den <= other.num * self.den + + def __add__(self, other: Rational) -> Rational: + return Rational( + self.num * other.den + other.num * self.den, self.den * other.den + ) + + def __sub__(self, other: Rational) -> Rational: + return Rational( + self.num * other.den - other.num * self.den, self.den * other.den + ) + + def __mul__(self, other: Rational) -> Rational: + return Rational(self.num * other.num, self.den * other.den) + + def __truediv__(self, other: Rational) -> Rational: + if other.num == 0: + raise ZeroDivisionError("Rational 不能除以 0") + return Rational(self.num * other.den, self.den * other.num) + + def __repr__(self) -> str: # noqa: D105 - 简短调试表示 + return f"Rational({self.num}, {self.den})" + + def to_json(self) -> dict[str, int]: + """序列化为 ``{num, den}``(工具 schema 与日志共用此形状)。""" + return {"num": self.num, "den": self.den} + + @classmethod + def from_json(cls, data: dict[str, Any] | Any) -> Rational: + if isinstance(data, Rational): + return data + if not isinstance(data, dict) or "num" not in data or "den" not in data: + raise ValueError(f"无法从 {data!r} 解析 Rational") + return cls(int(data["num"]), int(data["den"])) + + +@dataclass(frozen=True, order=True) +class TimeRange: + """闭区间 [start, end] 的有理秒时间段。""" + + start: Rational + end: Rational + + def __post_init__(self) -> None: + if self.end < self.start: + raise ValueError(f"TimeRange 起点不得晚于终点: {self.start} > {self.end}") + + @property + def duration(self) -> Rational: + return self.end - self.start + + def contains(self, t: Rational) -> bool: + return self.start <= t <= self.end + + def to_json(self) -> dict[str, dict[str, int]]: + return {"start": self.start.to_json(), "end": self.end.to_json()} + + @classmethod + def from_json(cls, data: dict[str, Any]) -> TimeRange: + return cls(Rational.from_json(data["start"]), Rational.from_json(data["end"])) + + +@dataclass(frozen=True, order=True) +class Size: + """像素尺寸。""" + + width: int + height: int + + def __post_init__(self) -> None: + if self.width <= 0 or self.height <= 0: + raise ValueError(f"Size 必须为正: {self}") + + @property + def max_side(self) -> int: + return max(self.width, self.height) + + def to_json(self) -> dict[str, int]: + return {"width": self.width, "height": self.height} + + @classmethod + def from_json(cls, data: dict[str, Any]) -> Size: + return cls(int(data["width"]), int(data["height"])) + + +# 能力位(对齐设计文档 §5.2 的最小必要集)。 +Capability = NewType("Capability", str) + + +class Capabilities: + """能力位常量(字符串即 JSON-RPC 能力名,避免魔法串)。""" + + PROJECT_READ = Capability("project.read") + MEDIA_READ = Capability("media.read") + MEDIA_IMPORT = Capability("media.import") + TIMELINE_READ = Capability("timeline.read") + TIMELINE_EDIT = Capability("timeline.edit") + NODE_READ = Capability("node.read") + NODE_EDIT = Capability("node.edit") + RENDER_FRAME = Capability("render.frame") + PLAYBACK = Capability("playback") + EXPORT = Capability("export") + + +@dataclass(frozen=True) +class Limits: + """宿主自报的资源预算,core 侧据此限流(计划文档 §3.1 / §6 约束 4)。""" + + max_frame_width: int = 1920 + max_frame_height: int = 1080 + max_frame_rate: float = 8.0 # 每秒取帧上限 + max_scan_frames: int = 64 # 单次 scan_timeline 的采样数上限 + max_snapshot_count: int = 16 # 快照时间线数量上限 + poll_interval_seconds: float = 0.5 # 宿主无事件源时适配层轮询间隔 + + def to_json(self) -> dict[str, Any]: + return { + "max_frame_width": self.max_frame_width, + "max_frame_height": self.max_frame_height, + "max_frame_rate": self.max_frame_rate, + "max_scan_frames": self.max_scan_frames, + "max_snapshot_count": self.max_snapshot_count, + "poll_interval_seconds": self.poll_interval_seconds, + } + + +@dataclass(frozen=True) +class Action: + """单次工具调用(一次 LLM 动作的组成部分)。""" + + tool: str + params: dict[str, Any] = field(default_factory=dict) + call_id: str = "" # provider 返回的 tool_call id,用于结果回喂 + + def to_json(self) -> dict[str, Any]: + return {"tool": self.tool, "params": self.params, "call_id": self.call_id} + + +@dataclass +class ActionBatch: + """一次 LLM 动作 = 一组工具调用 + 标签。 + + 整段先入"待确认清单",经确认回调批准后才执行变更类工具; + LLM 看不到任何事务/快照细节(计划文档 §3.2–3.3)。 + """ + + label: str + actions: list[Action] + id: str = "" # 由调用方(loop)分配 + created_at: str = "" # ISO 时间戳 + + def to_json(self) -> dict[str, Any]: + return { + "label": self.label, + "actions": [a.to_json() for a in self.actions], + "id": self.id, + "created_at": self.created_at, + } + + +@dataclass(frozen=True) +class ToolResult: + """工具执行结果:给 LLM 的文本摘要 + 可选的 PNG 帧图(回喂多模态)。""" + + tool: str + ok: bool + summary: str + images: tuple[PngBytes, ...] = () + error: str | None = None diff --git a/docs/ai-agent-design.md b/docs/ai-agent-design.md new file mode 100644 index 0000000..983aa7f --- /dev/null +++ b/docs/ai-agent-design.md @@ -0,0 +1,254 @@ +# AI Agent 插件设计(基于 OPP/1 外部插件系统) + +> 本文是 Oak 引入 AI 能力的长期设计,**已按外部插件系统重写**(旧版假设 +> RIIR 拆分后面对一堆 C ABI 小库、引擎内置 `oak-mcp-server`,已作废)。 +> AI 能力现在是一个**外部功能插件**:独立进程、经 OPP/1 协议操作 Oak。 +> +> 依赖文档(冲突时以它们为准): +> - [`external-plugin-system.md`](external-plugin-system.md)——插件系统总体设计 +> (进程模型、能力位、确认模式、里程碑 P1–P6) +> - [`external-plugin-protocol.md`](external-plugin-protocol.md)——**OPP/1 协议全文** +> (方法/事件/事务/shm/UI 的冻结定义,本文引用的 §n 均指该文档) +> +> **一句话**:多模态 LLM 跑在一个独立插件进程里,经 OPP/1 的策展宿主 API +> 操作 Oak(事务化编辑、确认后执行),经 `render.get_frame/get_thumbnails` +> 的 shm 取帧通路把画面回喂模型,形成"编辑 → 看图 → 再编辑"的视觉闭环。 + +--- + +## 1. 定位与前提 + +### 1.1 前提(未满足不动工) + +- 插件系统 **P1–P3 完成**:传输与生命周期、宿主 API 核心(事务 + + project/media/timeline/node 方法族)、取帧与导出的 shm 数据面可用。 +- AI 面板需要 **P4**(声明式 UI);无头模式(脚本/CI)只依赖 P1–P3。 +- Python SDK `oakxp`(P6 的一部分)是本插件的载体,二者同期开发、互为验证。 + +### 1.2 设计铁律(继承旧版,按插件系统重述) + +1. AI Agent **只经 OPP/1** 访问 Oak——插件进程内一行 Oak 代码都没有, + 不链接任何 Oak 产物(铁律:协议是唯一边界)。 +2. 一切编辑动作**必须包在 `edit.begin/commit` 事务里**(协议 §6), + 历史面板一次 Ctrl-Z 整段撤销;UI 默认"确认后执行"。 +3. 不为 AI 发明新的引擎内部机制;Agent 工具面是 OPP/1 方法的组合, + OPP/1 方法又是现有 `graphops/renderops/oak_task` 的组合。 +4. API key 绝不写入 **Oak 侧**的任何文件(`.ove` 工程、Oak 自身配置); + 允许存**插件自己的**配置文件(§5.1),环境变量仅作 CI/无头场景的覆盖项。 + +## 2. 总体架构 + +### 2.1 插件进程内部分层 + +插件名 **`oak-plugin-ai`**(Python 3,实现语言与发行形态的论证见 §2.3; +参考实现即插件系统的 `examples/plugin-roughcut` 的完整版): + +``` +┌─ Oak 主进程 ────────────────────────────────┐ +│ oak-plugin-host(P1–P4 提供) │ +│ ├ OPP/1 控制面(JSON-RPC over stdio) │ +│ └ shm down/up 区域 │ +└───────┬─────────────────────────────────────┘ + │ stdio + shm +┌───────┴─── oak-plugin-ai(独立进程)────────┐ +│ ⑤ LLMProvider:Claude/GPT │ llama.cpp 本地 │ +│ ④ Agent 编排:对话 loop、工具调用、视觉闭环 │ +│ ③ 工具适配层:OPP 方法 → LLM tool schema │ +│ ② 会话状态:事务令牌、帧缓存、操作日志 │ +│ ① oakxp SDK:分帧/收发/shm attach/帧→PNG │ +└─────────────────────────────────────────────┘ +``` + +- 插件可以**纯无头**跑(CI、批处理脚本:spawn 后不注册面板,只走工具面); + 注册面板时才是用户可见的"AI 剪辑助手"。 +- **MCP 的位置**:如要让外部 LLM 客户端(Claude Desktop、agent 框架)直连, + 由**插件自己**在进程内起一个 MCP server,把 §3 的工具面按 MCP 再暴露一次。 + Oak 内核始终对 AI、对 MCP 无感知——这是与旧版"引擎内置 oak-mcp-server" + 的根本区别。 + +### 2.2 视觉闭环(本设计的核心) + +``` +多模态 LLM ──► Agent 编排 ──► OPP 事务化编辑 ──► render.get_frame ──► PNG ──► 回喂 LLM + ▲ │ + └────────────────── 看图判断(效果/切点/内容定位) ◄─────────────────────────┘ +``` + +- **验证式**:`edit.commit` 后立即 `render.get_frame` 取切口/效果帧,LLM 判断 + "效果对不对",不对则 `edit.undo` 或追加修正事务。 +- **内容感知式**:`render.get_thumbnails(range, count≤64)` 等间隔采样拼 + contact sheet,LLM 扫图定位("人何时进画面""哪里该切"),据此下刀—— + 自动粗剪/打点的雏形。 +- **连续回放**:`playback.play` + `playback.playhead_moved` 事件(≤30 Hz)+ + 按间隔 `get_frame` 采样。注意受 §12 限流约束(默认 8 帧/s),采样间隔 + 不得小于限流周期。 +- 完整报文流水示例见协议文档 §14(握手→缩略图→事务下刀→验证帧→事件), + 可直接作为本插件的集成测试夹具。 + +### 2.3 实现语言与跨宿主复用(决策已定) + +**定为 Python,解释器嵌入式发行**:插件包内含私有 CPython(PyInstaller 或 +python-build-standalone),`.oakplugin` 单包交付,用户无需自行安装 Python。 +插件以 **GPLv3** 开源发布;作为独立进程经 stdio/JSON-RPC 与 Oak 通信, +许可证选择不影响 Oak 本体。 + +选 Python 而非 Rust 的理由: + +1. **性能无关**:插件只做协议编解码、LLM 编排、PNG 编码(Pillow 为 C 实现); + 延迟大头是 LLM 往返(秒级)与 Oak 侧渲染(与插件语言无关),插件进程 + 没有任何重计算。 +2. **跨宿主复用**:同一套 AI 能力规划覆盖 Oak / DaVinci Resolve / Premiere, + 但三家宿主的扩展 API 语言各异——Resolve 是 Python/Lua;**Premiere 没有 + Python 入口**(UXP/JavaScript 面板 + C++ SDK)。因此正确结构不是 + "一种语言通吃",而是**一份与宿主无关的 AI core + 各宿主薄适配层**: + +``` +┌─ AI core(宿主无关,一份代码)───────────────┐ +│ Agent 编排 / LLMProvider / tool schema │ +│ 提示词策略 / 会话状态 │ +└──────┬───────────┬──────────────┬───────────┘ + Oak 适配层 Resolve 适配层 Premiere 适配层 + (OPP/1, (Resolve (UXP/JS 面板 → + oakxp SDK) Python API, localhost 调 + 直接 import) core 本地服务) +``` + + - Oak 适配层 = oakxp SDK(OPP/1),即本文的 `oak-plugin-ai`; + - Resolve 适配层直接 import core(Python 母语,零成本复用); + - Premiere 用 UXP 面板做壳,经 localhost 与本机 core 进程通信。 +3. **生态**:主流 LLM SDK(anthropic / openai / llama.cpp 绑定)均为 Python + 一等公民,Agent 框架与评测工具链也最全。 + +## 3. 工具面(LLM tool schema → OPP/1 方法映射) + +Agent 暴露给 LLM 的是约 20 个**策展工具**,每个是 OPP/1 方法的薄组合 +(协议 §8 是方法全文,下表"OPP 方法"列即最终调用): + +| Agent 工具 | OPP 方法 | 说明 | +|---|---|---| +| `open_project` / `save_project` | `project.open/save` | 工程生命周期(事务 + 确认类) | +| `get_project_overview` | `project.get_info` + `timeline.get_structure` | 一次返回序列/轨道/块树,供 LLM 建立上下文 | +| `probe_media` / `import_footage` / `list_footage` | `media.probe/import_footage/list_footage` | 媒体探测与导入 | +| `add_track` / `place_clip` / `split_clip` / `trim_clip` / `move_clip` / `ripple_delete` / `add_transition` / `add_marker` | `timeline.*` | 时间线编辑,全部在事务内 | +| `add_effect` / `set_param` / `set_keyframe` / `list_effects` | `node.add_effect/set_param/set_keyframe/list_types` + `get_params` | 效果与关键帧;`get_params` 的 min/max/choices 回填进 tool schema,约束 LLM 出参 | +| `get_frame(time)` / `scan_timeline(range,n)` / `get_audio_levels` | `render.get_frame/get_thumbnails/get_audio_levels` | **视觉闭环取帧口** | +| `play` / `pause` / `seek` | `playback.*` | 回放控制 | +| `export_render(preset)` | `export.start` + `export.progress/done` 事件 | 导出(确认类) | +| `undo_last_action` | `edit.undo` | 仅用户明确要求时调用(协议 §6 规则 5) | + +**事务编排是适配层的职责,不暴露给 LLM**:LLM 的一次"动作"(可能含多个 +`timeline.*` 调用)由适配层包成一个事务——先 `edit.begin(label=LLM 动作摘要)`, +串行执行(协议保证同事务内按到达顺序),任一失败则 `edit.abort` 并把错误 +回喂 LLM,全成功才 `commit`。LLM 看不到 `txn` 令牌,从根上避免"忘记 commit" +"嵌套事务"这类误用。 + +**取帧→PNG 通路**(关键路径):`render.get_frame` 返回 `FrameRef`(shm 形态: +`bgra8` + 槽位号),SDK `frame.to_png()`(Pillow)编码 → base64 → 作为图片 +消息发给 LLM;随后**立即 `shm.release`**——批量扫描时必须流水线化释放, +否则 8 槽耗尽触发 `SHM_EXHAUSTED`(协议 §10.3)。小图(≤64 KiB)Oak 可能 +直接 inline PNG,SDK 对两种形态透明。 + +## 4. AI 面板(声明式 UI,协议 §11.1) + +面板在 `session.hello.panels` 声明 `"ui":"declarative"`,控件树: + +``` +column +├── chat_log #log 对话与操作日志(用户/助手/系统三角色) +├── list #pending 待确认动作清单(确认模式,见 §5) +├── row +│ ├── text_input #prompt 剪辑意图输入 +│ └── button #send 执行 +└── progress #job 扫描/导出进度 +``` + +- 交互经 `ui.event` 上行(`submit`/`click`/`select`),插件用 + `ui.set_props` 增量追加 `chat_log` 条目、更新进度。 +- **"撤销整段会话"**:面板放一个按钮,逐个 `edit.undo` 回滚本会话提交的 + 事务(插件在自己的会话状态里记事务顺序)。 +- 面板被关闭会收到 `panel_closed`,重开收到 `panel_shown` 时重发 + `ui.set_tree` 恢复(协议 §11.1)。 +- 像素面 UI(§11.2)本插件**不用**——聊天面板声明式足够。 + +## 5. 模型层与安全 + +### 5.1 LLMProvider + +抽象接口(输入:消息 + 图片;输出:文本 + tool_calls),后端: + +- **云端 BYOK**:Claude / GPT 多模态,用户自带 key(效果优先)。 +- **本地**:llama.cpp 跑 Qwen-VL / LLaVA 类多模态模型(隐私、离线优先)。 +- **自定义 endpoint**(OpenAI 兼容接口):把 provider 指向任何兼容 + `/v1/chat/completions` 的服务(自建代理、企业网关等)——为后续接入 + 托管服务预留通用通路,不绑定特定厂商。 + +API key 存**插件自己的配置文件**(如 `~/.oak/plugins/oak-plugin-ai/config.toml`), +面板提供密钥输入框(`text_input`),用户无需手配环境变量;环境变量只作 +CI/无头场景的覆盖项(优先级:环境变量 > 配置文件)。配置文件权限 0600、 +不进版本库。存插件自己的配置是插件的内部事务,Oak 不感知——铁律 4 约束的 +只是 **Oak 侧**的文件。无 key 时优雅降级:面板仍可用,但只做"工具说明 + +手动执行",不做对话编排。 + +### 5.2 能力位与确认(协议 §7 的具体化) + +manifest 声明**最小必要集**: + +```toml +capabilities = ["project.read", "media.read", "media.import", + "timeline.read", "timeline.edit", "node.read", "node.edit", + "render.frame", "playback", "export", "ui.panel"] +``` + +双层确认,职责分清: + +1. **Oak 协议层**(§7.3):`timeline.edit` 等确认类方法默认弹窗 + "插件 oak-plugin-ai 请求:split_clip …"。用户可选"本会话内允许"。 +2. **插件会话层**:Agent 把 LLM 规划出的整段动作先列入 `#pending` 清单, + 用户点"执行"才发事务——这是体验层确认,与协议层弹窗不冲突: + 建议插件引导用户在 Oak 侧对本插件设"本会话允许",确认交互集中在面板内。 + +### 5.3 其他安全约束 + +- **限流遵守**:扫描采样按握手 `limits` 下发的配额规划(默认 8 帧/s、 + 短边 ≤1080),收到 `RATE_LIMITED` 按 `retry_after_ms` 退避,不得重试轰炸。 +- **沙箱会话**(可选增强):对破坏性大改,Agent 可先 `project.save` 副本到 + 临时路径操作,用户接受后再回真实工程。有了事务 + 整段撤销后,此项降为 + 可选,默认不启用。 +- 插件崩溃不丢编辑:已 commit 的事务都在 UndoStack 里,未决事务 Oak 自动 + abort(协议 §6 规则 4)——AI 死在哪都不会留下半截剪辑。 + +## 6. 可测试(与项目风格一致) + +1. **Mock LLM server**:录制/回放 tool_call 序列与固定回复,Agent loop 在 + CI 无 key 无网络跑通。 +2. **Mock Oak(协议级)**:`oakxp` SDK 自带回放 harness——把协议文档 §14 的 + 报文流水当夹具,插件不连真 Oak 也能单测工具适配层与事务编排。 +3. **黄金帧校验**:连真 Oak 的端到端测试复用 render-worker harness + (真实渲染 ≥2 帧 + 像素非全黑 + 一致性断言),验证"Agent 的编辑确实 + 改变了画面"。 +4. **会话回放**:tool_call + 帧哈希落盘日志,可回放复现、可作测试夹具。 + +## 7. 里程碑(对齐插件系统 P1–P6) + +| 里程碑 | 内容 | 依赖 | 验收 | +|---|---|---|---| +| **A1 骨架** | `oakxp` SDK + 握手/心跳/重连;插件注册空面板 | P1、P4 | 杀掉插件 Oak 不崩、面板徽标与重启正常 | +| **A2 工具适配层** | §3 全表映射 + 事务编排(自动 begin/commit/abort)+ 取帧→PNG 通路 | P2、P3 | Mock Oak 夹具全绿;真 Oak 上"导入→铺轨→切开→删除→加效果"可整段撤销 | +| **A3 无头闭环** | Agent 编排 + LLMProvider + Mock LLM | A2 | CI 无网络跑通"LLM→工具→取帧→回喂";黄金帧校验过 | +| **A4 AI 面板** | §4 面板 + 待确认清单 + 撤销会话 | A3 | 真机对话粗剪一段素材,确认/撤销交互完整 | +| **A5 内容感知** | contact sheet 扫描打点、自动粗剪策略、本地模型 provider | A4 | 对 10 分钟素材自动出粗剪版,人工抽检切点可用 | + +## 8. 风险与边界(明确不做) + +- **不**把 LLM/推理放进任何 Oak 进程或引擎模块(引擎对 AI 无感知); + MCP server 如需存在,只在插件进程内。 +- **不**绕过 OPP/1 访问 Oak(协议是唯一边界);LLM 不直接接触 `txn` + 令牌(适配层封装事务)。 +- **不**把 API key 写入 Oak 工程文件或 Oak 自身配置(插件自己的配置文件 + 除外,见 §5.1)。 +- **不**让取帧回路阻塞 Oak GUI 线程——`render.*` 本来就走引擎 ticket/进程池 + 路径(协议 §8.6),插件侧并发流水线化而不是串行等帧。 +- **不**突破协议限流;需要更高帧率的"连续回放分析"场景,先按 §13 版本 + 演进规则给协议加配额项,不在插件侧硬挤。 +- 第三方大模型客户端的接入细节(OAuth、计费、配额)**超出本文范围**, + 按需另立文档。 diff --git a/docs/ai-agent-phased-plan.md b/docs/ai-agent-phased-plan.md new file mode 100644 index 0000000..f0303ce --- /dev/null +++ b/docs/ai-agent-phased-plan.md @@ -0,0 +1,380 @@ +# Quercus AI 剪辑助手 — 分阶段实施计划 + +> 本文是 [`ai-agent-design.md`](ai-agent-design.md)(设计)的**施工计划**(怎么做、 +> 分几步、每步验收什么)。设计决策以设计文档为准,本文不重复论证,只把 +> "一份宿主无关 AI core + 各宿主薄适配层"的结构落成可执行的阶段计划。 +> +> **范围调整(相对设计文档)**: +> - 设计文档的落地形态原是 Oak 插件(`oak-plugin-ai`,依赖 OPP/1 与插件系统 +> P1–P4)。**Oak 插件系统仍在开发中**(见 `../../oak/docs/zh/plans/` 下的 +> `external-plugin-system.md` / `external-plugin-protocol.md`),因此本计划 +> **只做 DaVinci Resolve 与 Premiere Pro 两个宿主**;Oak 适配层**只预留接口 +> (stub 包),不实现、不排期**,等 OPP/1 P1–P3 落地后按 §8 启动。 +> - 设计文档中"工具面 = OPP/1 方法映射"的表述,在本计划中改为"工具面 = +> HostAdapter 接口映射"——接口形状**刻意对齐 OPP/1 方法族**,使未来的 Oak +> 适配层只是 oakxp SDK 的薄封装(§3.4)。 +> +> 外部参考(实现时以官方最新文档为准): +> - Resolve 脚本 API:(X-Raym 整理, +> 源自 Resolve 安装目录 `Developer/Scripting/README.txt`) +> - Premiere UXP 示例: + +--- + +## 1. 目标与非目标 + +### 1.1 目标 + +1. **quercus-core**:宿主无关的 Python AI 核心——LLMProvider 抽象、Agent 编排 + (对话 loop + 工具调用 + 视觉闭环)、约 20 个策展工具的 schema、会话状态与 + 回放日志、密钥管理。 +2. **Resolve 适配层**(第一优先级):Python 母语直连 Resolve 脚本 API,支持 + 无头(`-nogui`)跑 CI,完整实现工具面与取帧闭环。 +3. **Premiere 适配层**:UXP 面板(TypeScript)+ localhost 桥接 core 本地服务, + 实现工具面的 Premiere 可用子集,能力缺口显式降级而非伪造。 +4. **统一交互**:core 内建本地 Web 聊天面板(Resolve / 无头 / 调试通用); + Premiere 用 UXP 原生面板。两层确认(会话层"待确认清单"为主)。 +5. **Oak 预留**:`quercus-oak` stub 包,实现 HostAdapter 接口签名 + OPP/1 + 映射注释 + manifest 草案,所有方法 `raise NotImplementedError`。 + +### 1.2 非目标(明确不做) + +- **不**实现 Oak 适配层(等 OPP/1 P1–P3,见 §8)。 +- **不**把 LLM 推理放进任何宿主进程;core 永远是独立进程。 +- **不**做 MCP server 对外暴露(设计文档 §2.1 已定位:需要时由 core 自起, + 本计划不排期)。 +- **不**支持 Resolve 免费版的 UI 集成(v19.1 起免费版移除 UIManager; + 免费版能否外部脚本调用在 P0 验证,若不可行则 Resolve 侧仅支持 Studio)。 +- **不**为宿主伪造原子事务:Resolve / Premiere 无事务 API,用快照 + 补偿 + 实现"尽力回滚"(§3.3),对用户诚实标注。 +- **不**在 Premiere 侧使用 ExtendScript / QE DOM 作为正式通路(EOL 风险; + 仅允许 P0 spike 里做能力对照)。 + +## 2. 仓库结构与进程拓扑 + +### 2.1 仓库布局(monorepo) + +``` +quercus/ +├── docs/ # 设计 + 本计划 +├── core/ # quercus-core(PyPI 风格包,宿主零依赖) +│ ├── providers/ # claude / openai_compat / llamacpp +│ ├── tools/ # ~20 个工具 schema + 事务编排 +│ ├── agent/ # 对话 loop、视觉闭环、待确认清单 +│ ├── session/ # 会话状态、ActionBatch 记录、回放日志 +│ ├── host/ # HostAdapter ABC + 类型(§3.1) +│ └── webui/ # 本地 Web 聊天面板(FastAPI + 静态页) +├── adapters/ +│ ├── resolve/ # quercus-resolve(Python,直连脚本 API) +│ ├── premiere/ +│ │ ├── bridge/ # core 侧:WebSocket server + 协议编解码 +│ │ └── panel/ # UXP 面板(TS + Vite,结构对齐 Adobe 官方示例) +│ └── oak/ # quercus-oak(stub,仅接口 + 映射注释) +├── tests/ +│ ├── mock_llm/ # 录制/回放 LLM server +│ ├── mock_host/ # HostAdapter 内存实现(行为对齐 OPP/1 语义) +│ └── e2e/ # Resolve -nogui 黄金帧测试;Premiere 手工清单 +└── pyproject.toml # workspace(uv/pdm 均可,实现时定) +``` + +### 2.2 进程拓扑 + +``` +┌─ DaVinci Resolve ─┐ ┌─ Premiere Pro ────────┐ ┌─ Oak(预留)─┐ +│ 脚本 API(进程内) │ │ UXP 面板(Chromium) │ │ oak-plugin- │ +└───────┬───────────┘ └──────────┬────────────┘ │ host (OPP/1) │ + │ fusionscript │ WebSocket └──────┬───────┘ + │ (同机直连) │ localhost+token │ stdio+shm +┌───────┴───────────┐ ┌──────────┴────────────┐ ┌──────┴───────┐ +│ quercus-resolve │ │ quercus-premiere │ │ quercus-oak │ +│ (core 的进程内 │ │ bridge(core 进程内 │ │ (stub) │ +│ adapter 对象) │ │ WebSocket server) │ │ │ +└───────┬───────────┘ └──────────┬────────────┘ └──────────────┘ + └──────────► quercus-core ◄┘ + (Agent / LLM / Web UI / 会话) +``` + +- **Resolve**:core 进程 import `DaVinciResolveScript`,adapter 是 core 进程内的 + 一个对象(Resolve 要求宿主在跑;core 直连,无中间进程)。也可反向:Resolve + 脚本菜单拉起 core——安装形态在 P3 定,默认**core 为主动方**(便于无头与 CI)。 +- **Premiere**:UXP 无法 import Python,core 在 localhost 起 WebSocket server + (随机端口 + 启动时写 token 文件到 `~/.quercus/bridge.json`,权限 0600; + 面板连接须带 token)。面板只做 UI 与宿主 API 调用,Agent 逻辑全在 core。 +- **Oak**:未来 core 进程内 import `oakxp`,同 Resolve 形态;本计划只留 stub。 + +### 2.3 关键决策(本计划新增,设计文档未覆盖的) + +| # | 决策 | 理由 | +|---|---|---| +| D1 | 面板主形态 = core 内建本地 Web UI;Premiere 另有 UXP 原生面板 | Resolve 免费版无 UI 能力、无头场景无 UI;Web UI 一份代码三处复用 | +| D2 | Premiere 桥用 WebSocket localhost + token 文件鉴权 | UXP 网络能力需 manifest 声明域名,localhost 可用;token 防同机其他进程误连 | +| D3 | 事务语义统一为 `ActionBatch`:宿主有事务用事务,无事务用快照 + 补偿 | Resolve/Premiere 均无事务/撤销分组 API,诚实降级(§3.3) | +| D4 | 取帧通路每宿主一条主通路 + 一条备用,P0 先验证再定主备 | 三家取帧能力差异最大,是整个视觉闭环的生死点(§5 表) | +| D5 | 内部时间模型统一有理秒(对齐 OPP/1 `Rational`) | 避免 Resolve 帧号 / Premiere ticks 渗透进 core;换算全部在适配层 | + +## 3. 统一抽象:HostAdapter 接口 + +### 3.1 接口形状(刻意对齐 OPP/1 §8 方法族) + +```python +# core/host/adapter.py(示意,非最终实现) +class HostAdapter(ABC): + # 会话与能力 + def capabilities(self) -> set[Capability]: ... + def limits(self) -> Limits: ... # 取帧速率/分辨率上限等,各宿主自报 + # 工程 / 媒体 / 时间线 / 节点(方法族与 OPP/1 §8.2–8.5 一一对应) + def get_project_overview(self) -> ProjectOverview: ... + def probe_media(self, path: str) -> MediaInfo: ... + def import_footage(self, paths: list[str]) -> list[FootageId]: ... + def get_timeline_structure(self, seq: SeqId) -> Timeline: ... + # 取帧(视觉闭环生死通路) + def get_frame(self, target: Target, time: Rational, max_size: Size) -> PngBytes: ... + def get_thumbnails(self, target: Target, range: TimeRange, count: int) -> list[PngBytes]: ... + def get_audio_levels(self, seq: SeqId, range: TimeRange, resolution: int) -> Levels: ... + # 回放 / 导出 + def play / pause / seek / get_state: ... + def export(self, seq: SeqId, output: str, preset: str) -> JobHandle: ... # 事件经回调 + # 编辑(ActionBatch 语义,见 §3.3) + def execute(self, batch: ActionBatch) -> BatchResult: ... + def undo_last(self) -> None: ... # 仅用户明确要求时 +``` + +- `EntityId` 为不透明字符串(对齐 OPP/1 §5),适配层内部维护 + id ↔ 宿主对象 的映射,工程重载后全部作废并通知 core 重新拉取。 +- 事件(结构变化 / 播放头 / 导出进度)以回调注入 core;宿主无事件源的 + (Resolve 脚本 API 无事件)由适配层**轮询合成**,轮询频率进 `limits`。 + +### 3.2 工具面(core 对 LLM 暴露 ~20 个策展工具) + +沿用设计文档 §3 的工具清单(`open_project` … `undo_last_action`),一处修改: +"OPP 方法"列改为"HostAdapter 方法"。事务编排职责不变——LLM 看不到事务/ +快照细节,core 把一次 LLM 动作包成 `ActionBatch` 交给适配层。 + +### 3.3 ActionBatch:三档事务语义(诚实分级) + +| 档位 | 宿主 | 实现 | 用户可见承诺 | +|---|---|---|---| +| 真事务 | Oak(未来) | OPP/1 `edit.begin/commit/abort` | 一次 Ctrl-Z 整段撤销,失败自动回滚 | +| 快照 + 补偿 | Resolve | 执行前 `Timeline.DuplicateTimeline("quercus-snapshot-*")`;失败/撤销时切回快照时间线 | "已为你保留编辑前快照时间线",非原子 | +| 快照 + 补偿 | Premiere | 复制当前序列(UXP 支持则快照,不支持则仅反向操作日志)+ 逐步反向操作 | 面板内"撤销本段会话"按钮回放补偿;非原子 | + +约束(写进 core,不依赖适配层自觉): +- 破坏性批次(删除/波纹删除/批量移动)**必须**先快照;只读批次(打点、 + 标记)可免快照。 +- 快照命名带时间戳与会话 id,面板提供"清理快照"入口;快照数量进 `limits` + 上限(防时间线列表被刷爆)。 +- 任一子操作失败:已执行的保持(与 OPP/1 §6 规则 3 语义对齐),错误回喂 + LLM,由 Agent 决定补偿或中止。 + +### 3.4 Oak 预留形态 + +`adapters/oak/` 包含:`OakAdapter(HostAdapter)` 全部方法 +`raise NotImplementedError("awaiting OPP/1 P1-P3")`;每个方法 docstring 标注 +对应 OPP/1 方法(如 `# -> timeline.split_clip,协议 §8.4`);`plugin.toml` +草案(capabilities 用设计文档 §5.2 最小集)。**不写任何 oakxp 调用代码。** + +## 4. 分阶段计划 + +> 工时为单人粗略估算,仅用于排序与预期管理。阶段间依赖见 §9 图。 + +### P0 技术验证(1–2 周,其余一切的闸门) + +四个 spike,每个产出可运行脚本 + 一页结论,全部通过才进 P1: + +| Spike | 验证内容 | 通过标准 | +|---|---|---| +| S1 Resolve 连接 | 外部 Python 经 `fusionscript` 连 Resolve;记录免费版/Studio 差异(外部调用、UIManager、`-nogui`) | 脚本拿到 `resolve` 对象并列出工程;明确版本门槛写进 README | +| S2 Resolve 取帧 | 三条通路对比:`Project.ExportCurrentFrameAsStill(path)`(主候选)、`Timeline.GetCurrentClipThumbnailImage()`(仅 Color 页)、Gallery `GrabAllStills` + 导出 | 能按给定时间点稳定拿到 PNG;测出单帧延迟与 `-nogui` 下可用性,定下主/备通路 | +| S3 Premiere UXP 基座 | UDT 加载 TS 面板(照官方 `premiere-api` 示例结构);面板 ↔ 本地 WebSocket server 通信(manifest network 权限、token 握手) | 面板按钮触发本地 server 往返 < 50 ms | +| S4 Premiere 取帧与编辑面 | UXP export API 导单帧(官方示例 `export.ts`);`sequenceEditor` 的 overwrite/insert/remove、markers、effects、transitions、keyframes 实际可用性;**split 与 ripple delete 是否存在原生 API** | 得到 Premiere 侧能力清单(✅/⚠️/❌),填进 §5 表;单帧导出延迟实测 | + +Go/No-Go 规则:S2、S4 的取帧延迟若 > 2 s/帧,视觉闭环体验不成立, +回到设计层改交互(如纯 contact-sheet 低帧率模式)再开工。 + +### P1 quercus-core(约 3 周,不碰任何宿主) + +1. **类型与配置**:`Rational`/`EntityId`/`FrameRef` 等核心类型; + `~/.quercus/config.toml`(权限 0600、gitignore;环境变量仅 CI 覆盖, + 优先级:环境变量 > 配置文件;密钥绝不写任何宿主侧文件——继承设计铁律 4)。 +2. **LLMProvider**:统一接口(消息 + 图片 → 文本 + tool_calls); + 后端:Claude、OpenAI 兼容 endpoint(自定义网关/代理通用通路)、 + llama.cpp 本地多模态(Qwen-VL/LLaVA 类)。无 key 优雅降级: + 面板可浏览工具说明并手动执行,不做对话编排。 +3. **Agent 编排**:对话 loop、tool_call 派发、视觉闭环(工具返回可携带 + 图片消息回喂)、待确认清单(整段动作先入清单,用户确认后才执行—— + 宿主无协议层弹窗,**确认只有这一层**,默认全开)。 +4. **工具 schema 注册表**:约 20 个工具的 JSON Schema;参数 min/max/choices + 由 `get_params` 类方法回填(同设计 §3 的约束思路)。 +5. **会话状态与回放**:ActionBatch 顺序记录、帧哈希、tool_call 日志落盘, + 可回放复现(测试夹具同格式)。 +6. **Mock LLM server**:录制/回放固定 tool_call 序列。 + +验收:`pytest` 全绿;无网络无 key 环境下跑通 +"mock LLM → 工具派发 → 回喂 → 第二轮对话"。 + +### P2 HostAdapter + Mock 宿主 + 工具适配层(约 2–3 周) + +1. HostAdapter ABC 与 `Limits`/`Capability`(§3.1–3.2)。 +2. **MockHostAdapter**:内存时间线模型,行为对齐 OPP/1 语义(事务档位、 + id 作废、`RATE_LIMITED` 式退避、帧非全黑校验)——它就是未来 Oak 适配层 + 的验收替身,也是 P1 的测试底座。 +3. **工具适配层**:tool schema → adapter 方法组合;ActionBatch 编排 + (快照策略、失败补偿、补偿顺序);取帧流水线(并发取帧 + 立即释放, + 对齐 OPP/1 §10.3 的教训:批量扫描绝不串行等帧);限流客户端 + (按 `limits` 令牌桶,退避不重试轰炸)。 +4. **本地 Web 面板**:聊天、待确认清单、进度、撤销会话按钮、快照管理。 + +验收:Mock 宿主上"导入 → 铺轨 → 切/重建 → 删除 → 加效果 → 取帧验证 → +整段会话撤销"全链路自动化通过;Web 面板手工走查。 + +### P3 Resolve 适配层(约 3–4 周,依赖 P0 S1/S2、P2) + +1. **连接与生命周期**:`fusionscript` 连接(环境变量 + `RESOLVE_SCRIPT_API/LIB` 探测 + 报错指引)、断线重连、`-nogui` 模式支持、 + 版本门槛检查(S1 结论)。 +2. **方法映射**(详表见 §5):工程/媒体池/时间线/标记/导出全量实现; + 时间模型换算(帧号 ↔ 有理秒,按时间线 fps);`EntityId` ↔ 对象映射 + (优先 `GetUniqueId()`)。 +3. **取帧通路**:按 S2 结论实现主/备两条;contact sheet 拼贴(Pillow)。 +4. **ActionBatch 档位**:快照(`DuplicateTimeline`)+ 补偿操作表;面板 + "撤销会话"回放到快照。 +5. **安装形态**:安装脚本写 Scripts 目录菜单项(`Utility/`)+ core 独立 + 启动两种入口;文档写清 Studio/免费版差异。 + +验收:`-nogui` CI 端到端——真实工程"导入 → 建时间线 → 粗剪 → 打点 → +导出",黄金帧校验(取到的帧非全黑、编辑前后切口帧像素不同);快照撤销 +恢复一致性断言;GUI 模式手工走查 Web 面板对话粗剪一段真实素材。 + +### P4 Premiere 适配层(约 4–5 周,依赖 P0 S3/S4、P2) + +1. **bridge**:core 内 WebSocket server(随机端口 + token 文件 0600); + NDJSON/JSON 消息协议(复用 core 的 HostAdapter 调用语义,一份 schema + 两用);断线重连与版本协商。 +2. **UXP 面板**(TS + Vite,结构照官方示例):聊天 UI、待确认清单、 + 进度;`manifest.json` 只声明必要权限(localhost 网络)。 +3. **方法映射**:按 S4 能力清单实现 ✅ 项;⚠️ 项做变通(split 用 + 出入点重建、ripple 用移动补偿等);❌ 项在 schema 层显式隐藏并给 + LLM 一份"本宿主不支持"说明(防幻觉调用)。 +4. **取帧**:S4 定下的通路实现;延迟进 `limits` 让扫描策略自适应。 +5. **ActionBatch 档位**:序列快照(若 API 支持复制序列)+ 补偿日志。 +6. **打包**:开发走 UDT;发布形态(独立分发 `.ccx`)与签名要求调研 + 并落地最简可用路径。 + +验收:Premiere 内面板对话完成"铺轨 → 打点 → 加转场/效果 → 导出", +待确认与撤销交互完整;bridge 断连/重连不丢会话;手工 E2E 清单全过 +(Premiere 无无头模式,此阶段接受手工测试为主)。 + +### P5 内容感知能力(约 3–4 周,依赖 P3,P4 可并行) + +1. **Contact sheet 扫描打点**:等间隔采样拼图回喂,LLM 输出时间点 → + `add_marker`(两宿主 markers API 都全)。 +2. **自动粗剪策略**:LLM 判定废片段 → ActionBatch(Resolve 直删/补偿; + Premiere 按能力变通);人工抽检闭环。 +3. **转录利用**(能力增强,可选):Premiere 有 transcript 导出、Resolve + Studio 有 `TranscribeAudio`——按 `capabilities` 探测启用"按台词剪辑"。 +4. **本地模型 provider 打磨**:llama.cpp 多模态的提示词与分辨率预算。 + +验收:对 10 分钟素材自动出粗剪版,人工抽检切点可用(同设计 A5 标准); +CI 有 mock 回放的粗剪回归。 + +### P6 发布与硬化(约 2 周) + +- 两宿主安装器/打包(Resolve 脚本目录 + core 单包;Premiere `.ccx`); + core 用 PyInstaller / python-build-standalone 嵌入式发行(用户不装 Python, + 同设计 §2.3)。 +- 崩溃与断线恢复矩阵测试;日志与诊断包;密钥配置引导;限流与大图内存 + 压测。 +- 文档:安装、能力矩阵(§5 表面向用户版)、故障排查。 + +### P7 Oak 适配(**预留,不启动**) + +启动前提:OPP/1 P1–P3 完成(传输生命周期、宿主 API 核心、取帧 shm 数据面)。 +届时 `quercus-oak` = HostAdapter → oakxp 的薄封装:事务档位直接升到"真事务" +(`edit.begin/commit`),取帧走 shm `FrameRef` + `shm.release` 流水线,确认 +双层(协议弹窗 + 会话清单)按设计文档 §5.2。预计工作量显著小于 P3/P4—— +这正是接口对齐 OPP/1 的收益。**本计划不为其分配资源。** + +## 5. 宿主能力映射表(工具 × 宿主) + +> ✅ 原生支持;⚠️ 有变通(注明);❌ 缺失(schema 层隐藏)。 +> Resolve 方法名见 X-Raym 文档;Premiere 为 UXP API 域(以 S4 实测为准修订)。 + +| core 工具 | Resolve | Premiere(UXP) | Oak(预留 → OPP/1) | +|---|---|---|---| +| `open/save_project` | ✅ `ProjectManager.LoadProject/SaveProject` | ✅ project open/save | `project.open/save` | +| `get_project_overview` | ✅ `GetCurrentTimeline` + `GetItemListInTrack` 遍历 | ✅ activeSequence 遍历 | `project.get_info` + `timeline.get_structure` | +| `probe_media` | ⚠️ `MediaPoolItem.GetClipProperty()`(无独立 probe) | ✅ projectItem 元数据 | `media.probe` | +| `import_footage` | ✅ `MediaPool.ImportMedia` | ✅ `importFiles` | `media.import_footage` | +| `add_track` | ✅ `Timeline.AddTrack` | ✅ sequence tracks | `timeline.add_track` | +| `place_clip` | ⚠️ `AppendToTimeline`(clipInfo 带 startFrame/endFrame/recordFrame/trackIndex) | ✅ `sequenceEditor` overwrite/insert | `timeline.place_clip` | +| `split_clip` | ⚠️ 无直接 API——按出入点重建相邻两段(S4 复核新版是否已加) | ⚠️ 视 S4 结论;否则同样重建 | `timeline.split_clip` | +| `trim_clip` | ⚠️ `SetProperty`/重建 | ✅ trackItem 出入点 | `timeline.trim_clip` | +| `move_clip` | ⚠️ 删除 + `AppendToTimeline` 重放 | ✅ move track item | `timeline.move_clip` | +| `ripple_delete` | ✅ `Timeline.DeleteClips(items, True)` | ⚠️ 视 S4;否则补偿移动 | `timeline.ripple_delete` | +| `add_transition` | ⚠️ `TimelineItem` 属性/新建(有限) | ✅ transition 模块 | `timeline.add_transition` | +| `add_marker` | ✅ `Timeline.AddMarker`(含 customData,可存 AI 元数据) | ✅ markers 模块 | `timeline.add_marker` | +| `add_effect/set_param/keyframe` | ⚠️ 效果面有限(Fusion comp / 属性),调色走节点图另议 | ✅ effects + keyframe 模块 | `node.*` | +| `get_frame` | ✅ S2 主通路(`ExportCurrentFrameAsStill`) | ⚠️ S4:export 单帧,延迟实测 | `render.get_frame`(shm) | +| `scan_timeline` | ✅ `GrabAllStills` / 逐点取帧拼图 | ⚠️ 批量导帧拼 contact sheet | `render.get_thumbnails` | +| `get_audio_levels` | ❌(v1 隐藏;Fairlight 侧另议) | ⚠️ 视 API;否则隐藏 | `render.get_audio_levels` | +| `play/pause/seek` | ⚠️ `SetCurrentTimecode` 可 seek;播放控制弱 | ✅ sourceMonitor play/pause/position | `playback.*` | +| `export_render` | ✅ `LoadRenderPreset` + `AddRenderJob` + `StartRendering` + `GetRenderJobStatus` 轮询 | ✅ encoderManager / export | `export.start` + 事件 | +| `undo_last_action` | ❌ 无 undo API → 快照恢复(§3.3) | ❌ 同左(补偿回放) | `edit.undo` | +| 事件(结构/进度) | ⚠️ 轮询合成 | ✅ eventManager(工程/编码事件) | OPP/1 §9 原生事件 | + +## 6. 安全与密钥(继承设计铁律,按本计划重述) + +1. core 只经 HostAdapter 操作宿主;Premiere 桥接受 token 鉴权、只绑 + `127.0.0.1`。 +2. 一切编辑走 ActionBatch,破坏性批次先快照;UI 默认"确认后执行" + (宿主无协议层确认,会话层清单是唯一确认点,不可默认关闭)。 +3. API key 只存 `~/.quercus/config.toml`(0600);绝不写入 Resolve 工程/ + Premiere 工程/未来 `.ove` 的任何位置。 +4. 遵守各宿主实测限流:取帧并发与分辨率预算写进 `limits`,core 侧令牌桶 + 统一执行,退避不轰炸。 + +## 7. 测试策略 + +1. **Mock LLM**(P1):无 key 无网络 CI 跑通 Agent loop。 +2. **MockHostAdapter**(P2):协议级行为夹具;未来 Oak 适配层先对它达标 + 再连真 Oak。 +3. **Resolve 黄金帧 E2E**(P3):`-nogui` 起真实实例,导入测试素材 → + 编辑 → 取帧,断言帧非全黑且切口前后像素变化——对齐设计 §6.3 的 + "Agent 的编辑确实改变了画面"。 +4. **Premiere 手工清单**(P4):无无头模式,维护一份逐步 checklist + + 预期画面截图基线,发布前必跑。 +5. **会话回放**:tool_call + 帧哈希日志可直接作回归夹具。 + +## 8. 风险登记册 + +| 风险 | 影响 | 对策 | +|---|---|---| +| Resolve 免费版外部脚本/UI 受限(v19.1 移除 UIManager) | Resolve 侧受众收窄 | S1 定门槛;必要时仅支持 Studio 并在文档明示 | +| Resolve/Premiere 均无 undo/事务 API | "撤销会话"非原子,可能残留 | 快照 + 补偿(§3.3);UI 文案不承诺原子性 | +| Premiere UXP 取帧慢或无直接 API | 视觉闭环降格 | S4 实测;降格方案 = 低帧率 contact-sheet 模式 | +| Premiere split/ripple 无原生 API | 粗剪动作变通复杂易错 | 重建法封装进适配层 + 黄金帧类断言兜底 | +| UXP 网络/面板权限政策变化 | 桥接失效 | manifest 最小权限;关注 Adobe 公告;bridge 协议版本协商 | +| LLM 成本与延迟 | 体验差 | 本地 provider、扫描降采样、contact sheet 合并请求 | +| 时间模型换算(帧号/ticks/fps 下拉) | 切点漂移 | 有理秒唯一内部模型 + 适配层换算单测(NTSC fps 用例) | + +## 9. 里程碑总表与依赖 + +``` +P0(spike) ──► P1(core) ──► P2(adapter+mock) ──┬──► P3(Resolve) ──► P5(内容感知) ──► P6(发布) + │ └──► P4(Premiere) ────────────────►──┘ + └──(P7 Oak:等 OPP/1 P1–P3,不占本计划资源) +``` + +| 里程碑 | 出口标准(一句话) | 估算 | +|---|---|---| +| P0 | 四个 spike 通过,两宿主取帧通路定案 | 1–2 周 | +| P1 | Mock LLM 下 Agent loop CI 全绿 | ~3 周 | +| P2 | Mock 宿主全链路 + Web 面板可用 | ~2–3 周 | +| P3 | Resolve 无头 E2E 黄金帧通过,GUI 对话粗剪走通 | ~3–4 周 | +| P4 | Premiere 面板对话编辑 + 撤销会话走通 | ~4–5 周 | +| P5 | 10 分钟素材自动粗剪,抽检可用 | ~3–4 周 | +| P6 | 两宿主安装包 + 文档 + 硬化完成 | ~2 周 | +| P7 | (预留)Oak 适配 = oakxp 薄封装,等 OPP/1 P1–P3 | 未排期 | + +P4 与 P3 可由两人并行;单人执行顺序建议 P3 先于 P4(Resolve API 面更全、 +可无头,能在最硬的宿主上先把 HostAdapter 接口的真实形状磨出来)。 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c3392be --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "quercus" +version = "0.1.0" +description = "Quercus AI 剪辑助手 — monorepo(workspace 根,虚拟包)" +requires-python = ">=3.11" +dependencies = [ + "quercus-core", +] + +[dependency-groups] +dev = [ + "pytest>=8.0", +] + +[tool.uv] +# 根目录不是可安装包,仅作 workspace 锚点 +package = false + +[tool.uv.sources] +quercus-core = { workspace = true } + +[tool.uv.workspace] +members = ["core"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..456bdec --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,25 @@ +"""pytest 全局配置:把 tests/ 加入 sys.path(供 ``import mock_llm``)与共享 fixtures。""" +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +_TESTS_DIR = Path(__file__).resolve().parent +if str(_TESTS_DIR) not in sys.path: + sys.path.insert(0, str(_TESTS_DIR)) + + +@pytest.fixture +def tmp_home(tmp_path, monkeypatch): + """把 ~/.quercus 定向到临时目录(经 QUERCUS_HOME 覆盖)。""" + home = tmp_path / "home" + monkeypatch.setenv("QUERCUS_HOME", str(home)) + return home + + +@pytest.fixture +def fake_png(): + """最小 PNG 头 + 填充字节;core 只流转字节,不解码。""" + return b"\x89PNG\r\n\x1a\n" + bytes(range(64)) diff --git a/tests/core/test_config.py b/tests/core/test_config.py new file mode 100644 index 0000000..2b0cc64 --- /dev/null +++ b/tests/core/test_config.py @@ -0,0 +1,83 @@ +"""配置:0600 权限、环境变量覆盖优先级、密钥不落日志。""" +import stat + +import pytest + +from quercus_core.config import ( + ENV_ANTHROPIC_API_KEY, + ENV_OPENAI_API_KEY, + ENV_OPENAI_BASE_URL, + Config, + config_home, + config_summary, + default_config_path, + default_sessions_dir, + load_config, + save_config, +) + + +class TestSaveLoad: + def test_save_creates_0600(self, tmp_path): + path = tmp_path / "cfg" / "config.toml" + save_config(Config(anthropic_api_key="sk-ant-secret"), path) + assert path.exists() + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + + def test_roundtrip(self, tmp_path): + path = tmp_path / "config.toml" + save_config( + Config( + anthropic_api_key="sk-a", + openai_api_key="sk-o", + openai_base_url="https://gw.example/v1", + ), + path, + ) + cfg = load_config(path) + assert cfg.anthropic_api_key == "sk-a" + assert cfg.openai_api_key == "sk-o" + assert cfg.openai_base_url == "https://gw.example/v1" + assert cfg.has_anthropic_key and cfg.has_openai_key + + def test_env_overrides_file(self, tmp_path, monkeypatch): + path = tmp_path / "config.toml" + save_config(Config(anthropic_api_key="file-key", openai_api_key="file-o"), path) + monkeypatch.setenv(ENV_ANTHROPIC_API_KEY, "env-key") + cfg = load_config(path) + assert cfg.anthropic_api_key == "env-key" # 环境变量 > 配置文件 + assert cfg.openai_api_key == "file-o" # 未覆盖的保持文件值 + + def test_env_without_file(self, tmp_path, monkeypatch): + monkeypatch.setenv(ENV_OPENAI_API_KEY, "env-o") + monkeypatch.setenv(ENV_OPENAI_BASE_URL, "http://localhost:8080/v1") + monkeypatch.delenv(ENV_ANTHROPIC_API_KEY, raising=False) + cfg = load_config(tmp_path / "missing.toml") + assert cfg.openai_api_key == "env-o" + assert cfg.openai_base_url == "http://localhost:8080/v1" + assert cfg.anthropic_api_key is None + + def test_no_config_no_env(self, tmp_path, monkeypatch): + monkeypatch.delenv(ENV_ANTHROPIC_API_KEY, raising=False) + monkeypatch.delenv(ENV_OPENAI_API_KEY, raising=False) + monkeypatch.delenv(ENV_OPENAI_BASE_URL, raising=False) + cfg = load_config(tmp_path / "missing.toml") + assert cfg.anthropic_api_key is None + assert cfg.openai_api_key is None + assert cfg.openai_base_url is None + assert not cfg.has_anthropic_key and not cfg.has_openai_key + + +class TestHome: + def test_home_override(self, tmp_home): + assert config_home() == tmp_home / ".quercus" + assert default_config_path() == tmp_home / ".quercus" / "config.toml" + assert default_sessions_dir() == tmp_home / ".quercus" / "sessions" + + +def test_config_summary_hides_secrets(): + cfg = Config(anthropic_api_key="top-secret-value", openai_api_key="second-secret") + summary = config_summary(cfg) + assert summary["anthropic_configured"] is True + assert summary["openai_configured"] is True + assert "secret" not in str(summary) diff --git a/tests/core/test_host_adapter.py b/tests/core/test_host_adapter.py new file mode 100644 index 0000000..976b334 --- /dev/null +++ b/tests/core/test_host_adapter.py @@ -0,0 +1,72 @@ +"""HostAdapter ABC:只定义接口形状,不可实例化,方法签名齐全。""" +import pytest + +from quercus_core.host.adapter import HostAdapter +from quercus_core.types import Action, ActionBatch, Limits, Rational + + +def test_abc_cannot_instantiate(): + with pytest.raises(TypeError): + HostAdapter() # type: ignore[abstract] + + +def test_abstract_methods_declared(): + assert {"capabilities", "limits", "get_frame", "get_thumbnails", "execute", "undo_last"} <= set( + HostAdapter.__abstractmethods__ + ) + + +def test_missing_abstract_method_raises(): + class Partial(HostAdapter): + def capabilities(self): + return set() + + with pytest.raises(TypeError): + Partial() # type: ignore[abstract] + + +def test_concrete_subclass_works(): + class Impl(HostAdapter): + def capabilities(self): + return set() + + def limits(self): + return Limits() + + def open_project(self, path): ... + + def save_project(self, path=None): ... + + def get_project_overview(self): ... + + def probe_media(self, path): ... + + def import_footage(self, paths): ... + + def get_timeline_structure(self, seq): ... + + def get_frame(self, target, time, max_size): ... + + def get_thumbnails(self, target, range, count): ... + + def get_audio_levels(self, seq, range, resolution): ... + + def play(self, speed=1.0): ... + + def pause(self): ... + + def seek(self, time): ... + + def get_state(self): ... + + def export(self, seq, output, preset): ... + + def execute(self, batch): ... + + def undo_last(self): ... + + impl = Impl() + assert impl.capabilities() == set() + assert impl.limits().max_frame_rate == 8.0 + # execute 接受 ActionBatch 语义 + impl.execute(ActionBatch(label="t", actions=[Action(tool="play", params={})])) diff --git a/tests/core/test_loop.py b/tests/core/test_loop.py new file mode 100644 index 0000000..732a96f --- /dev/null +++ b/tests/core/test_loop.py @@ -0,0 +1,195 @@ +"""Agent loop:两轮对话、确认门、只读直通、视觉闭环回喂、会话日志。""" +from quercus_core.agent.loop import AgentLoop +from quercus_core.providers.base import AssistantTurn, ToolCall +from quercus_core.session.log import SessionLog, replay +from quercus_core.types import PngBytes, ToolResult + +from mock_llm import MockProvider + +PNG = PngBytes(b"\x89PNG\r\n\x1a\n" + bytes(range(64))) + + +def _sha256(data: bytes) -> str: + import hashlib + + return hashlib.sha256(data).hexdigest() + + +class StubExecutor: + """内存 stub 工具执行器(P1 无真实宿主实现)。""" + + def __init__(self): + self.calls = [] + + def execute(self, action): + self.calls.append(action) + if action.tool == "get_frame": + return ToolResult(tool="get_frame", ok=True, summary="帧:00:00:02", images=(PNG,)) + if action.tool == "place_clip": + return ToolResult(tool="place_clip", ok=True, summary="已放置片段 c1 到轨道 1") + if action.tool == "ripple_delete": + return ToolResult(tool="ripple_delete", ok=True, summary="已波纹删除 1 个片段") + return ToolResult(tool=action.tool, ok=True, summary=f"{action.tool} ok") + + +def _frame_call(i: int) -> ToolCall: + return ToolCall( + id=f"call_frame_{i}", + name="get_frame", + arguments={"time": {"num": i, "den": 1}, "max_size": {"width": 32, "height": 18}}, + ) + + +def test_two_round_loop_confirm_auto_approve(): + """第一轮含变更工具(触发确认门) + get_frame(PNG 回喂);第二轮纯文本结束。""" + mock = MockProvider( + script=[ + AssistantTurn( + text="", + tool_calls=( + ToolCall( + id="call_place", + name="place_clip", + arguments={"clip_id": "c1", "track_index": 1, "time": {"num": 0, "den": 1}}, + ), + _frame_call(2), + ), + ), + AssistantTurn(text="剪辑完成", tool_calls=()), + ] + ) + stub = StubExecutor() + confirmed = [] + + def confirm(batch): + confirmed.append(batch) + return True + + loop = AgentLoop(provider=mock, executor=stub, confirm=confirm) + result = loop.run("铺一个片段并看一眼画面") + + assert result.final_text == "剪辑完成" + assert result.turns == 2 + assert result.tool_calls_executed == 2 + assert [a.tool for a in stub.calls] == ["place_clip", "get_frame"] + assert len(confirmed) == 1 + assert len(confirmed[0].actions) == 2 + + # 视觉闭环:get_frame 的 PNG 作为 tool 消息回喂 provider + last_messages = mock.observed[-1] + tool_msgs = [m for m in last_messages if m.role == "tool"] + assert len(tool_msgs) == 2 + frame_msg = next(m for m in tool_msgs if m.name == "get_frame") + assert PNG in frame_msg.images + + +def test_rejected_batch_mutation_not_executed(): + mock = MockProvider( + script=[ + AssistantTurn( + text="", + tool_calls=( + ToolCall(id="c1", name="ripple_delete", arguments={"clip_ids": ["x1"]}), + ), + ), + AssistantTurn(text="已取消删除", tool_calls=()), + ] + ) + stub = StubExecutor() + loop = AgentLoop(provider=mock, executor=stub, confirm=lambda batch: False) + result = loop.run("删掉 x1") + + assert stub.calls == [] # 变更工具未执行 + assert result.final_text == "已取消删除" + assert loop.batch_decisions[0][1] is False + # 拒绝结果回喂 LLM + last_messages = mock.observed[-1] + tool_msgs = [m for m in last_messages if m.role == "tool"] + assert len(tool_msgs) == 1 + assert "未执行" in tool_msgs[0].text + + +def test_readonly_batch_bypasses_confirm(): + mock = MockProvider( + script=[ + AssistantTurn(text="", tool_calls=(_frame_call(1),)), + AssistantTurn(text="帧已取到", tool_calls=()), + ] + ) + stub = StubExecutor() + confirm_calls = [] + + def confirm(batch): + confirm_calls.append(batch) + return True + + loop = AgentLoop(provider=mock, executor=stub, confirm=confirm) + result = loop.run("取一帧") + + assert confirm_calls == [] # 纯只读批次不触发确认门 + assert [a.tool for a in stub.calls] == ["get_frame"] + assert result.final_text == "帧已取到" + + +def test_default_confirm_is_auto_approve(): + """未注入 confirm 回调时默认全开(计划文档 P1.3)。""" + mock = MockProvider( + script=[ + AssistantTurn( + text="", + tool_calls=(ToolCall(id="c1", name="add_marker", arguments={"time": {"num": 1, "den": 1}}),), + ), + AssistantTurn(text="已打点", tool_calls=()), + ] + ) + stub = StubExecutor() + loop = AgentLoop(provider=mock, executor=stub) + result = loop.run("打点") + assert [a.tool for a in stub.calls] == ["add_marker"] + assert result.final_text == "已打点" + + +def test_max_turns_bound(): + mock = MockProvider( + script=[AssistantTurn(text="", tool_calls=(_frame_call(i),)) for i in range(3)] + ) + stub = StubExecutor() + loop = AgentLoop(provider=mock, executor=stub, max_turns=2) + result = loop.run("一直取帧") + assert result.turns == 2 + assert len(stub.calls) == 2 + + +def test_unknown_tool_reported_back(): + mock = MockProvider( + script=[ + AssistantTurn(text="", tool_calls=(ToolCall(id="c1", name="no_such_tool", arguments={}),)), + AssistantTurn(text="done", tool_calls=()), + ] + ) + stub = StubExecutor() + loop = AgentLoop(provider=mock, executor=stub) + loop.run("调用未知工具") + assert stub.calls == [] + last = mock.observed[-1][-1] + assert "未知工具" in last.text + + +def test_loop_writes_session_log(tmp_path): + mock = MockProvider( + script=[ + AssistantTurn(text="", tool_calls=(_frame_call(3),)), + AssistantTurn(text="结束", tool_calls=()), + ] + ) + stub = StubExecutor() + log = SessionLog(tmp_path / "sessions" / "s1.jsonl", session_id="sess-1") + loop = AgentLoop(provider=mock, executor=stub, session=log) + loop.run("取帧并结束") + + events = replay(log.path) + kinds = [e.kind for e in events] + assert kinds == ["user_message", "assistant_turn", "batch_decision", "tool_result", "assistant_turn"] + # 帧哈希落盘(不落图片本体) + result_event = next(e for e in events if e.kind == "tool_result") + assert result_event.payload["image_hashes"] == [_sha256(PNG)] diff --git a/tests/core/test_mock_llm.py b/tests/core/test_mock_llm.py new file mode 100644 index 0000000..7751367 --- /dev/null +++ b/tests/core/test_mock_llm.py @@ -0,0 +1,55 @@ +"""mock_llm:脚本化回复、录制/回放往返、脚本文件序列化。""" +from quercus_core.providers.base import AssistantTurn, Message, Role, ToolCall + +from mock_llm import MockProvider, RecordingProvider + + +def test_mock_provider_scripted(): + mock = MockProvider( + script=[ + AssistantTurn( + text="", tool_calls=(ToolCall(id="c1", name="get_frame", arguments={"time": {"num": 1, "den": 1}}),) + ), + AssistantTurn(text="done", tool_calls=()), + ] + ) + turn1 = mock.generate([Message(role=Role.USER, text="hi")]) + assert turn1.tool_calls[0].name == "get_frame" + assert mock.observed[0][0].text == "hi" + turn2 = mock.generate([Message(role=Role.USER, text="hi")]) + assert turn2.text == "done" and not turn2.tool_calls + assert mock.exhausted + + +def test_mock_provider_empty_script_graceful(): + mock = MockProvider() + turn = mock.generate([Message(role=Role.USER, text="hi")]) + assert turn.text and not turn.tool_calls # 空脚本不抛异常,结束本轮 + + +def test_script_file_roundtrip(tmp_path): + turns = [ + AssistantTurn( + text="", tool_calls=(ToolCall(id="c1", name="place_clip", arguments={"x": 1}),) + ), + AssistantTurn(text="ok", tool_calls=()), + ] + path = tmp_path / "script.json" + MockProvider.dump_script(turns, path) + loaded = MockProvider.load_script(path) + assert loaded == turns + + +def test_recording_provider_records_and_replays(tmp_path): + inner = MockProvider( + script=[AssistantTurn(text="a", tool_calls=()), AssistantTurn(text="b", tool_calls=())] + ) + rec = RecordingProvider(inner) + rec.generate([Message(role=Role.USER, text="1")]) + rec.generate([Message(role=Role.USER, text="2")]) + assert [t.text for t in rec.recorded] == ["a", "b"] + + path = tmp_path / "recorded.json" + MockProvider.dump_script(rec.recorded, path) + replayed = MockProvider.load_script(path) + assert replayed == rec.recorded diff --git a/tests/core/test_providers.py b/tests/core/test_providers.py new file mode 100644 index 0000000..0ad7e42 --- /dev/null +++ b/tests/core/test_providers.py @@ -0,0 +1,321 @@ +"""Provider 层测试:httpx MockTransport,零网络。 + +覆盖:OpenAI 兼容请求/响应解析、图片回喂、错误处理;Claude(anthropic SDK) +的消息/图片/tool_result 转换与 tool_use 解析;注册表可用性与无 key 降级。 +""" +import base64 +import json + +import httpx +import pytest + +from quercus_core.config import Config +from quercus_core.providers import create_provider, provider_status +from quercus_core.providers.base import ( + LLMProvider, + Message, + ProviderError, + ProviderUnavailable, + Role, + ToolCall, + ToolSchema, +) +from quercus_core.providers.claude import ClaudeProvider +from quercus_core.providers.openai_compat import OpenAICompatProvider +from quercus_core.types import PngBytes + +PNG = PngBytes(b"\x89PNG\r\n\x1a\n" + bytes(range(64))) + +GET_FRAME_SCHEMA = ToolSchema( + name="get_frame", + description="取帧", + parameters={"type": "object", "properties": {"time": {"type": "object"}}, "required": ["time"]}, + read_only=True, +) + +CLAUDE_MESSAGE = { + "id": "msg_01", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [], + "stop_reason": "end_turn", + "stop_sequence": None, + "usage": {"input_tokens": 10, "output_tokens": 5}, +} + + +# ---- OpenAI 兼容 ---- + +def test_openai_compat_request_and_parse(): + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["url"] = str(request.url) + captured["authorization"] = request.headers.get("authorization") + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "id": "chatcmpl-1", + "object": "chat.completion", + "created": 1, + "model": "gpt-4o", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "我来调用工具", + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "get_frame", + "arguments": json.dumps({"time": {"num": 1, "den": 1}}), + }, + } + ], + }, + "finish_reason": "tool_calls", + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + }, + ) + + client = httpx.Client(transport=httpx.MockTransport(handler)) + provider = OpenAICompatProvider( + api_key="sk-test", + base_url="https://gw.example.com/v1", + model="local-vl", + client=client, + ) + turn = provider.generate( + [Message(role=Role.USER, text="hi")], tools=[GET_FRAME_SCHEMA] + ) + assert captured["url"] == "https://gw.example.com/v1/chat/completions" + assert captured["authorization"] == "Bearer sk-test" + assert captured["body"]["model"] == "local-vl" + assert captured["body"]["tools"][0]["function"]["name"] == "get_frame" + assert turn.text == "我来调用工具" + assert turn.tool_calls[0].name == "get_frame" + assert turn.tool_calls[0].arguments == {"time": {"num": 1, "den": 1}} + + +def test_openai_compat_image_message(): + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content) + return httpx.Response(200, json={"choices": [{"message": {"content": "ok"}}]}) + + client = httpx.Client(transport=httpx.MockTransport(handler)) + provider = OpenAICompatProvider(api_key="k", base_url="https://x/v1", client=client) + provider.generate([Message(role=Role.USER, text="看图", images=(PNG,))]) + content = captured["body"]["messages"][0]["content"] + assert content[0] == {"type": "text", "text": "看图"} + url = content[1]["image_url"]["url"] + assert url.startswith("data:image/png;base64,") + assert base64.b64decode(url.split(",", 1)[1]) == PNG + + +def test_openai_compat_tool_result_image_follow_up_user_message(): + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content) + return httpx.Response(200, json={"choices": [{"message": {"content": "ok"}}]}) + + client = httpx.Client(transport=httpx.MockTransport(handler)) + provider = OpenAICompatProvider(api_key="k", base_url="https://x/v1", client=client) + provider.generate( + [ + Message(role=Role.TOOL, text="帧", images=(PNG,), tool_call_id="c1", name="get_frame"), + ] + ) + msgs = captured["body"]["messages"] + assert msgs[0]["role"] == "tool" + assert msgs[0]["tool_call_id"] == "c1" + assert msgs[1]["role"] == "user" # 帧图以随后的 user 消息回喂 + assert msgs[1]["content"][0]["type"] == "text" + assert msgs[1]["content"][1]["image_url"]["url"].startswith("data:image/png;base64,") + + +def test_openai_compat_http_error(): + client = httpx.Client( + transport=httpx.MockTransport(lambda req: httpx.Response(500, text="boom")) + ) + provider = OpenAICompatProvider(api_key="k", base_url="https://x/v1", client=client) + with pytest.raises(ProviderError): + provider.generate([Message(role=Role.USER, text="hi")]) + + +# ---- Claude ---- + +def test_claude_tool_use_parse(): + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + headers={"content-type": "application/json"}, + json={ + "id": "msg_01", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [ + {"type": "text", "text": "我来取帧"}, + { + "type": "tool_use", + "id": "toolu_01", + "name": "get_frame", + "input": {"time": {"num": 1, "den": 1}, "max_size": {"width": 320, "height": 180}}, + }, + ], + "stop_reason": "tool_use", + "stop_sequence": None, + "usage": {"input_tokens": 10, "output_tokens": 5}, + }, + ) + + client = httpx.Client(transport=httpx.MockTransport(handler)) + provider = ClaudeProvider(api_key="sk-test", http_client=client) + turn = provider.generate([Message(role=Role.USER, text="hi")]) + assert turn.text == "我来取帧" + assert len(turn.tool_calls) == 1 + assert turn.tool_calls[0].id == "toolu_01" + assert turn.tool_calls[0].name == "get_frame" + assert turn.tool_calls[0].arguments["max_size"]["width"] == 320 + + +def test_claude_image_message_becomes_image_block(): + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, headers={"content-type": "application/json"}, json=CLAUDE_MESSAGE + ) + + client = httpx.Client(transport=httpx.MockTransport(handler)) + provider = ClaudeProvider(api_key="sk-test", http_client=client) + provider.generate([Message(role=Role.USER, text="看图", images=(PNG,))]) + content = captured["body"]["messages"][0]["content"] + assert content[0]["type"] == "text" # 文本块在前 + img_block = content[1] + assert img_block["type"] == "image" + assert img_block["source"]["media_type"] == "image/png" + assert base64.b64decode(img_block["source"]["data"]) == PNG + + +def test_claude_tool_result_with_image(): + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, headers={"content-type": "application/json"}, json=CLAUDE_MESSAGE + ) + + client = httpx.Client(transport=httpx.MockTransport(handler)) + provider = ClaudeProvider(api_key="sk-test", http_client=client) + provider.generate( + [ + Message( + role=Role.ASSISTANT, + tool_calls=(ToolCall(id="toolu_01", name="get_frame", arguments={}),), + ), + Message( + role=Role.TOOL, + text="帧", + images=(PNG,), + tool_call_id="toolu_01", + name="get_frame", + ), + ] + ) + msgs = captured["body"]["messages"] + assert msgs[0]["role"] == "assistant" + assert msgs[0]["content"][0]["type"] == "tool_use" + assert msgs[1]["role"] == "user" # 连续 tool 消息合并为一条带 tool_result 的 user + tool_result = msgs[1]["content"][0] + assert tool_result["type"] == "tool_result" + assert tool_result["tool_use_id"] == "toolu_01" + assert tool_result["content"][0]["type"] == "text" + assert tool_result["content"][1]["type"] == "image" + + +def test_claude_multiple_tool_results_merged_into_one_user_message(): + """agent loop 的典型回喂形态:多条 tool 消息合并成一条 user(tool_result...)。""" + captured: dict = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, headers={"content-type": "application/json"}, json=CLAUDE_MESSAGE + ) + + client = httpx.Client(transport=httpx.MockTransport(handler)) + provider = ClaudeProvider(api_key="sk-test", http_client=client) + provider.generate( + [ + Message( + role=Role.ASSISTANT, + tool_calls=( + ToolCall(id="t1", name="place_clip", arguments={"x": 1}), + ToolCall(id="t2", name="get_frame", arguments={"t": 1}), + ), + ), + Message(role=Role.TOOL, text="placed", tool_call_id="t1", name="place_clip"), + Message(role=Role.TOOL, text="frame", images=(PNG,), tool_call_id="t2", name="get_frame"), + ] + ) + msgs = captured["body"]["messages"] + assert len(msgs) == 2 + assert msgs[0]["role"] == "assistant" + assert [b["type"] for b in msgs[0]["content"]] == ["tool_use", "tool_use"] + # 两条 tool_result 合并进同一条 user 消息(保持 assistant 之后紧跟 user) + user_content = msgs[1]["content"] + assert [b["type"] for b in user_content] == ["tool_result", "tool_result"] + assert [b["tool_use_id"] for b in user_content] == ["t1", "t2"] + + +# ---- 注册表 / 无 key 降级 ---- + +def test_registry_unavailable_without_keys(tmp_path, monkeypatch): + monkeypatch.delenv("QUERCUS_ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("QUERCUS_OPENAI_API_KEY", raising=False) + cfg = Config() + status = provider_status(cfg) + assert status["claude"].available is False + assert status["openai_compat"].available is False + assert status["claude"].reason + with pytest.raises(ProviderUnavailable): + create_provider("claude", cfg) + with pytest.raises(ProviderUnavailable): + create_provider("openai_compat", cfg) + + +def test_registry_create_with_keys(): + cfg = Config(anthropic_api_key="sk-ant", openai_api_key="sk-o") + status = provider_status(cfg) + assert status["claude"].available is True + assert status["claude"].model + assert status["openai_compat"].available is True + mock_client = httpx.Client( + transport=httpx.MockTransport(lambda req: httpx.Response(200, json={"choices": [{"message": {"content": "ok"}}]})) + ) + claude = create_provider("claude", cfg, http_client=mock_client) + assert isinstance(claude, ClaudeProvider) + openai = create_provider("openai_compat", cfg, http_client=mock_client) + assert isinstance(openai, OpenAICompatProvider) + assert isinstance(claude, LLMProvider) + + +def test_registry_unknown_provider(): + with pytest.raises(ValueError): + create_provider("llamacpp", Config()) diff --git a/tests/core/test_schemas.py b/tests/core/test_schemas.py new file mode 100644 index 0000000..6c1ca64 --- /dev/null +++ b/tests/core/test_schemas.py @@ -0,0 +1,112 @@ +"""工具 schema 注册表:合法性、read_only 标注、有理秒参数形状。""" +import json + +import pytest + +from quercus_core.providers.base import ToolSchema +from quercus_core.tools.schemas import ( + RATIONAL_SCHEMA, + TIME_RANGE_SCHEMA, + TOOL_LIST, + TOOLS, + get_tool, + tool_read_only, +) + +# 期望的 read_only 标注(独立于代码本身,防止误改)。 +EXPECTED_READ_ONLY = { + "open_project": False, + "save_project": False, + "get_project_overview": True, + "probe_media": True, + "import_footage": False, + "list_footage": True, + "add_track": False, + "place_clip": False, + "split_clip": False, + "trim_clip": False, + "move_clip": False, + "ripple_delete": False, + "add_transition": False, + "add_marker": False, + "add_effect": False, + "set_param": False, + "set_keyframe": False, + "list_effects": True, + "get_frame": True, + "scan_timeline": True, + "get_audio_levels": True, + "play": True, + "pause": True, + "seek": True, + "export_render": False, + "undo_last_action": False, +} + + +def test_tool_count(): + assert len(TOOLS) == 26 + assert len(TOOL_LIST) == 26 + assert len({t.name for t in TOOL_LIST}) == 26 + + +def test_schema_shape(): + for name, tool in TOOLS.items(): + assert isinstance(tool, ToolSchema) + assert tool.name == name + assert isinstance(tool.description, str) and tool.description + p = tool.parameters + assert p["type"] == "object" + assert isinstance(p["properties"], dict) + required = p.get("required", []) + assert isinstance(required, list) + for req in required: + assert req in p["properties"], f"{name}: required {req} 不在 properties" + json.dumps(p) # 可 JSON 序列化 => 合法 JSON Schema 对象 + + +def test_read_only_flags(): + assert set(TOOLS) == set(EXPECTED_READ_ONLY) + for name, expected in EXPECTED_READ_ONLY.items(): + assert TOOLS[name].read_only is expected, f"{name} read_only 应为 {expected}" + assert tool_read_only(name) is expected, f"{name} tool_read_only 不一致" + + +def test_unknown_tool_treated_as_mutation(): + assert tool_read_only("no_such_tool") is False + + +@pytest.mark.parametrize( + "tool_name,param_name", + [ + ("place_clip", "time"), + ("split_clip", "time"), + ("trim_clip", "in_point"), + ("move_clip", "time"), + ("add_marker", "time"), + ("set_keyframe", "time"), + ("get_frame", "time"), + ("seek", "time"), + ], +) +def test_rational_params_are_num_den(tool_name, param_name): + props = TOOLS[tool_name].parameters["properties"] + assert props[param_name] == RATIONAL_SCHEMA, f"{tool_name}.{param_name}" + + +def test_time_range_param_shape(): + props = TOOLS["scan_timeline"].parameters["properties"] + assert props["range"] == TIME_RANGE_SCHEMA + assert props["count"]["maximum"] == 64 + + +def test_get_frame_max_size(): + props = TOOLS["get_frame"].parameters["properties"] + max_size = props["max_size"] + assert max_size["type"] == "object" + assert max_size["required"] == ["width", "height"] + + +def test_get_tool_lookup(): + assert get_tool("play") is TOOLS["play"] + assert get_tool("nope") is None diff --git a/tests/core/test_session_log.py b/tests/core/test_session_log.py new file mode 100644 index 0000000..daf285b --- /dev/null +++ b/tests/core/test_session_log.py @@ -0,0 +1,74 @@ +"""会话日志:JSONL 写读往返、帧哈希记录(不落图片本体)。""" +import hashlib +import json + +from quercus_core.providers.base import AssistantTurn, ToolCall +from quercus_core.session.log import SessionLog, frame_sha256, replay +from quercus_core.types import Action, ActionBatch, PngBytes, ToolResult + +PNG = PngBytes(b"\x89PNG\r\n\x1a\n" + bytes(range(64))) + + +def test_write_read_roundtrip(tmp_path): + path = tmp_path / "s1.jsonl" + log = SessionLog(path, session_id="sess-1") + log.log_user("你好") + log.log_assistant( + AssistantTurn( + text="", + tool_calls=(ToolCall(id="c1", name="get_frame", arguments={"time": {"num": 1, "den": 1}}),), + ) + ) + log.log_tool_result(ToolResult(tool="get_frame", ok=True, summary="帧", images=(PNG,))) + log.log_batch_decision( + ActionBatch( + label="AI 动作:get_frame", + actions=[Action(tool="get_frame", params={"time": {"num": 1, "den": 1}}, call_id="c1")], + id="b1", + ), + approved=True, + ) + + events = replay(path) + assert len(events) == 4 + assert events[0].kind == "user_message" + assert events[0].payload["text"] == "你好" + assert events[0].session_id == "sess-1" + assert events[1].kind == "assistant_turn" + assert events[1].payload["tool_calls"][0]["name"] == "get_frame" + assert events[2].kind == "tool_result" + assert events[3].kind == "batch_decision" + assert events[3].payload["approved"] is True + + +def test_frame_hash_recorded_not_bytes(tmp_path): + path = tmp_path / "s2.jsonl" + log = SessionLog(path, session_id="s") + log.log_tool_result(ToolResult(tool="get_frame", ok=True, summary="f", images=(PNG,))) + + events = replay(path) + ev = events[0] + assert ev.kind == "tool_result" + assert ev.payload["image_hashes"] == [hashlib.sha256(PNG).hexdigest()] + # 日志文件里绝不出现图片本体字节 + assert PNG not in path.read_bytes() + + +def test_jsonl_each_line_is_valid_json(tmp_path): + path = tmp_path / "s3.jsonl" + log = SessionLog(path) + log.log_user("x") + log.log_tool_result(ToolResult(tool="seek", ok=True, summary="ok")) + for line in path.read_text(encoding="utf-8").splitlines(): + json.loads(line) + + +def test_frame_sha256_helper(fake_png): + assert frame_sha256(fake_png) == hashlib.sha256(fake_png).hexdigest() + + +def test_create_default_uses_home(tmp_home): + log = SessionLog.create_default() + assert log.path.parent == tmp_home / ".quercus" / "sessions" + assert log.path.suffix == ".jsonl" + assert log.path.name # .jsonl diff --git a/tests/core/test_types.py b/tests/core/test_types.py new file mode 100644 index 0000000..a878972 --- /dev/null +++ b/tests/core/test_types.py @@ -0,0 +1,122 @@ +"""核心类型:Rational 运算与序列化、TimeRange、Size、ActionBatch 等。""" +import pytest + +from quercus_core.types import ( + Action, + ActionBatch, + Capabilities, + Limits, + PngBytes, + Rational, + Size, + TimeRange, + ToolResult, +) + + +class TestRational: + def test_normalization(self): + assert Rational(2, 4) == Rational(1, 2) + assert Rational(0, 7) == Rational(0, 1) + assert Rational(1, -2) == Rational(-1, 2) + assert Rational(4, 2).num == 2 + assert Rational(4, 2).den == 1 + + def test_zero_denominator_raises(self): + with pytest.raises(ValueError): + Rational(1, 0) + + def test_arithmetic(self): + assert Rational(1, 3) + Rational(1, 6) == Rational(1, 2) + assert Rational(1, 2) - Rational(1, 4) == Rational(1, 4) + assert Rational(1, 3) * Rational(3, 4) == Rational(1, 4) + assert Rational(1, 2) / Rational(1, 4) == Rational(2) + with pytest.raises(ZeroDivisionError): + Rational(1, 2) / Rational(0, 1) + + def test_float_conversion(self): + assert Rational.from_float(0.5) == Rational(1, 2) + assert Rational(1, 3).to_float() == pytest.approx(1 / 3) + + def test_ordering_and_hashing(self): + assert Rational(1, 3) < Rational(1, 2) + assert sorted([Rational(1, 2), Rational(1, 3)]) == [Rational(1, 3), Rational(1, 2)] + assert len({Rational(1, 2), Rational(2, 4)}) == 1 # 约分后同一对象 + + def test_json_roundtrip(self): + assert Rational(2, 4).to_json() == {"num": 1, "den": 2} + assert Rational.from_json({"num": 2, "den": 4}) == Rational(1, 2) + assert Rational.from_json(Rational(3, 4)) == Rational(3, 4) + with pytest.raises(ValueError): + Rational.from_json({"num": 1}) + with pytest.raises(ValueError): + Rational.from_json("nope") + + +class TestTimeRange: + def test_basic(self): + tr = TimeRange(Rational(0), Rational(2)) + assert tr.duration == Rational(2) + assert tr.contains(Rational(1)) + assert not tr.contains(Rational(3)) + + def test_invalid_order(self): + with pytest.raises(ValueError): + TimeRange(Rational(2), Rational(1)) + + def test_json_roundtrip(self): + tr = TimeRange(Rational(1, 2), Rational(3)) + assert TimeRange.from_json(tr.to_json()) == tr + assert tr.to_json() == { + "start": {"num": 1, "den": 2}, + "end": {"num": 3, "den": 1}, + } + + +class TestSize: + def test_basic(self): + s = Size(1920, 1080) + assert s.max_side == 1920 + assert Size.from_json({"width": 320, "height": 180}) == Size(320, 180) + + def test_invalid(self): + with pytest.raises(ValueError): + Size(0, 100) + + +class TestMisc: + def test_png_bytes_is_bytes(self): + png = PngBytes(b"\x89PNG") + assert isinstance(png, bytes) + + def test_capability_constants(self): + assert Capabilities.TIMELINE_EDIT == "timeline.edit" + assert Capabilities.RENDER_FRAME == "render.frame" + + def test_limits_defaults(self): + limits = Limits() + assert limits.max_frame_rate == 8.0 + assert limits.max_scan_frames == 64 + assert limits.to_json()["max_frame_width"] == 1920 + + def test_action_batch_json(self): + batch = ActionBatch( + label="AI 动作:place_clip", + actions=[ + Action( + tool="place_clip", + params={"time": {"num": 0, "den": 1}}, + call_id="call_1", + ) + ], + id="b1", + ) + data = batch.to_json() + assert data["label"] == "AI 动作:place_clip" + assert data["actions"][0]["tool"] == "place_clip" + assert data["actions"][0]["call_id"] == "call_1" + + def test_tool_result_carries_png(self): + png = PngBytes(b"\x89PNG") + result = ToolResult(tool="get_frame", ok=True, summary="帧", images=(png,)) + assert result.images == (png,) diff --git a/tests/mock_llm/__init__.py b/tests/mock_llm/__init__.py new file mode 100644 index 0000000..c6aeb45 --- /dev/null +++ b/tests/mock_llm/__init__.py @@ -0,0 +1,4 @@ +"""mock_llm:进程内 MockProvider / RecordingProvider(测试辅助包)。""" +from .provider import MockProvider, RecordingProvider + +__all__ = ["MockProvider", "RecordingProvider"] diff --git a/tests/mock_llm/provider.py b/tests/mock_llm/provider.py new file mode 100644 index 0000000..c30aa01 --- /dev/null +++ b/tests/mock_llm/provider.py @@ -0,0 +1,104 @@ +"""进程内 Mock LLM:脚本化回复 + 录制模式(CI 无 key 无网络跑通 agent loop 的底座)。 + +脚本文件格式(JSON 列表,每项一个 AssistantTurn):: + + [ + {"text": "", "tool_calls": [{"id": "call_1", "name": "get_frame", + "arguments": {"time": {"num": 1, "den": 1}}}]}, + {"text": "完成", "tool_calls": []} + ] +""" +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from quercus_core.providers.base import AssistantTurn, LLMProvider, Message, ToolCall, ToolSchema + + +def _turn_to_dict(turn: AssistantTurn) -> dict[str, Any]: + return { + "text": turn.text, + "tool_calls": [ + {"id": tc.id, "name": tc.name, "arguments": tc.arguments} + for tc in turn.tool_calls + ], + } + + +def _turn_from_dict(data: dict[str, Any]) -> AssistantTurn: + calls = tuple( + ToolCall(id=item["id"], name=item["name"], arguments=item.get("arguments") or {}) + for item in data.get("tool_calls") or [] + ) + return AssistantTurn(text=data.get("text", ""), tool_calls=calls) + + +class MockProvider(LLMProvider): + """按预置脚本依次返回 AssistantTurn;记录每次收到的消息供断言。""" + + name = "mock" + + def __init__( + self, + script: list[AssistantTurn] | None = None, + script_file: Path | str | None = None, + ) -> None: + self._script = list(script) if script is not None else [] + if script_file is not None: + self._script = self.load_script(script_file) + self._index = 0 + self.observed: list[list[Message]] = [] # 每次 generate 收到的消息历史 + + def generate( + self, + messages: list[Message], + tools: list[ToolSchema] | None = None, + ) -> AssistantTurn: + self.observed.append(list(messages)) + if self._index >= len(self._script): + return AssistantTurn(text="(mock: 脚本已耗尽,结束本轮)") + turn = self._script[self._index] + self._index += 1 + return turn + + @property + def exhausted(self) -> bool: + return self._index >= len(self._script) + + # ---- 脚本文件 ---- + + @staticmethod + def dump_script(turns: list[AssistantTurn], path: Path | str) -> Path: + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps([_turn_to_dict(t) for t in turns], ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return path + + @staticmethod + def load_script(path: Path | str) -> list[AssistantTurn]: + data = json.loads(Path(path).read_text(encoding="utf-8")) + return [_turn_from_dict(item) for item in data] + + +class RecordingProvider(LLMProvider): + """录制模式:包装真实(或 mock)provider,记录其响应序列。""" + + name = "recording" + + def __init__(self, inner: LLMProvider) -> None: + self._inner = inner + self.recorded: list[AssistantTurn] = [] + + def generate( + self, + messages: list[Message], + tools: list[ToolSchema] | None = None, + ) -> AssistantTurn: + turn = self._inner.generate(messages, tools=tools) + self.recorded.append(turn) + return turn diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..4aa2045 --- /dev/null +++ b/uv.lock @@ -0,0 +1,462 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'emscripten'", + "python_full_version < '3.12' or sys_platform != 'emscripten'", +] + +[manifest] +members = [ + "quercus", + "quercus-core", +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anthropic" +version = "0.125.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "docstring-parser" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/f8/6f0560884b5363848347bd640b6c1d04abc25e7aa61787a232f790c6b60a/anthropic-0.125.0.tar.gz", hash = "sha256:e0cdd336580cb7411c1cdab69f80973e9bf4bff7f8e08141811d46307d45c682", size = 1112593, upload-time = "2026-08-19T22:00:42.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/1a/b1bd30cda3790557e8791bec5922a6ec8fabb6fa8b008c76a39cf7be6152/anthropic-0.125.0-py3-none-any.whl", hash = "sha256:3486013602eca76d8b12540764e53654f02cf4951110bca86cf06e67428a9f21", size = 1184067, upload-time = "2026-08-19T22:00:44.596Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jiter" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c", size = 176431, upload-time = "2026-06-29T13:05:13.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/3f/fae6cc967d120ec89e31c5418a51176d8278b3087fbb384a9176754f353c/jiter-0.16.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:67fddeda1688f0cce2d2ae83ccf8a80f79936f2d2997d6cc2261f82fdb54a4d3", size = 309289, upload-time = "2026-06-29T13:02:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/97c6c3562c077f6247d6e6ce5c82562500b6316c0d928e97e106b7a1321a/jiter-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c90c0f63df322be920eda6ce622e3083d8906ba267f8220fe7873213b8b4430e", size = 315181, upload-time = "2026-06-29T13:02:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/7b/89/d8d073f8aa2667e46c6c0873f86fe4a512bba4293cc730f626a076211a62/jiter-0.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64c0203212098470032aabcde9356fc168f377aade3e43def61dfe17e92f2037", size = 340939, upload-time = "2026-06-29T13:02:55.412Z" }, + { url = "https://files.pythonhosted.org/packages/87/c9/db4fda3ed73fb864139305e935e5b8b38a5a24692a5a9dd356c22f1b9c8d/jiter-0.16.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12288303c9844e61e1651d02a9a6f6633e47d39f897d6991d1427161ce6b746e", size = 364932, upload-time = "2026-06-29T13:02:57.28Z" }, + { url = "https://files.pythonhosted.org/packages/a2/74/52b5e86241057f52ddd7c9a580f90effb51f9d06239f6fc612279b91a838/jiter-0.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cf109d010b4b05a105afb3d43be36a21322d345ad3111e13d15f680afef0e5b", size = 461132, upload-time = "2026-06-29T13:02:58.994Z" }, + { url = "https://files.pythonhosted.org/packages/a9/87/544a700f7447c1f31c5d7833821a4daa5683165c2d5a094fbf5b5800c3dc/jiter-0.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62c1b7fe1f77925acf5af68b6140b8810fa87dfd4dc0a9c8568ec2fa2a10429c", size = 374857, upload-time = "2026-06-29T13:03:00.455Z" }, + { url = "https://files.pythonhosted.org/packages/40/cd/0fcc3f7d39183674d5bfa9ec640faaeb506c60be7c8f94625dfba366e37c/jiter-0.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8597d23c87f59294f83bcb6229b9ed1fccee13dbba967b46930d2f1759466fee", size = 347053, upload-time = "2026-06-29T13:03:02.045Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ae/c7e64e7932ad597fa395b61440b249ada6366716e25c6e08dd2afbd021e6/jiter-0.16.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3126a5dbad56401989ac769aca0cb56005bfb3e2366eea0ca99d1a91c3c1ee03", size = 356153, upload-time = "2026-06-29T13:03:03.706Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/1c719044f14da814e1a060191ab19b96f3e99207bc5b4bfc6d6be34b3f80/jiter-0.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c4b4717bdb35ae456f831a6b08d01880fff399887a6bbc526a583a406e484eea", size = 393956, upload-time = "2026-06-29T13:03:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/3b/dc/7b2f303a2847207e265503853a2d964a55354cffd62a5f2936c155486798/jiter-0.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:adff21bc78edfe086c15eb495b900306076de378dc2337c132401fc39bd79c91", size = 521081, upload-time = "2026-06-29T13:03:06.886Z" }, + { url = "https://files.pythonhosted.org/packages/c2/5f/501cf6e1e09caeb420195179ffc6f62aca603f1220ec53fd80d0d70b3e56/jiter-0.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dab907db06fc593645e73109acf4581ba5b548897d28b9348dc41ddc8343b2d3", size = 552085, upload-time = "2026-06-29T13:03:08.339Z" }, + { url = "https://files.pythonhosted.org/packages/79/54/aa5be86520113b79455c3877f3d1f07a348098df4083ba3688e9537e52dd/jiter-0.16.0-cp311-cp311-win32.whl", hash = "sha256:560b2cf3fb03240cd34f27409a238547488708f05b7c3924f571a60422251ec7", size = 206755, upload-time = "2026-06-29T13:03:09.653Z" }, + { url = "https://files.pythonhosted.org/packages/64/ec/2feb893eb330bd69b413866f4d5daada33c3962f1c6f270c91ca2d87fdf9/jiter-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:e431cfc9caf44c1d5459ff77d4e64cbf85fddb6a35dad836a15c6a9ec23087c1", size = 199155, upload-time = "2026-06-29T13:03:10.979Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9c/ca040d94415048a3666fc237774df8151c96f8d2b661cbe3b184acc95876/jiter-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:2a8e9e39cf083016137aa5cadafe3188adc2ba6ba1fbf1e5d18889ad3e9ad056", size = 194403, upload-time = "2026-06-29T13:03:12.341Z" }, + { url = "https://files.pythonhosted.org/packages/83/2b/52ace16ed031354f0539749a49e4bf33797d82bea5137910835fa4b09793/jiter-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:67c3bc1760f8c99d805dcab4e644027142a53b1d5d861f18780ebdbd5d40b72a", size = 306943, upload-time = "2026-06-29T13:03:14.035Z" }, + { url = "https://files.pythonhosted.org/packages/94/2e/34957c2c1b661c252ba9bcc60ae0bddc27e0f7202c6073326a13c5390eec/jiter-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5af7780e4a26bd7d0d989592bf9ef12ebf806b74ab709223ecca37c749872ea9", size = 307779, upload-time = "2026-06-29T13:03:15.418Z" }, + { url = "https://files.pythonhosted.org/packages/88/6c/59bd309cab4460c54cf1079f3eb7fe7af6a4c895c5c957a53378693bad2b/jiter-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5bf78d0e05e45cfdd66558893938d59afe3d1b1a824a202039b20e607d25a72", size = 335826, upload-time = "2026-06-29T13:03:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8c/f5ef7b65f0df47afa16596969defb281ebb86e96df346d62be6fd853d620/jiter-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e", size = 362573, upload-time = "2026-06-29T13:03:18.781Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0b/ace4354da061ee38844a0c27dc2c21eecd27aea119e8da324bea987522d0/jiter-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a23f0e4f957e1be65752d2dfac9a5a06b1917af8dc85deb639c3b9d02e31290", size = 457979, upload-time = "2026-06-29T13:03:20.293Z" }, + { url = "https://files.pythonhosted.org/packages/55/40/c0253d3772eb9dcd8e6606ee9b2d53ec8e5b814589c47f140aa585f21eaa/jiter-0.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c22a488f7b9218e245a0025a9ba6b100e2e54700831cf4cf16833a27fba3ad01", size = 372302, upload-time = "2026-06-29T13:03:21.739Z" }, + { url = "https://files.pythonhosted.org/packages/a8/d2/4839422241aa12860ce597b20068727094ba0bc480723c74924ca5bad483/jiter-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46add52f4ad47a08bfb1219f3e673da972191489a33016edefdb5ea55bfa8c48", size = 343805, upload-time = "2026-06-29T13:03:23.384Z" }, + { url = "https://files.pythonhosted.org/packages/e2/59/e196888a05befdda7dbe299b722d56f2f6eec65402bc34c0a3306d595feb/jiter-0.16.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9c8a956fd72c2cf1e730d01ea080341f13aa0a97a4a33b51abebe725b7ae9ca9", size = 351107, upload-time = "2026-06-29T13:03:24.815Z" }, + { url = "https://files.pythonhosted.org/packages/ec/74/4cd9e0fca65232136400354b630fbfcd2de634e22ccbb96567725981b548/jiter-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:561926e0573ffe4a32498420a76d64b16c513e1ab413b9d28158a8764ac701e5", size = 388441, upload-time = "2026-06-29T13:03:26.266Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8c/554691e48bc711299c0a293dd8a6179e24b2d66a54dc295421fcf64569c0/jiter-0.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:44d019fa8cdaf89bf29c71b39e3712143fdd0ac76725c6ef954f9957a5ea8730", size = 516354, upload-time = "2026-06-29T13:03:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/a4/cb/01e9d69dc2cc6759d4f91e230b34489c4fdb2518992650633f9e20bece89/jiter-0.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0df91907609837f33341b8e6fe73b95991fdaa57caf1a0fbd343dffe826f386f", size = 547880, upload-time = "2026-06-29T13:03:29.534Z" }, + { url = "https://files.pythonhosted.org/packages/79/70/2953195f1c6ad00f49fa67e13df7e60acb3dd4f387101bc15abccddd905e/jiter-0.16.0-cp312-cp312-win32.whl", hash = "sha256:51d7b836acb0108d7c77df1742332cac2a1fa04a74d6dacec46e7091f0e91274", size = 203473, upload-time = "2026-06-29T13:03:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/05/2909a8b10699a4d560f8c502b6b2c5f3991b682b1922c1eedda242b225bd/jiter-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:1878349266f8ee36ecb1375cc5ba2f115f35fd9f0a1a4119e725e379126647f7", size = 196905, upload-time = "2026-06-29T13:03:32.472Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a9/6b82bb1c8d7790d602489b967b982a909e5d092875a6c2ade96444c8dfc5/jiter-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:2ed5738ae4af18271a51a528b8811b0cbfa4a1858de9d83359e4169855d6a331", size = 190618, upload-time = "2026-06-29T13:03:34.672Z" }, + { url = "https://files.pythonhosted.org/packages/91/c0/555fc60473d30d66894ba825e63615e3be7524fac23858356afa7a38906c/jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195", size = 306203, upload-time = "2026-06-29T13:03:36.243Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2b/c3eaf16f5d7c9bad66ea32f40a95bd169b29a91217fcc7f081375157e99c/jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09", size = 306489, upload-time = "2026-06-29T13:03:37.846Z" }, + { url = "https://files.pythonhosted.org/packages/96/3f/02fdfc6705cad96127d883af5c34e4867f554f29ec7705ec1a46156400a9/jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536", size = 335453, upload-time = "2026-06-29T13:03:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a6/e4bda5920d4b0d7c5dfb7174ce4a6b2e4d3e11c9162c452ef0eab4cdbdbd/jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450", size = 361625, upload-time = "2026-06-29T13:03:40.597Z" }, + { url = "https://files.pythonhosted.org/packages/b7/97/4e6b59b2c6e55cbb3e183595f81ad65dcfb21c915fee5e19e335df21bc55/jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e", size = 456958, upload-time = "2026-06-29T13:03:42.074Z" }, + { url = "https://files.pythonhosted.org/packages/15/e0/97e9557686d2f94f4b93786eccb7eed28e9228ad132ea8237f44727314a7/jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8", size = 372017, upload-time = "2026-06-29T13:03:43.658Z" }, + { url = "https://files.pythonhosted.org/packages/0f/94/db768b6938e0df35c86beeba3dfbbb025c9ee5c19e1aa271f2396e50864d/jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026", size = 343320, upload-time = "2026-06-29T13:03:45.226Z" }, + { url = "https://files.pythonhosted.org/packages/c1/d6/5a59d938244a30735fe62d9433fd325f9021ea29d89780ea4596ea93bc89/jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053", size = 350520, upload-time = "2026-06-29T13:03:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/c4a857f49c9af125f6bbcac7e3eee7f7978ed89682833062e2dbf62576b1/jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e", size = 387550, upload-time = "2026-06-29T13:03:48.361Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d6/5fbc2f7d6b67b754caa61a993a2e626e815dec47ffc2f9e35f01adfebec7/jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb", size = 515424, upload-time = "2026-06-29T13:03:49.881Z" }, + { url = "https://files.pythonhosted.org/packages/ed/54/284f0164b64a5fed915fea6ba7e9ba9b3d8d37c67d59cf2e3bb99d45cdfe/jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84", size = 546981, upload-time = "2026-06-29T13:03:51.363Z" }, + { url = "https://files.pythonhosted.org/packages/13/c5/2a467585a576594384e1d2c43e1224deaafc085f24e243529cf98beef8e1/jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e", size = 202853, upload-time = "2026-06-29T13:03:53.015Z" }, + { url = "https://files.pythonhosted.org/packages/88/6a/de61d04b9eec69c71719968d2f716532a3bc121170c44a39e14979c6be81/jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd", size = 196160, upload-time = "2026-06-29T13:03:54.447Z" }, + { url = "https://files.pythonhosted.org/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a", size = 189862, upload-time = "2026-06-29T13:03:55.754Z" }, + { url = "https://files.pythonhosted.org/packages/a7/89/bc4f1b57d5da938fd344a466396541e586d161320d70bffd929aaafcd8f4/jiter-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee", size = 308239, upload-time = "2026-06-29T13:03:57.205Z" }, + { url = "https://files.pythonhosted.org/packages/65/7a/c415453e5213001bf3b411ff65dec3d303b0e76a4a2cfea9768cd4960994/jiter-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2", size = 308928, upload-time = "2026-06-29T13:03:58.643Z" }, + { url = "https://files.pythonhosted.org/packages/11/fc/1f4fb7ebf9a724c7741994f4aae18fba1e2f3133df14521a79194952c34a/jiter-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883", size = 336998, upload-time = "2026-06-29T13:04:00.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/8d/72cadaac05ccfa7cc3a0a2232862e6c72443ca40cf300ba8b57f9f18b69b/jiter-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898", size = 362112, upload-time = "2026-06-29T13:04:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/58/4a/c4b0d5f651fda90a24ffce9f8d56cde462a2e09d31ae3de3c68cef34c04e/jiter-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5", size = 459807, upload-time = "2026-06-29T13:04:03.214Z" }, + { url = "https://files.pythonhosted.org/packages/80/58/ef77879ea9aa56b50824edc5a445e226422c7a8d211f3fd2a56bcb9493cf/jiter-0.16.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567", size = 373181, upload-time = "2026-06-29T13:04:04.629Z" }, + { url = "https://files.pythonhosted.org/packages/49/2e/ffbc3f254e4d8a66da3062c624a7df4b7c2b2cf9e1fe43cf394b3e104041/jiter-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69", size = 344927, upload-time = "2026-06-29T13:04:06.067Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f6/0be5dc6d64a89f80aa8fec984f94dedb2973e251edcae55841d60786d578/jiter-0.16.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29", size = 352754, upload-time = "2026-06-29T13:04:07.477Z" }, + { url = "https://files.pythonhosted.org/packages/da/6e/7d31243b3b91cd261dd19e9d3557fc3251a80883d3d8049c86174e7ab7af/jiter-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93", size = 390553, upload-time = "2026-06-29T13:04:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/25/33/51ae371fde3c88897520f62b4d5f8b27ad7103e2bb10812ff52195609853/jiter-0.16.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a", size = 516900, upload-time = "2026-06-29T13:04:10.407Z" }, + { url = "https://files.pythonhosted.org/packages/a0/45/6449b3d123ea439ba79507c657288f461d55049e7bcbdc2cf8eb8210f491/jiter-0.16.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00", size = 548754, upload-time = "2026-06-29T13:04:12.046Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e7/fd2fb11ae3e2649333da3aa170d04d7b3000bbdc3b270f6513382fdf4e04/jiter-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe", size = 122381, upload-time = "2026-06-29T13:04:13.413Z" }, + { url = "https://files.pythonhosted.org/packages/26/80/f0b147a62c315a164ed2168908286ca302310824c218d3aae52b06c0c9a9/jiter-0.16.0-cp314-cp314-win32.whl", hash = "sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106", size = 204578, upload-time = "2026-06-29T13:04:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e6/4758a14304b4523a6f5adb2419340086aa3593bd4327c2b25b5948a90548/jiter-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8", size = 198154, upload-time = "2026-06-29T13:04:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/26/be/41fa54a2e7ea41d6c99f1dc5b1f0fd4cb474680304b5d268dd518e81da3a/jiter-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585", size = 191458, upload-time = "2026-06-29T13:04:17.707Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/59127338b86d9fe4d99418f5a15118bea778103ee0fe9d9dd7e0af174e95/jiter-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207", size = 316739, upload-time = "2026-06-29T13:04:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/2d/95/49461034d5388196d3dabf98748935f017b7785d8f3f5349f834bcc4ed0d/jiter-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818", size = 340911, upload-time = "2026-06-29T13:04:21.257Z" }, + { url = "https://files.pythonhosted.org/packages/cd/97/a4369f2fb82cb3dda13b98622f31249b2e014b223fe64ee534413ad72294/jiter-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3", size = 361747, upload-time = "2026-06-29T13:04:22.677Z" }, + { url = "https://files.pythonhosted.org/packages/28/51/49b6ed456261646e1906016a6760367a28aacd3c24805e4e5fe64116c1db/jiter-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284", size = 460225, upload-time = "2026-06-29T13:04:24.441Z" }, + { url = "https://files.pythonhosted.org/packages/33/b5/5689aff4f66c5b60be63106e591dbfcba2190df97d2c9c7cf052361ddb98/jiter-0.16.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929", size = 373169, upload-time = "2026-06-29T13:04:25.884Z" }, + { url = "https://files.pythonhosted.org/packages/a2/96/3ae1b85ee0d6d6cab254fb7f8da018272b932bbf2d69b07e98aa2a96c746/jiter-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620", size = 350332, upload-time = "2026-06-29T13:04:27.302Z" }, + { url = "https://files.pythonhosted.org/packages/15/32/c99d7bafd78986556c95bf60ce84c6cc98786eac56066c12d7f828bb6747/jiter-0.16.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af", size = 353377, upload-time = "2026-06-29T13:04:28.731Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/f99a8e571287c3dec766bcc18528bbe8e8fb5365522ab5e6d64c93e87066/jiter-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e", size = 387746, upload-time = "2026-06-29T13:04:30.319Z" }, + { url = "https://files.pythonhosted.org/packages/75/69/c78a5b3f71040e34eb5917df26fb7ae9a2174cad1ccbf277512507c53a6e/jiter-0.16.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077", size = 517292, upload-time = "2026-06-29T13:04:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f7/095b38eda4c70d03651c403f29a5590f16d12ddc5d544aac9f9cddf72277/jiter-0.16.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734", size = 549259, upload-time = "2026-06-29T13:04:33.721Z" }, + { url = "https://files.pythonhosted.org/packages/2e/c5/6a0207d90e5f656d95af98ebd0934f382d37674416f215aeda2ff8063e51/jiter-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf", size = 206523, upload-time = "2026-06-29T13:04:35.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/31/c757d5f30a8980fd945ce7b98be10be9e4ff59c7c42f5fd86804c2e87db8/jiter-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db", size = 200366, upload-time = "2026-06-29T13:04:36.61Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a2/d88de6d313d734a544a7901353ad5db67cb38dcfcd91713b7979dafc345d/jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce", size = 190516, upload-time = "2026-06-29T13:04:38.004Z" }, + { url = "https://files.pythonhosted.org/packages/06/d3/8e278946d43eeca2585b4dd0834a887cd71136329b837f3a16ed86a8b4b0/jiter-0.16.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:850ccb1d7eedb4200f4014b1c0e8a577de114fc3cd88faad646dcc9bc4bb12ad", size = 304518, upload-time = "2026-06-29T13:05:00.172Z" }, + { url = "https://files.pythonhosted.org/packages/72/43/28d4ef495028bf0506a413d4db3f4eb3e7288a382e0f065f306a17bbeb5e/jiter-0.16.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:e34e97bda77eb63242a410243c071e28ac7e0d8c0948c5ee658498690a4b2f2f", size = 310207, upload-time = "2026-06-29T13:05:02.123Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ca/c366b1012da1d640de975d9683acd44e4d150d9068845d0ca2610435253f/jiter-0.16.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7dc85ea77d4abbae8bad0d3538678aedee75bceec4e2f6c8dfb1c74772e5aa5", size = 342771, upload-time = "2026-06-29T13:05:03.55Z" }, + { url = "https://files.pythonhosted.org/packages/16/52/50cc4056fc1ae02e7154704e7ecc89df0afb8300222cfe8a52d3f67e4730/jiter-0.16.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17ca7fae79f6d99cd9a042b75f917eaada7b895cfc7dd2ee3a16089dcaec7a85", size = 346468, upload-time = "2026-06-29T13:05:05.452Z" }, + { url = "https://files.pythonhosted.org/packages/98/ab/664fd8c4be028b2bedd3d2ff08769c4ede23d0dbc87a77c62384a0515b5d/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:f17d61a28b4b3e0e3e2ba98490c70501403b4d196f78732439160e7fd3678127", size = 303106, upload-time = "2026-06-29T13:05:07.118Z" }, + { url = "https://files.pythonhosted.org/packages/1a/07/421f1d5b65493a76e16027b848aba6a7d28073ae75944fa4289cc914d39f/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:96e38eea538c8ddf853a35727c7be0741c76c13f04148ac5c116222f50ece3b3", size = 304658, upload-time = "2026-06-29T13:05:08.708Z" }, + { url = "https://files.pythonhosted.org/packages/0a/db/bba1155f01a01c3c37a89425d571da751bbedf5c54247b831a04cb971798/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d284fb8d94d5855d60c44fefcab4bf966f1da6fada73992b01f6f0c9bc0c6702", size = 339719, upload-time = "2026-06-29T13:05:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2", size = 343885, upload-time = "2026-06-29T13:05:12.087Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "quercus" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "quercus-core" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [{ name = "quercus-core", editable = "core" }] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8.0" }] + +[[package]] +name = "quercus-core" +version = "0.1.0" +source = { editable = "core" } +dependencies = [ + { name = "anthropic" }, + { name = "httpx" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "anthropic", specifier = ">=0.40,<1.0" }, + { name = "httpx", specifier = ">=0.27,<1.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +]