feat: ollama, embedings and more

This commit is contained in:
2026-04-30 13:59:46 -04:00
parent 468c6f4075
commit f4e1e18541
25 changed files with 3042 additions and 785 deletions
+2 -6
View File
@@ -3,18 +3,14 @@
# Python
__pycache__/
.pytest_cache/
.tmp/
.venv/
*.pyc
# Agent runtime
koboldcpp.exe
model.gguf
*.gguf
data/
src/LocalDiplomacy.Agent/data/
src/LocalDiplomacy.Agent/koboldcpp.exe
src/LocalDiplomacy.Agent/model.gguf
src/LocalDiplomacy.Agent/config.yaml
src/LocalDiplomacy.Agent/data/
# .NET/Bannerlord build output
bin/
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
22432
+21 -22
View File
@@ -2,31 +2,30 @@ server:
host: "127.0.0.1"
port: 8766
koboldcpp:
autostart: true
executable_path: "./koboldcpp.exe"
model_path: "./model.gguf"
base_url: "http://127.0.0.1:5001"
ollama:
base_url: "http://127.0.0.1:11434"
chat_path: "/v1/chat/completions"
model: "local-model"
port: 5001
context_size: 8192
extra_args:
- "--jinja"
- "--jinjatools"
startup_timeout_seconds: 180
model: "llama3.1:8b"
timeout_seconds: 120
tool_mode: "openai_tools"
json_repair_retry: true
auto_pull_models: true
memory:
provider: "disabled"
vector_store: "qdrant"
qdrant_host: "127.0.0.1"
qdrant_port: 6333
collection: "localdiplomacy_memories"
embedder_provider: "ollama"
embedder_model: "nomic-embed-text"
provider: "sqlite"
sqlite_path: "./data/localdiplomacy.sqlite3"
embedding_provider: "ollama"
embedding_model: "nomic-embed-text"
embedding_auto_pull: true
max_prompt_memories: 8
vector_index:
mode: "embedded"
path: "./data/qdrant"
host: "127.0.0.1"
port: 6333
executable_path: "./qdrant/qdrant.exe"
autostart: false
startup_timeout_seconds: 30
fallback_mode: "disabled"
event_log:
sqlite_path: "./data/localdiplomacy_events.sqlite3"
@@ -35,5 +34,5 @@ generation:
temperature: 0.7
max_tokens: 800
# Useful for Qwen3-style thinking models when tool calls must be machine-readable.
suppress_thinking: false
suppress_thinking: true
suppress_thinking_token: "/no_think"
@@ -1,11 +1,11 @@
from __future__ import annotations
import json
import re
import traceback
from copy import deepcopy
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import FastAPI, Request
@@ -24,19 +24,27 @@ from .contracts import (
WorldTickResponse,
)
from .event_log import EventLog
from .koboldcpp_client import KoboldCppClient
from .koboldcpp_process import KoboldCppProcess
from .embeddings import create_embedder
from .memory import MemoryStore
from .ollama_client import OllamaClient
from .tools import ToolRegistry
from .vector_index import VectorIndex
class AppState:
def __init__(self) -> None:
self.config = load_config()
self.event_log = EventLog(resolve_runtime_path(self.config.event_log.sqlite_path))
self.memory = MemoryStore(self.config.memory)
self.kobold_process = KoboldCppProcess(self.config.koboldcpp, Path.cwd())
self.kobold_client = KoboldCppClient(self.config)
self.embedder = create_embedder(
self.config.memory.embedding_provider,
self.config.memory.embedding_model,
base_url=self.config.ollama.base_url,
timeout_seconds=self.config.ollama.timeout_seconds,
auto_pull=self.config.memory.embedding_auto_pull,
)
self.vector_index = VectorIndex(self.config.vector_index, self.embedder)
self.memory = MemoryStore(self.config.memory, self.vector_index)
self.llm_client = OllamaClient(self.config)
self.recent_errors: list[str] = []
self.ui_logs: list[dict[str, str]] = []
@@ -56,17 +64,18 @@ state = AppState()
@asynccontextmanager
async def lifespan(_: FastAPI):
state.vector_index.initialize()
state.memory.initialize()
state.log("info", "LocalDiplomacy.Agent starting.")
try:
state.kobold_process.ensure_started()
state.log("info", f"KoboldCpp endpoint configured as {state.config.koboldcpp.base_url}.")
except Exception as exc: # Keep service up so /health can explain the problem.
state.recent_errors.append(str(exc))
state.log("error", str(exc))
if state.llm_client.is_reachable():
state.log("info", f"Ollama endpoint configured as {state.config.ollama.base_url}.")
else:
message = state.llm_client.last_error or f"Ollama is unreachable at {state.config.ollama.base_url}."
state.recent_errors.append(message)
state.log("error", message)
yield
state.log("info", "LocalDiplomacy.Agent stopping.")
state.kobold_process.stop()
state.vector_index.stop()
app = FastAPI(title="LocalDiplomacy.Agent", version=__version__, lifespan=lifespan)
@@ -91,12 +100,12 @@ def dashboard_state() -> JSONResponse:
"version": __version__,
"health": health_data,
"debug": debug_data,
"koboldcpp": {
"base_url": state.config.koboldcpp.base_url,
"chat_path": state.config.koboldcpp.chat_path,
"model": state.config.koboldcpp.model,
"autostart": state.config.koboldcpp.autostart,
"timeout_seconds": state.config.koboldcpp.timeout_seconds,
"llm": {
"provider": "ollama",
"base_url": state.config.ollama.base_url,
"chat_path": state.config.ollama.chat_path,
"model": state.config.ollama.model,
"timeout_seconds": state.config.ollama.timeout_seconds,
},
"generation": state.config.generation.model_dump(mode="json"),
"logs": state.ui_logs[-100:],
@@ -104,48 +113,45 @@ def dashboard_state() -> JSONResponse:
)
@app.post("/api/koboldcpp")
async def update_koboldcpp_settings(request: Request) -> JSONResponse:
@app.post("/api/ollama")
async def update_ollama_settings(request: Request) -> JSONResponse:
payload = await request.json()
base_url = str(payload.get("base_url") or "").strip().rstrip("/")
model = str(payload.get("model") or "").strip()
timeout_seconds = payload.get("timeout_seconds")
autostart = payload.get("autostart")
if not base_url.startswith(("http://", "https://")):
return JSONResponse({"ok": False, "error": "KoboldCpp API URL must start with http:// or https://."}, status_code=400)
return JSONResponse({"ok": False, "error": "Ollama API URL must start with http:// or https://."}, status_code=400)
state.config.koboldcpp.base_url = base_url
state.config.ollama.base_url = base_url
if model:
state.config.koboldcpp.model = model
state.config.ollama.model = model
if isinstance(timeout_seconds, (int, float)) and timeout_seconds > 0:
state.config.koboldcpp.timeout_seconds = int(timeout_seconds)
if isinstance(autostart, bool):
state.config.koboldcpp.autostart = autostart
state.config.ollama.timeout_seconds = int(timeout_seconds)
save_config(state.config)
state.log("info", f"KoboldCpp API settings updated: {state.config.koboldcpp.base_url}, model {state.config.koboldcpp.model}.")
state.log("info", f"Ollama API settings updated: {state.config.ollama.base_url}, model {state.config.ollama.model}.")
return JSONResponse({"ok": True})
@app.post("/api/koboldcpp/ping")
def ping_koboldcpp() -> JSONResponse:
reachable = state.kobold_process.is_reachable()
state.log("info" if reachable else "warning", f"KoboldCpp ping {'succeeded' if reachable else 'failed'} for {state.config.koboldcpp.base_url}.")
return JSONResponse({"ok": reachable, "base_url": state.config.koboldcpp.base_url})
@app.post("/api/ollama/ping")
def ping_ollama() -> JSONResponse:
reachable = state.llm_client.is_reachable()
state.log("info" if reachable else "warning", f"Ollama ping {'succeeded' if reachable else 'failed'} for {state.config.ollama.base_url}.")
return JSONResponse({"ok": reachable, "base_url": state.config.ollama.base_url})
@app.get("/health", response_model=HealthResponse)
def health() -> HealthResponse:
errors = list(state.recent_errors[-5:])
kobold = "reachable" if state.kobold_process.is_reachable() else "unreachable"
if kobold == "unreachable":
errors.extend(state.kobold_process.validate_runtime_files())
status = "ok" if not errors and kobold == "reachable" else "degraded"
llm = "reachable" if state.llm_client.is_reachable() else "unreachable"
if llm == "unreachable" and state.llm_client.last_error:
errors.append(state.llm_client.last_error)
status = "ok" if not errors and llm == "reachable" else "degraded"
return HealthResponse(
status=status,
agent_version=__version__,
koboldcpp=kobold,
llm=llm,
memory=state.memory.status,
event_log="ok",
errors=errors,
@@ -155,7 +161,7 @@ def health() -> HealthResponse:
@app.get("/debug/status", response_model=DebugStatusResponse)
def debug_status() -> DebugStatusResponse:
return DebugStatusResponse(
last_koboldcpp_latency_ms=state.kobold_client.last_latency_ms,
last_llm_latency_ms=state.llm_client.last_latency_ms,
memory_count_estimate=state.memory.count_estimate(),
queued_action_count=0,
recent_errors=state.recent_errors[-10:],
@@ -171,7 +177,17 @@ async def conversation_respond(request: ConversationRequest) -> ConversationResp
campaign_id=request.campaign_id,
turn_id=request.turn_id,
)
registry = ToolRegistry(state.memory, state.event_log)
registry = ToolRegistry(
state.memory,
state.event_log,
{
"save_id": request.save_id,
"campaign_id": request.campaign_id,
"character_id": request.npc.id,
"kingdom_id": request.npc.kingdom_id,
"player_id": request.player.id,
},
)
tool_schemas = _select_tool_schemas(request, registry)
forced_tool_name = _forced_tool_name(tool_schemas)
messages = _build_action_messages(request) if forced_tool_name else _build_messages(request)
@@ -235,6 +251,9 @@ def _build_messages(request: ConversationRequest) -> list[dict[str, str]]:
"Stay grounded in the supplied game state. Do not invent entity IDs. "
"Use the provided tools only through the API tool-calling mechanism. "
"Never write JSON arrays, XML tags, pseudo-functions, or tool calls in your visible message content. "
"Never explain your reasoning, compare candidate replies, or mention the game state packet. "
"Write exactly one concise line of NPC dialogue in the NPC's voice. "
"Do not write the player's response. "
"If an action is needed, call propose_game_action. Otherwise, answer as the NPC in natural language. "
"Proposed actions are requests only; the game validates them before anything happens."
)
@@ -256,7 +275,7 @@ def _build_messages(request: ConversationRequest) -> list[dict[str, str]]:
"role": "user",
"content": (
"Game state packet follows as JSON. Use it as context, then respond to the player. "
"Visible content must be ordinary NPC dialogue, not JSON.\n\n"
"Visible content must be exactly one ordinary NPC dialogue line, not JSON, analysis, or alternatives.\n\n"
f"{json.dumps(packet, ensure_ascii=False)}"
),
},
@@ -394,7 +413,7 @@ async def _run_tool_loop(
tool_choice: str | dict[str, Any] | None = None
if index == 0 and forced_tool_name:
tool_choice = {"type": "function", "function": {"name": forced_tool_name}}
data = await state.kobold_client.chat(messages, tool_schemas, tool_choice=tool_choice) or {}
data = await state.llm_client.chat(messages, tool_schemas, tool_choice=tool_choice) or {}
choices = data.get("choices") or [{}]
choice = choices[0] or {}
message = choice.get("message") or {}
@@ -403,7 +422,7 @@ async def _run_tool_loop(
content = str(message.get("content") or "").strip()
if _looks_like_pseudo_tool_content(content):
return await _repair_visible_response(messages, content)
return content
return _clean_visible_response(content)
messages.append(message)
for call in tool_calls:
@@ -431,6 +450,59 @@ def _looks_like_pseudo_tool_content(content: str) -> bool:
)
def _clean_visible_response(content: str) -> str:
cleaned = _strip_reasoning_markers(content).strip()
quoted = re.findall(r'"([^"\n]{1,320})"', cleaned)
if quoted:
cleaned = quoted[-1].strip()
lines = [line.strip() for line in cleaned.splitlines() if line.strip()]
rejected_prefixes = (
"okay",
"the user",
"my task",
"let me",
"looking at",
"the correct",
"the most",
"this response",
"therefore",
"i need to",
"i should",
)
dialogue_lines = [
line.strip('"')
for line in lines
if not line.lower().startswith(rejected_prefixes)
and "json" not in line.lower()
and "game state" not in line.lower()
]
if dialogue_lines:
cleaned = dialogue_lines[0]
sentences = re.split(r"(?<=[.!?])\s+", cleaned)
if sentences and len(cleaned) > 240:
cleaned = sentences[0]
return cleaned.strip().strip('"') or "Well met."
def _strip_reasoning_markers(content: str) -> str:
markers = (
"<think>",
"</think>",
"Okay,",
"The most appropriate response is:",
"The correct response",
"Therefore,",
)
cleaned = content
if "</think>" in cleaned:
cleaned = cleaned.split("</think>", 1)[1]
for marker in markers[:2]:
cleaned = cleaned.replace(marker, "")
return cleaned.strip()
async def _repair_visible_response(messages: list[dict[str, Any]], bad_content: str) -> str:
repair_messages = [
*messages,
@@ -447,14 +519,14 @@ async def _repair_visible_response(messages: list[dict[str, Any]], bad_content:
),
},
]
data = await state.kobold_client.chat(repair_messages, [])
data = await state.llm_client.chat(repair_messages, [])
choices = data.get("choices") or [{}]
choice = choices[0] or {}
message = choice.get("message") or {}
repaired = str(message.get("content") or "").strip()
if _looks_like_pseudo_tool_content(repaired):
return "I understand the matter, but I will not move until the terms are made plain."
return repaired
return _clean_visible_response(repaired)
DASHBOARD_HTML = """
@@ -609,22 +681,22 @@ DASHBOARD_HTML = """
<h2>Status</h2>
<div class="status">
<div class="row"><span>Agent</span><span class="value" id="agentStatus">...</span></div>
<div class="row"><span>KoboldCpp</span><span class="value" id="koboldStatus">...</span></div>
<div class="row"><span>Ollama</span><span class="value" id="llmStatus">...</span></div>
<div class="row"><span>Memory</span><span class="value" id="memoryStatus">...</span></div>
<div class="row"><span>Last latency</span><span class="value" id="latency">...</span></div>
</div>
</section>
<section style="margin-top:18px">
<h2>KoboldCpp API</h2>
<h2>Ollama API</h2>
<label for="baseUrl">API address</label>
<input id="baseUrl" placeholder="http://127.0.0.1:5001">
<input id="baseUrl" placeholder="http://127.0.0.1:11434">
<label for="model">Model id</label>
<input id="model" placeholder="local-model">
<label for="timeout">Timeout seconds</label>
<input id="timeout" type="number" min="1" step="1">
<div class="buttons">
<button class="primary" onclick="saveSettings()">Save</button>
<button onclick="pingKobold()">Ping</button>
<button onclick="pingOllama()">Ping</button>
</div>
</section>
</div>
@@ -640,19 +712,19 @@ DASHBOARD_HTML = """
const response = await fetch('/api/dashboard');
const data = await response.json();
const agentOk = data.health.status === 'ok';
const koboldOk = data.health.koboldcpp === 'reachable';
const llmOk = data.health.llm === 'reachable';
document.getElementById('headline').textContent = agentOk ? 'Ready for Bannerlord' : 'Needs attention';
document.getElementById('headline').className = agentOk ? 'ok' : 'bad';
document.getElementById('agentStatus').textContent = data.health.status;
document.getElementById('agentStatus').className = 'value ' + (agentOk ? 'ok' : 'bad');
document.getElementById('koboldStatus').textContent = data.health.koboldcpp;
document.getElementById('koboldStatus').className = 'value ' + (koboldOk ? 'ok' : 'bad');
document.getElementById('llmStatus').textContent = data.health.llm;
document.getElementById('llmStatus').className = 'value ' + (llmOk ? 'ok' : 'bad');
document.getElementById('memoryStatus').textContent = data.health.memory;
document.getElementById('latency').textContent = data.debug.last_koboldcpp_latency_ms === null ? 'none' : data.debug.last_koboldcpp_latency_ms + ' ms';
document.getElementById('latency').textContent = data.debug.last_llm_latency_ms === null ? 'none' : data.debug.last_llm_latency_ms + ' ms';
if (!loadedConfig) {
document.getElementById('baseUrl').value = data.koboldcpp.base_url;
document.getElementById('model').value = data.koboldcpp.model;
document.getElementById('timeout').value = data.koboldcpp.timeout_seconds;
document.getElementById('baseUrl').value = data.llm.base_url;
document.getElementById('model').value = data.llm.model;
document.getElementById('timeout').value = data.llm.timeout_seconds;
loadedConfig = true;
}
const logs = document.getElementById('logs');
@@ -668,22 +740,21 @@ DASHBOARD_HTML = """
base_url: document.getElementById('baseUrl').value,
model: document.getElementById('model').value,
timeout_seconds: Number(document.getElementById('timeout').value),
autostart: false
};
const response = await fetch('/api/koboldcpp', {
const response = await fetch('/api/ollama', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
if (!response.ok) {
const data = await response.json();
alert(data.error || 'Failed to save KoboldCpp settings.');
alert(data.error || 'Failed to save Ollama settings.');
}
await refresh();
}
async function pingKobold() {
await fetch('/api/koboldcpp/ping', {method: 'POST'});
async function pingOllama() {
await fetch('/api/ollama/ping', {method: 'POST'});
await refresh();
}
@@ -15,30 +15,32 @@ class ServerConfig(BaseModel):
port: int = 8766
class KoboldCppConfig(BaseModel):
autostart: bool = True
executable_path: str = "./koboldcpp.exe"
model_path: str = "./model.gguf"
base_url: str = "http://127.0.0.1:5001"
class OllamaConfig(BaseModel):
base_url: str = "http://127.0.0.1:11434"
chat_path: str = "/v1/chat/completions"
model: str = "local-model"
port: int = 5001
context_size: int = 8192
extra_args: list[str] = Field(default_factory=lambda: ["--jinja", "--jinjatools"])
startup_timeout_seconds: int = 180
model: str = "llama3.1:8b"
timeout_seconds: int = 120
tool_mode: str = "openai_tools"
json_repair_retry: bool = True
auto_pull_models: bool = True
class MemoryConfig(BaseModel):
provider: str = "disabled"
vector_store: str = "qdrant"
qdrant_host: str = "127.0.0.1"
qdrant_port: int = 6333
collection: str = "localdiplomacy_memories"
embedder_provider: str = "ollama"
embedder_model: str = "nomic-embed-text"
provider: str = "sqlite"
sqlite_path: str = "./data/localdiplomacy.sqlite3"
embedding_provider: str = "ollama"
embedding_model: str = "nomic-embed-text"
embedding_auto_pull: bool = True
max_prompt_memories: int = 8
class VectorIndexConfig(BaseModel):
mode: str = "embedded"
path: str = "./data/qdrant"
host: str = "127.0.0.1"
port: int = 6333
executable_path: str = "./qdrant/qdrant.exe"
autostart: bool = False
startup_timeout_seconds: int = 30
fallback_mode: str = "disabled"
class EventLogConfig(BaseModel):
@@ -48,14 +50,15 @@ class EventLogConfig(BaseModel):
class GenerationConfig(BaseModel):
temperature: float = 0.7
max_tokens: int = 800
suppress_thinking: bool = False
suppress_thinking: bool = True
suppress_thinking_token: str = "/no_think"
class AppConfig(BaseModel):
server: ServerConfig = Field(default_factory=ServerConfig)
koboldcpp: KoboldCppConfig = Field(default_factory=KoboldCppConfig)
ollama: OllamaConfig = Field(default_factory=OllamaConfig)
memory: MemoryConfig = Field(default_factory=MemoryConfig)
vector_index: VectorIndexConfig = Field(default_factory=VectorIndexConfig)
event_log: EventLogConfig = Field(default_factory=EventLogConfig)
generation: GenerationConfig = Field(default_factory=GenerationConfig)
@@ -115,14 +115,14 @@ class ActionResultResponse(BaseModel):
class HealthResponse(BaseModel):
status: Literal["ok", "degraded", "error"]
agent_version: str
koboldcpp: str
llm: str
memory: str
event_log: str
errors: list[str] = Field(default_factory=list)
class DebugStatusResponse(BaseModel):
last_koboldcpp_latency_ms: int | None = None
last_llm_latency_ms: int | None = None
memory_count_estimate: int | None = None
queued_action_count: int = 0
recent_errors: list[str] = Field(default_factory=list)
@@ -0,0 +1,140 @@
from __future__ import annotations
import hashlib
import math
import re
from dataclasses import dataclass
from typing import Protocol
import httpx
_TOKEN_PATTERN = re.compile(r"[A-Za-z0-9_]+")
class Embedder(Protocol):
@property
def dimensions(self) -> int:
...
def embed(self, text: str) -> list[float]:
...
@dataclass(frozen=True)
class HashingEmbedder:
"""Deterministic local embedding fallback used until a real local embedder is configured."""
dimensions: int = 384
def embed(self, text: str) -> list[float]:
vector = [0.0] * self.dimensions
for token in _TOKEN_PATTERN.findall(text.lower()):
digest = hashlib.blake2b(token.encode("utf-8"), digest_size=8).digest()
bucket = int.from_bytes(digest[:4], "little") % self.dimensions
sign = 1.0 if digest[4] & 1 else -1.0
vector[bucket] += sign
magnitude = math.sqrt(sum(value * value for value in vector))
if magnitude == 0:
return vector
return [value / magnitude for value in vector]
@dataclass(frozen=True)
class OllamaEmbedder:
model: str
base_url: str = "http://127.0.0.1:11434"
timeout_seconds: int = 60
auto_pull: bool = True
_model_checked: bool = False
@property
def dimensions(self) -> int:
return len(self.embed("dimension probe"))
def embed(self, text: str) -> list[float]:
self._ensure_model()
response = httpx.post(
f"{self.base_url}/api/embed",
json={"model": self.model, "input": text},
timeout=self.timeout_seconds,
)
response.raise_for_status()
data = response.json()
embeddings = data.get("embeddings")
if isinstance(embeddings, list) and embeddings:
return [float(value) for value in embeddings[0]]
embedding = data.get("embedding")
if isinstance(embedding, list):
return [float(value) for value in embedding]
raise ValueError("Ollama embed response did not include an embedding.")
def _ensure_model(self) -> None:
if self._model_checked:
return
object.__setattr__(self, "_model_checked", True)
if not self.auto_pull:
return
try:
tags = httpx.get(f"{self.base_url}/api/tags", timeout=2)
tags.raise_for_status()
models = tags.json().get("models") or []
names = {str(model.get("name") or model.get("model")) for model in models if model.get("name") or model.get("model")}
if self.model in names:
return
except httpx.HTTPError:
return
response = httpx.post(
f"{self.base_url}/api/pull",
json={"model": self.model, "stream": False},
timeout=None,
)
response.raise_for_status()
@dataclass
class FallbackEmbedder:
primary: Embedder
fallback: Embedder
_using_fallback: bool = False
@property
def dimensions(self) -> int:
if self._using_fallback:
return self.fallback.dimensions
try:
return self.primary.dimensions
except Exception:
self._using_fallback = True
return self.fallback.dimensions
def embed(self, text: str) -> list[float]:
if self._using_fallback:
return self.fallback.embed(text)
try:
return self.primary.embed(text)
except Exception:
self._using_fallback = True
return self.fallback.embed(text)
def create_embedder(
provider: str,
model: str,
base_url: str = "http://127.0.0.1:11434",
timeout_seconds: int = 60,
auto_pull: bool = True,
) -> Embedder:
normalized = provider.lower().strip()
if normalized in {"hashing", "local_hash", "deterministic"}:
return HashingEmbedder()
if normalized == "ollama":
return FallbackEmbedder(
primary=OllamaEmbedder(model=model, base_url=base_url, timeout_seconds=timeout_seconds, auto_pull=auto_pull),
fallback=HashingEmbedder(),
)
return HashingEmbedder()
@@ -1,57 +0,0 @@
from __future__ import annotations
import time
from typing import Any
import httpx
from .config import AppConfig
class KoboldCppClient:
def __init__(self, config: AppConfig):
self.config = config
self.last_latency_ms: int | None = None
async def chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
tool_choice: str | dict[str, Any] | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"model": self.config.koboldcpp.model,
"messages": self._prepare_messages(messages),
"temperature": self.config.generation.temperature,
"max_tokens": self.config.generation.max_tokens,
}
if tools:
payload["tools"] = tools
payload["tool_choice"] = tool_choice or "auto"
url = f"{self.config.koboldcpp.base_url}{self.config.koboldcpp.chat_path}"
started = time.perf_counter()
async with httpx.AsyncClient(timeout=self.config.koboldcpp.timeout_seconds) as client:
response = await client.post(url, json=payload)
response.raise_for_status()
self.last_latency_ms = int((time.perf_counter() - started) * 1000)
return response.json()
def _prepare_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
if not self.config.generation.suppress_thinking:
return messages
token = self.config.generation.suppress_thinking_token.strip()
if not token:
return messages
prepared: list[dict[str, Any]] = []
token_prefix = f"{token} "
for message in messages:
copied = dict(message)
content = copied.get("content")
role = copied.get("role")
if role in {"system", "user"} and isinstance(content, str) and not content.lstrip().startswith(token):
copied["content"] = f"{token_prefix}{content}"
prepared.append(copied)
return prepared
@@ -1,84 +0,0 @@
from __future__ import annotations
import subprocess
import time
from pathlib import Path
import httpx
from .config import KoboldCppConfig, resolve_runtime_path
class KoboldCppProcess:
def __init__(self, config: KoboldCppConfig, cwd: Path | None = None):
self.config = config
self.cwd = cwd or Path.cwd()
self.process: subprocess.Popen[str] | None = None
self.last_error: str | None = None
def validate_runtime_files(self) -> list[str]:
errors: list[str] = []
executable = resolve_runtime_path(self.config.executable_path, self.cwd)
model = resolve_runtime_path(self.config.model_path, self.cwd)
if not executable.exists():
errors.append(f"Missing KoboldCpp executable: {executable}")
if not model.exists():
errors.append(f"Missing KoboldCpp model: {model}")
return errors
def is_reachable(self) -> bool:
try:
with httpx.Client(timeout=2) as client:
response = client.get(f"{self.config.base_url}/v1/models")
return response.status_code < 500
except httpx.HTTPError:
return False
def ensure_started(self) -> None:
if not self.config.autostart or self.is_reachable():
return
errors = self.validate_runtime_files()
if errors:
self.last_error = "; ".join(errors)
raise RuntimeError(self.last_error)
executable = resolve_runtime_path(self.config.executable_path, self.cwd)
model = resolve_runtime_path(self.config.model_path, self.cwd)
command = [
str(executable),
"--model",
str(model),
"--port",
str(self.config.port),
"--contextsize",
str(self.config.context_size),
*self.config.extra_args,
]
self.process = subprocess.Popen(
command,
cwd=self.cwd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
text=True,
)
self._wait_until_ready()
def _wait_until_ready(self) -> None:
deadline = time.monotonic() + self.config.startup_timeout_seconds
while time.monotonic() < deadline:
if self.process is not None and self.process.poll() is not None:
self.last_error = f"KoboldCpp exited early with code {self.process.returncode}"
raise RuntimeError(self.last_error)
if self.is_reachable():
return
time.sleep(1)
self.last_error = "Timed out waiting for KoboldCpp API readiness"
raise TimeoutError(self.last_error)
def stop(self) -> None:
if self.process is None or self.process.poll() is not None:
return
self.process.terminate()
@@ -0,0 +1,231 @@
from __future__ import annotations
import hashlib
import re
import sqlite3
from dataclasses import dataclass, field
from pathlib import Path
from .config import resolve_runtime_path
from .vector_index import VectorIndex
_HEADING_PATTERN = re.compile(r"^(#{1,6})\s+(.+?)\s*$", re.MULTILINE)
@dataclass
class LoreStore:
sqlite_path: str
vector_index: VectorIndex | None = None
_initialized: bool = False
_path: Path = field(init=False)
def __post_init__(self) -> None:
self._path = resolve_runtime_path(self.sqlite_path)
def initialize(self) -> None:
self._path.parent.mkdir(parents=True, exist_ok=True)
with self._connect() as connection:
connection.execute("PRAGMA journal_mode=WAL")
self._create_schema(connection)
connection.commit()
self._initialized = True
def import_markdown(self, source_key: str, name: str, markdown: str) -> int:
self._ensure_initialized()
content_hash = hashlib.sha256(markdown.encode("utf-8")).hexdigest()
chunks = self._chunk_markdown(markdown)
with self._connect() as connection:
cursor = connection.execute(
"""
INSERT INTO lore_sources (source_key, name, content_hash)
VALUES (?, ?, ?)
ON CONFLICT(source_key) DO UPDATE SET
name = excluded.name,
content_hash = excluded.content_hash,
updated_at = CURRENT_TIMESTAMP
RETURNING id
""",
(source_key, name, content_hash),
)
source_id = int(cursor.fetchone()["id"])
connection.execute("DELETE FROM lore_chunks WHERE lore_source_id = ?", (source_id,))
count = 0
for index, chunk in enumerate(chunks):
chunk_key = f"{source_key}:{index}"
cursor = connection.execute(
"""
INSERT INTO lore_chunks (
lore_source_id,
source_key,
chunk_key,
heading_path,
title,
text,
summary,
tags_json
)
VALUES (?, ?, ?, ?, ?, ?, ?, '[]')
""",
(
source_id,
source_key,
chunk_key,
chunk["heading_path"],
chunk["title"],
chunk["text"],
chunk["summary"],
),
)
chunk_id = int(cursor.lastrowid)
point_id = self._upsert_vector(chunk_id, source_id, source_key, chunk)
if point_id:
connection.execute(
"UPDATE lore_chunks SET qdrant_point_id = ? WHERE id = ?",
(point_id, chunk_id),
)
count += 1
connection.commit()
return count
def search(self, query: str, source_key: str | None = None, limit: int = 5) -> list[dict[str, object]]:
self._ensure_initialized()
if self.vector_index and self.vector_index.enabled:
filters = {"source_key": source_key} if source_key else {}
results = self.vector_index.search_lore(query, filters, limit)
if results:
ids = [result.sqlite_id for result in results]
rows_by_id = {int(row["id"]): row for row in self._fetch_rows_by_ids(ids)}
return [self._row_to_chunk(rows_by_id[chunk_id]) for chunk_id in ids if chunk_id in rows_by_id]
params: list[object] = []
where = ""
if source_key:
where = "AND source_key = ?"
params.append(source_key)
needle = f"%{query.lower()}%"
rows = self._fetch_rows(
f"""
SELECT *
FROM lore_chunks
WHERE (LOWER(text) LIKE ? OR LOWER(summary) LIKE ? OR LOWER(title) LIKE ?)
{where}
ORDER BY id
LIMIT ?
""",
[needle, needle, needle, *params, limit],
)
return [self._row_to_chunk(row) for row in rows]
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self._path)
connection.row_factory = sqlite3.Row
return connection
def _ensure_initialized(self) -> None:
if not self._initialized:
self.initialize()
def _create_schema(self, connection: sqlite3.Connection) -> None:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS lore_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_key TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
content_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS lore_chunks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lore_source_id INTEGER NOT NULL,
source_key TEXT NOT NULL,
chunk_key TEXT NOT NULL,
heading_path TEXT NOT NULL,
title TEXT NOT NULL,
text TEXT NOT NULL,
summary TEXT NOT NULL,
tags_json TEXT NOT NULL DEFAULT '[]',
qdrant_point_id TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(lore_source_id) REFERENCES lore_sources(id) ON DELETE CASCADE
)
"""
)
connection.execute(
"""
CREATE INDEX IF NOT EXISTS idx_lore_chunks_source
ON lore_chunks(source_key)
"""
)
def _upsert_vector(self, chunk_id: int, source_id: int, source_key: str, chunk: dict[str, str]) -> str | None:
if not self.vector_index or not self.vector_index.enabled:
return None
return self.vector_index.upsert_lore_chunk(
chunk_id,
"\n".join([chunk["title"], chunk["summary"], chunk["text"]]),
{
"lore_source_id": source_id,
"source_key": source_key,
"title": chunk["title"],
},
)
def _fetch_rows(self, statement: str, params: list[object]) -> list[sqlite3.Row]:
with self._connect() as connection:
return list(connection.execute(statement, params).fetchall())
def _fetch_rows_by_ids(self, chunk_ids: list[int]) -> list[sqlite3.Row]:
if not chunk_ids:
return []
placeholders = ",".join("?" for _ in chunk_ids)
return self._fetch_rows(f"SELECT * FROM lore_chunks WHERE id IN ({placeholders})", list(chunk_ids))
@staticmethod
def _chunk_markdown(markdown: str) -> list[dict[str, str]]:
matches = list(_HEADING_PATTERN.finditer(markdown))
if not matches:
text = markdown.strip()
return [{"title": "Lore", "heading_path": "Lore", "text": text, "summary": text[:240]}] if text else []
chunks: list[dict[str, str]] = []
heading_stack: list[tuple[int, str]] = []
for index, match in enumerate(matches):
level = len(match.group(1))
title = match.group(2).strip()
start = match.end()
end = matches[index + 1].start() if index + 1 < len(matches) else len(markdown)
body = markdown[start:end].strip()
heading_stack = [(existing_level, existing_title) for existing_level, existing_title in heading_stack if existing_level < level]
heading_stack.append((level, title))
if not body:
continue
heading_path = " > ".join(existing_title for _, existing_title in heading_stack)
chunks.append(
{
"title": title,
"heading_path": heading_path,
"text": body,
"summary": body[:240],
}
)
return chunks
@staticmethod
def _row_to_chunk(row: sqlite3.Row) -> dict[str, object]:
return {
"id": row["id"],
"source_key": row["source_key"],
"title": row["title"],
"heading_path": row["heading_path"],
"text": row["text"],
"summary": row["summary"],
"qdrant_point_id": row["qdrant_point_id"],
}
@@ -1,70 +1,476 @@
from __future__ import annotations
import json
import re
import sqlite3
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from .config import MemoryConfig
from .config import MemoryConfig, resolve_runtime_path
from .vector_index import VectorIndex
_TOKEN_PATTERN = re.compile(r"[A-Za-z0-9_]+")
@dataclass
class MemoryStore:
config: MemoryConfig
_fallback: list[dict[str, Any]] = field(default_factory=list)
_mem0: Any = None
vector_index: VectorIndex | None = None
_initialized: bool = False
_fts_available: bool = False
_last_error: str | None = None
_path: Path = field(init=False)
def __post_init__(self) -> None:
self._path = resolve_runtime_path(self.config.sqlite_path)
def initialize(self) -> None:
if self.config.provider.lower() != "mem0":
return
try:
from mem0 import Memory # type: ignore
except ImportError:
self._mem0 = None
return
mem0_config = {
"vector_store": {
"provider": "qdrant",
"config": {
"host": self.config.qdrant_host,
"port": self.config.qdrant_port,
"collection_name": self.config.collection,
},
},
"embedder": {
"provider": self.config.embedder_provider,
"config": {"model": self.config.embedder_model},
},
}
self._mem0 = Memory.from_config(mem0_config)
self._path.parent.mkdir(parents=True, exist_ok=True)
with self._connect() as connection:
connection.execute("PRAGMA journal_mode=WAL")
connection.execute("PRAGMA foreign_keys=ON")
self._create_schema(connection)
self._fts_available = self._create_fts_schema(connection)
connection.commit()
self._initialized = True
self._last_error = None
@property
def status(self) -> str:
if self.config.provider.lower() == "disabled":
return "disabled"
return "reachable" if self._mem0 is not None else "fallback"
if self._last_error:
return "error"
if not self._initialized:
return "not_initialized"
sqlite_status = "sqlite_fts" if self._fts_available else "sqlite"
if self.vector_index:
return f"{sqlite_status}+vector:{self.vector_index.status}"
return sqlite_status
def remember(self, text: str, metadata: dict[str, Any]) -> None:
if self._mem0 is not None:
user_id = metadata.get("campaign_id", "default")
self._mem0.add(text, user_id=user_id, metadata=metadata)
return
self._fallback.append({"text": text, "metadata": metadata})
self._ensure_initialized()
normalized = self._normalize_metadata(metadata)
tags = normalized.pop("tags")
summary = normalized.pop("summary", None) or text
extra_metadata = {
key: value
for key, value in normalized.items()
if key
not in {
"save_id",
"campaign_id",
"character_id",
"related_character_id",
"player_id",
"kingdom_id",
"location_id",
"category",
"importance",
"confidence",
"visibility",
"created_day",
}
}
with self._connect() as connection:
cursor = connection.execute(
"""
INSERT INTO memories (
save_id,
campaign_id,
subject_character_id,
related_character_id,
player_id,
kingdom_id,
location_id,
category,
importance,
confidence,
visibility,
text,
summary,
tags_json,
created_day,
metadata_json
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
normalized.get("save_id"),
normalized.get("campaign_id"),
normalized.get("character_id"),
normalized.get("related_character_id"),
normalized.get("player_id"),
normalized.get("kingdom_id"),
normalized.get("location_id"),
normalized.get("category"),
int(normalized.get("importance") or 3),
float(normalized.get("confidence") or 1.0),
normalized.get("visibility") or "private",
text,
summary,
json.dumps(tags, ensure_ascii=False),
normalized.get("created_day"),
json.dumps(extra_metadata, ensure_ascii=False),
),
)
memory_id = int(cursor.lastrowid)
if self._fts_available:
connection.execute(
"""
INSERT INTO memories_fts (memory_id, text, summary, tags)
VALUES (?, ?, ?, ?)
""",
(memory_id, text, summary, " ".join(tags)),
)
qdrant_point_id = self._upsert_vector(memory_id, text, normalized, tags)
if qdrant_point_id:
connection.execute(
"UPDATE memories SET qdrant_point_id = ? WHERE id = ?",
(qdrant_point_id, memory_id),
)
connection.commit()
def search(self, query: str, metadata: dict[str, Any], limit: int = 5) -> list[dict[str, Any]]:
if self._mem0 is not None:
user_id = metadata.get("campaign_id", "default")
result = self._mem0.search(query, user_id=user_id, limit=limit)
return result if isinstance(result, list) else [result]
campaign_id = metadata.get("campaign_id")
matches = [
item
for item in self._fallback
if item["metadata"].get("campaign_id") == campaign_id
and query.lower() in item["text"].lower()
]
return matches[:limit]
self._ensure_initialized()
normalized = self._normalize_metadata(metadata)
limit = max(1, min(int(limit), self.config.max_prompt_memories))
vector_matches = self._search_vector(query, normalized, limit)
if vector_matches:
return vector_matches
if self._fts_available and query.strip():
try:
matches = self._search_fts(query, normalized, limit)
if matches:
return matches
except sqlite3.OperationalError as exc:
self._last_error = str(exc)
return self._search_like(query, normalized, limit)
def count_estimate(self) -> int:
return len(self._fallback)
self._ensure_initialized()
with self._connect() as connection:
row = connection.execute("SELECT COUNT(*) FROM memories").fetchone()
return int(row[0] if row else 0)
def rebuild_vector_index(self) -> int:
self._ensure_initialized()
if not self.vector_index or not self.vector_index.enabled:
return 0
count = 0
with self._connect() as connection:
rows = connection.execute("SELECT * FROM memories ORDER BY id").fetchall()
for row in rows:
tags = json.loads(row["tags_json"] or "[]")
payload = self._row_to_vector_payload(row, tags)
point_id = self.vector_index.upsert_memory(
int(row["id"]),
self._index_text(row["text"], row["summary"], tags),
payload,
)
if point_id:
connection.execute(
"UPDATE memories SET qdrant_point_id = ? WHERE id = ?",
(point_id, row["id"]),
)
count += 1
connection.commit()
return count
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self._path)
connection.row_factory = sqlite3.Row
return connection
def _ensure_initialized(self) -> None:
if not self._initialized:
self.initialize()
def _create_schema(self, connection: sqlite3.Connection) -> None:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS memory_schema (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"""
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
save_id TEXT,
campaign_id TEXT NOT NULL,
subject_character_id TEXT,
related_character_id TEXT,
player_id TEXT,
kingdom_id TEXT,
location_id TEXT,
category TEXT,
importance INTEGER NOT NULL DEFAULT 3,
confidence REAL NOT NULL DEFAULT 1.0,
visibility TEXT NOT NULL DEFAULT 'private',
text TEXT NOT NULL,
summary TEXT NOT NULL,
tags_json TEXT NOT NULL DEFAULT '[]',
created_day REAL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_accessed_at TEXT,
qdrant_point_id TEXT,
metadata_json TEXT NOT NULL DEFAULT '{}'
)
"""
)
connection.execute(
"""
CREATE INDEX IF NOT EXISTS idx_memories_scope
ON memories(save_id, campaign_id, subject_character_id)
"""
)
connection.execute(
"""
CREATE INDEX IF NOT EXISTS idx_memories_campaign
ON memories(campaign_id)
"""
)
connection.execute(
"""
CREATE INDEX IF NOT EXISTS idx_memories_faction
ON memories(save_id, kingdom_id)
"""
)
connection.execute(
"""
CREATE INDEX IF NOT EXISTS idx_memories_location
ON memories(save_id, location_id)
"""
)
connection.execute(
"""
CREATE INDEX IF NOT EXISTS idx_memories_category
ON memories(save_id, category)
"""
)
connection.execute("INSERT OR IGNORE INTO memory_schema (version) VALUES (1)")
def _create_fts_schema(self, connection: sqlite3.Connection) -> bool:
try:
connection.execute(
"""
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts
USING fts5(memory_id UNINDEXED, text, summary, tags)
"""
)
return True
except sqlite3.OperationalError as exc:
self._last_error = str(exc)
return False
def _search_fts(
self,
query: str,
metadata: dict[str, Any],
limit: int,
) -> list[dict[str, Any]]:
match_query = self._fts_query(query)
if not match_query:
return []
where, params = self._scope_clause(metadata, "m")
rows = self._fetch_rows(
f"""
SELECT m.*, bm25(memories_fts) AS rank
FROM memories_fts
JOIN memories m ON m.id = memories_fts.memory_id
WHERE memories_fts MATCH ?
{where}
ORDER BY rank, m.importance DESC, m.id DESC
LIMIT ?
""",
[match_query, *params, limit],
)
self._mark_accessed([int(row["id"]) for row in rows])
return [self._row_to_memory(row) for row in rows]
def _search_like(
self,
query: str,
metadata: dict[str, Any],
limit: int,
) -> list[dict[str, Any]]:
where, params = self._scope_clause(metadata, "m")
text_clause = ""
if query.strip():
text_clause = "AND (LOWER(m.text) LIKE ? OR LOWER(m.summary) LIKE ? OR LOWER(m.tags_json) LIKE ?)"
needle = f"%{query.lower()}%"
params.extend([needle, needle, needle])
rows = self._fetch_rows(
f"""
SELECT m.*
FROM memories m
WHERE 1 = 1
{where}
{text_clause}
ORDER BY m.importance DESC, m.id DESC
LIMIT ?
""",
[*params, limit],
)
self._mark_accessed([int(row["id"]) for row in rows])
return [self._row_to_memory(row) for row in rows]
def _search_vector(
self,
query: str,
metadata: dict[str, Any],
limit: int,
) -> list[dict[str, Any]]:
if not self.vector_index or not self.vector_index.enabled:
return []
results = self.vector_index.search_memories(query, metadata, limit)
if not results:
return []
ids = [result.sqlite_id for result in results]
rows_by_id = {int(row["id"]): row for row in self._fetch_rows_by_ids(ids)}
ordered_rows = [rows_by_id[memory_id] for memory_id in ids if memory_id in rows_by_id]
self._mark_accessed([int(row["id"]) for row in ordered_rows])
return [self._row_to_memory(row) for row in ordered_rows]
def _fetch_rows(self, statement: str, params: list[Any]) -> list[sqlite3.Row]:
with self._connect() as connection:
return list(connection.execute(statement, params).fetchall())
def _fetch_rows_by_ids(self, memory_ids: list[int]) -> list[sqlite3.Row]:
if not memory_ids:
return []
placeholders = ",".join("?" for _ in memory_ids)
return self._fetch_rows(f"SELECT * FROM memories WHERE id IN ({placeholders})", list(memory_ids))
def _upsert_vector(
self,
memory_id: int,
text: str,
metadata: dict[str, Any],
tags: list[str],
) -> str | None:
if not self.vector_index or not self.vector_index.enabled:
return None
payload = {
"save_id": metadata.get("save_id"),
"campaign_id": metadata.get("campaign_id"),
"character_id": metadata.get("character_id"),
"related_character_id": metadata.get("related_character_id"),
"player_id": metadata.get("player_id"),
"kingdom_id": metadata.get("kingdom_id"),
"location_id": metadata.get("location_id"),
"category": metadata.get("category"),
"importance": int(metadata.get("importance") or 3),
"visibility": metadata.get("visibility") or "private",
"tags": tags,
}
return self.vector_index.upsert_memory(
memory_id,
self._index_text(text, metadata.get("summary") or text, tags),
payload,
)
def _mark_accessed(self, memory_ids: list[int]) -> None:
if not memory_ids:
return
placeholders = ",".join("?" for _ in memory_ids)
with self._connect() as connection:
connection.execute(
f"UPDATE memories SET last_accessed_at = CURRENT_TIMESTAMP WHERE id IN ({placeholders})",
memory_ids,
)
connection.commit()
def _scope_clause(self, metadata: dict[str, Any], alias: str) -> tuple[str, list[Any]]:
clauses: list[str] = []
params: list[Any] = []
for field, column in (
("save_id", "save_id"),
("campaign_id", "campaign_id"),
("character_id", "subject_character_id"),
("related_character_id", "related_character_id"),
("player_id", "player_id"),
("kingdom_id", "kingdom_id"),
("location_id", "location_id"),
("category", "category"),
):
value = metadata.get(field)
if value is None or value == "":
continue
if field in {"save_id", "campaign_id"}:
clauses.append(f"AND {alias}.{column} = ?")
else:
clauses.append(f"AND ({alias}.{column} = ? OR {alias}.{column} IS NULL)")
params.append(value)
return "\n".join(clauses), params
@staticmethod
def _normalize_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
normalized = dict(metadata)
tags = normalized.get("tags") or []
if isinstance(tags, str):
tags = [tags]
normalized["tags"] = [str(tag) for tag in tags if str(tag).strip()]
return normalized
@staticmethod
def _fts_query(query: str) -> str:
tokens = [token.replace("'", "''") for token in _TOKEN_PATTERN.findall(query.lower())]
return " OR ".join(tokens[:12])
@staticmethod
def _row_to_memory(row: sqlite3.Row) -> dict[str, Any]:
metadata = json.loads(row["metadata_json"] or "{}")
tags = json.loads(row["tags_json"] or "[]")
scope = {
"save_id": row["save_id"],
"campaign_id": row["campaign_id"],
"character_id": row["subject_character_id"],
"related_character_id": row["related_character_id"],
"player_id": row["player_id"],
"kingdom_id": row["kingdom_id"],
"location_id": row["location_id"],
}
metadata.update(
{
**scope,
"category": row["category"],
"importance": row["importance"],
"confidence": row["confidence"],
"visibility": row["visibility"],
"tags": tags,
"created_day": row["created_day"],
"created_at": row["created_at"],
"last_accessed_at": row["last_accessed_at"],
}
)
return {
"id": row["id"],
"text": row["text"],
"summary": row["summary"],
"metadata": metadata,
}
@staticmethod
def _index_text(text: str, summary: str, tags: list[str]) -> str:
return "\n".join(part for part in (summary, text, " ".join(tags)) if part)
@staticmethod
def _row_to_vector_payload(row: sqlite3.Row, tags: list[str]) -> dict[str, Any]:
return {
"save_id": row["save_id"],
"campaign_id": row["campaign_id"],
"character_id": row["subject_character_id"],
"related_character_id": row["related_character_id"],
"player_id": row["player_id"],
"kingdom_id": row["kingdom_id"],
"location_id": row["location_id"],
"category": row["category"],
"importance": row["importance"],
"visibility": row["visibility"],
"tags": tags,
}
@@ -22,7 +22,7 @@ from .contracts import (
)
class MockKoboldClient:
class MockLlmClient:
"""Deterministic local LLM stand-in for connector testing."""
last_latency_ms: int | None = 1
@@ -168,7 +168,7 @@ def _extract_packet(content: str) -> dict[str, Any]:
async def run_local_mock(command: str, args: argparse.Namespace) -> dict[str, Any]:
state.kobold_client = MockKoboldClient() # type: ignore[assignment]
state.llm_client = MockLlmClient() # type: ignore[assignment]
if command == "health":
return agent_health().model_dump(mode="json")
@@ -243,11 +243,38 @@ async def run_http(command: str, args: argparse.Namespace) -> dict[str, Any]:
return response.json()
async def run_interactive_chat(args: argparse.Namespace) -> None:
print("LocalDiplomacy chat. Type /quit to exit.")
print(f"Mode: {'mock in-process' if args.mock_agent else args.url}")
while True:
try:
message = input("You> ").strip()
except EOFError:
print()
return
if not message:
continue
if message.lower() in {"/q", "/quit", "quit", "exit"}:
return
args.message = message
result = await run_local_mock("say", args) if args.mock_agent else await run_http("say", args)
print(f"Derthert> {result.get('assistant_text', '')}")
if result.get("game_actions"):
print("Actions:")
for action in result["game_actions"]:
print(f" - {action.get('action_type')} ({action.get('reason')})")
if result.get("memory_writes"):
print("Memory writes:")
for memory in result["memory_writes"]:
print(f" - {memory.get('text')}")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Mock Bannerlord connector for LocalDiplomacy.Agent.")
parser.add_argument("--url", default="http://127.0.0.1:8766", help="Agent URL for HTTP mode.")
parser.add_argument("--timeout", type=float, default=120.0, help="HTTP timeout in seconds.")
parser.add_argument("--mock-agent", action="store_true", help="Run against the in-process agent with a fake Kobold client.")
parser.add_argument("--mock-agent", action="store_true", help="Run against the in-process agent with a fake LLM client.")
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("health")
@@ -255,6 +282,8 @@ def build_parser() -> argparse.ArgumentParser:
say = subparsers.add_parser("say")
say.add_argument("message")
subparsers.add_parser("chat")
tick = subparsers.add_parser("tick")
tick.add_argument("--day", type=float, default=1.0)
@@ -270,6 +299,9 @@ def build_parser() -> argparse.ArgumentParser:
async def async_main() -> int:
parser = build_parser()
args = parser.parse_args()
if args.command == "chat":
await run_interactive_chat(args)
return 0
if args.mock_agent:
result = await run_local_mock(args.command, args)
else:
@@ -0,0 +1,138 @@
from __future__ import annotations
import time
from typing import Any
import httpx
from .config import AppConfig
class OllamaClient:
def __init__(self, config: AppConfig):
self.config = config
self.last_latency_ms: int | None = None
self.last_error: str | None = None
self._effective_model: str | None = None
def is_reachable(self) -> bool:
try:
with httpx.Client(timeout=2) as client:
response = client.get(f"{self.config.ollama.base_url}/api/tags")
return response.status_code < 500
except httpx.HTTPError as exc:
self.last_error = str(exc)
return False
async def chat(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
tool_choice: str | dict[str, Any] | None = None,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"model": self._model(),
"messages": self._prepare_messages(messages),
"temperature": self.config.generation.temperature,
"max_tokens": self.config.generation.max_tokens,
}
if tools:
payload["tools"] = tools
payload["tool_choice"] = tool_choice or "auto"
url = f"{self.config.ollama.base_url}{self.config.ollama.chat_path}"
started = time.perf_counter()
async with httpx.AsyncClient(timeout=self.config.ollama.timeout_seconds) as client:
response = await client.post(url, json=payload)
if response.status_code == 400 and tools and "does not support tools" in response.text:
payload.pop("tools", None)
payload.pop("tool_choice", None)
response = await client.post(url, json=payload)
response.raise_for_status()
self.last_latency_ms = int((time.perf_counter() - started) * 1000)
self.last_error = None
return response.json()
def _model(self) -> str:
if self._effective_model:
return self._effective_model
configured = self.config.ollama.model
installed = self._installed_models()
if configured.lower().strip() == "auto":
self._effective_model = installed[0] if installed else configured
return self._effective_model
if configured in installed:
self._effective_model = configured
return configured
if self.config.ollama.auto_pull_models and self._pull_model(configured):
self._effective_model = configured
return configured
self._effective_model = installed[0] if installed else configured
return self._effective_model
def _installed_models(self) -> list[str]:
try:
with httpx.Client(timeout=2) as client:
response = client.get(f"{self.config.ollama.base_url}/api/tags")
response.raise_for_status()
models = response.json().get("models") or []
except httpx.HTTPError as exc:
self.last_error = str(exc)
return []
names: list[str] = []
for model in models:
name = str(model.get("name") or model.get("model") or "")
if not name or self._looks_like_embedding_model(model):
continue
names.append(name)
return names
def _pull_model(self, model: str) -> bool:
try:
with httpx.Client(timeout=None) as client:
response = client.post(
f"{self.config.ollama.base_url}/api/pull",
json={"model": model, "stream": False},
)
response.raise_for_status()
self.last_error = None
return True
except httpx.HTTPError as exc:
self.last_error = str(exc)
return False
@staticmethod
def _looks_like_embedding_model(model: dict[str, Any]) -> bool:
name = str(model.get("name") or model.get("model") or "").lower()
details = model.get("details") or {}
families = [str(family).lower() for family in details.get("families") or []]
family = str(details.get("family") or "").lower()
return (
"embed" in name
or "embedding" in name
or family in {"bert", "nomic-bert"}
or any(family_name in {"bert", "nomic-bert"} or "embed" in family_name for family_name in families)
)
def _prepare_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
if not self.config.generation.suppress_thinking:
return messages
token = self.config.generation.suppress_thinking_token.strip()
if not token:
return messages
prepared: list[dict[str, Any]] = []
token_prefix = f"{token} "
for message in messages:
copied = dict(message)
content = copied.get("content")
role = copied.get("role")
if role in {"system", "user"} and isinstance(content, str) and not content.lstrip().startswith(token):
copied["content"] = f"{token_prefix}{content}"
prepared.append(copied)
return prepared
@@ -0,0 +1,74 @@
from __future__ import annotations
import os
import subprocess
import time
from dataclasses import dataclass
from pathlib import Path
import httpx
from .config import VectorIndexConfig, resolve_runtime_path
@dataclass
class QdrantManagedProcess:
config: VectorIndexConfig
_process: subprocess.Popen[bytes] | None = None
def is_reachable(self) -> bool:
try:
with httpx.Client(timeout=1.0) as client:
response = client.get(f"http://{self.config.host}:{self.config.port}/healthz")
return response.status_code < 500
except httpx.HTTPError:
return False
def ensure_started(self) -> None:
if self.is_reachable():
return
if not self.config.autostart:
raise RuntimeError("Qdrant server is not reachable and autostart is disabled.")
executable = resolve_runtime_path(self.config.executable_path)
if not executable.exists():
raise FileNotFoundError(f"Qdrant executable not found: {executable}")
storage_path = resolve_runtime_path(self.config.path)
storage_path.mkdir(parents=True, exist_ok=True)
env = {
**os.environ,
"QDRANT__SERVICE__HOST": self.config.host,
"QDRANT__SERVICE__HTTP_PORT": str(self.config.port),
"QDRANT__STORAGE__STORAGE_PATH": str(storage_path),
}
creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
self._process = subprocess.Popen(
[str(executable)],
cwd=str(executable.parent),
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=creation_flags,
)
self._wait_until_reachable()
def stop(self) -> None:
if self._process is None or self._process.poll() is not None:
return
self._process.terminate()
try:
self._process.wait(timeout=10)
except subprocess.TimeoutExpired:
self._process.kill()
self._process.wait(timeout=5)
def _wait_until_reachable(self) -> None:
deadline = time.monotonic() + self.config.startup_timeout_seconds
while time.monotonic() < deadline:
if self.is_reachable():
return
if self._process is not None and self._process.poll() is not None:
raise RuntimeError(f"Qdrant exited early with code {self._process.returncode}.")
time.sleep(0.25)
raise TimeoutError("Timed out waiting for Qdrant to become reachable.")
@@ -14,9 +14,10 @@ ToolHandler = Callable[[dict[str, Any]], dict[str, Any]]
class ToolRegistry:
def __init__(self, memory: MemoryStore, event_log: EventLog):
def __init__(self, memory: MemoryStore, event_log: EventLog, default_scope: dict[str, Any] | None = None):
self.memory = memory
self.event_log = event_log
self.default_scope = default_scope or {}
self.queued_actions: list[GameAction] = []
self.memory_writes: list[MemoryWrite] = []
@@ -138,6 +139,7 @@ class ToolRegistry:
"Search local long-term memory for relevant facts.",
{
"query": {"type": "string"},
"save_id": {"type": "string"},
"campaign_id": {"type": "string"},
"character_id": {"type": "string"},
"kingdom_id": {"type": "string"},
@@ -150,6 +152,7 @@ class ToolRegistry:
"Store an atomic long-term memory fact: conversations, secrets, known info, relationships, visits, events, promises, or personality changes.",
{
"text": {"type": "string"},
"save_id": {"type": "string"},
"campaign_id": {"type": "string"},
"character_id": {"type": "string"},
"kingdom_id": {"type": "string"},
@@ -180,6 +183,7 @@ class ToolRegistry:
"analyze_lie",
"Analyze whether a player statement conflicts with known memories or live state; returns a recommendation, not a game mutation.",
{
"save_id": {"type": "string"},
"campaign_id": {"type": "string"},
"speaker_id": {"type": "string"},
"listener_id": {"type": "string"},
@@ -330,9 +334,10 @@ class ToolRegistry:
def _search_memory(self, args: dict[str, Any]) -> dict[str, Any]:
metadata = {
"campaign_id": args.get("campaign_id"),
"character_id": args.get("character_id"),
"kingdom_id": args.get("kingdom_id"),
"save_id": args.get("save_id") or self.default_scope.get("save_id"),
"campaign_id": args.get("campaign_id") or self.default_scope.get("campaign_id"),
"character_id": args.get("character_id") or self.default_scope.get("character_id"),
"kingdom_id": args.get("kingdom_id") or self.default_scope.get("kingdom_id"),
}
memories = self.memory.search(args["query"], metadata, int(args.get("limit", 5)))
return {"ok": True, "memories": memories}
@@ -348,9 +353,10 @@ class ToolRegistry:
def _remember_fact(self, args: dict[str, Any]) -> dict[str, Any]:
scope = {
"campaign_id": args.get("campaign_id"),
"character_id": args.get("character_id"),
"kingdom_id": args.get("kingdom_id"),
"save_id": args.get("save_id") or self.default_scope.get("save_id"),
"campaign_id": args.get("campaign_id") or self.default_scope.get("campaign_id"),
"character_id": args.get("character_id") or self.default_scope.get("character_id"),
"kingdom_id": args.get("kingdom_id") or self.default_scope.get("kingdom_id"),
}
write = MemoryWrite(
scope=scope,
@@ -371,7 +377,11 @@ class ToolRegistry:
def _analyze_lie(self, args: dict[str, Any]) -> dict[str, Any]:
memories = self.memory.search(
args["claim"],
{"campaign_id": args.get("campaign_id"), "character_id": args.get("listener_id")},
{
"save_id": args.get("save_id") or self.default_scope.get("save_id"),
"campaign_id": args.get("campaign_id") or self.default_scope.get("campaign_id"),
"character_id": args.get("listener_id"),
},
5,
)
return {
@@ -0,0 +1,231 @@
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from .config import VectorIndexConfig, resolve_runtime_path
from .embeddings import Embedder
from .qdrant_process import QdrantManagedProcess
MEMORY_COLLECTION = "localdiplomacy_memories"
LORE_COLLECTION = "localdiplomacy_lore"
@dataclass
class VectorSearchResult:
sqlite_id: int
score: float
@dataclass
class VectorIndex:
config: VectorIndexConfig
embedder: Embedder
_client: Any = None
_managed_process: QdrantManagedProcess | None = None
_status: str = "disabled"
_last_error: str | None = None
_path: Path = field(init=False)
def __post_init__(self) -> None:
self._path = resolve_runtime_path(self.config.path)
@property
def status(self) -> str:
if self._last_error:
return f"error:{self._last_error}"
return self._status
@property
def enabled(self) -> bool:
return self._client is not None
def initialize(self) -> None:
mode = self.config.mode.lower().strip()
if mode == "disabled":
self._status = "disabled"
return
if mode == "embedded":
self._initialize_embedded()
return
if mode == "managed_server":
self._initialize_managed_server()
return
self._last_error = f"unknown_mode:{self.config.mode}"
self._status = "disabled"
def upsert_memory(self, sqlite_id: int, text: str, payload: dict[str, Any]) -> str | None:
return self._upsert(MEMORY_COLLECTION, "memories", sqlite_id, text, payload)
def upsert_lore_chunk(self, sqlite_id: int, text: str, payload: dict[str, Any]) -> str | None:
return self._upsert(LORE_COLLECTION, "lore_chunks", sqlite_id, text, payload)
def _upsert(
self,
collection_name: str,
sqlite_table: str,
sqlite_id: int,
text: str,
payload: dict[str, Any],
) -> str | None:
if not self._client:
return None
try:
vector = self.embedder.embed(text)
models = self._models()
self._client.upsert(
collection_name=collection_name,
points=[
models.PointStruct(
id=sqlite_id,
vector=vector,
payload={
**{key: value for key, value in payload.items() if value is not None},
"sqlite_table": sqlite_table,
"sqlite_id": sqlite_id,
},
)
],
)
except Exception as exc: # qdrant-client exceptions vary by backend.
self._last_error = str(exc)
return None
return str(sqlite_id)
def search_memories(self, query: str, filters: dict[str, Any], limit: int) -> list[VectorSearchResult]:
return self._search(MEMORY_COLLECTION, query, filters, limit)
def search_lore(self, query: str, filters: dict[str, Any], limit: int) -> list[VectorSearchResult]:
return self._search(LORE_COLLECTION, query, filters, limit)
def _search(
self,
collection_name: str,
query: str,
filters: dict[str, Any],
limit: int,
) -> list[VectorSearchResult]:
if not self._client or not query.strip():
return []
try:
vector = self.embedder.embed(query)
result = self._query_points(collection_name, vector, filters, limit)
except Exception as exc:
self._last_error = str(exc)
return []
return [
VectorSearchResult(
sqlite_id=int(point.payload.get("sqlite_id")),
score=float(getattr(point, "score", 0.0)),
)
for point in result
if getattr(point, "payload", None) and point.payload.get("sqlite_id") is not None
]
def stop(self) -> None:
if self._managed_process is not None:
self._managed_process.stop()
def _initialize_embedded(self) -> None:
try:
from qdrant_client import QdrantClient
except ImportError:
self._last_error = "qdrant_client_not_installed"
self._status = "disabled"
return
try:
self._path.parent.mkdir(parents=True, exist_ok=True)
self._client = QdrantClient(path=str(self._path))
self._ensure_collections()
self._status = "embedded"
self._last_error = None
except Exception as exc:
self._client = None
self._last_error = str(exc)
self._status = "disabled"
def _initialize_managed_server(self) -> None:
try:
from qdrant_client import QdrantClient
except ImportError:
self._last_error = "qdrant_client_not_installed"
self._status = "disabled"
return
self._managed_process = QdrantManagedProcess(self.config)
try:
self._managed_process.ensure_started()
self._client = QdrantClient(host=self.config.host, port=self.config.port)
self._ensure_collections()
self._status = "managed_server"
self._last_error = None
except Exception as exc:
self._client = None
self._last_error = str(exc)
if self.config.fallback_mode.lower().strip() == "embedded":
self._initialize_embedded()
else:
self._status = "disabled"
def _ensure_collections(self) -> None:
models = self._models()
for collection_name in (MEMORY_COLLECTION, LORE_COLLECTION):
if not self._client.collection_exists(collection_name):
self._client.create_collection(
collection_name=collection_name,
vectors_config=models.VectorParams(
size=self.embedder.dimensions,
distance=models.Distance.COSINE,
),
)
def _query_points(self, collection_name: str, vector: list[float], filters: dict[str, Any], limit: int) -> list[Any]:
query_filter = self._build_filter(filters)
if hasattr(self._client, "query_points"):
response = self._client.query_points(
collection_name=collection_name,
query=vector,
query_filter=query_filter,
limit=limit,
with_payload=True,
)
return list(response.points)
return list(
self._client.search(
collection_name=collection_name,
query_vector=vector,
query_filter=query_filter,
limit=limit,
with_payload=True,
)
)
@staticmethod
def _models() -> Any:
from qdrant_client import models
return models
def _build_filter(self, filters: dict[str, Any]) -> Any:
must = []
models = self._models()
for key in (
"save_id",
"campaign_id",
"character_id",
"kingdom_id",
"location_id",
"category",
"lore_source_id",
"source_key",
):
value = filters.get(key)
if value is None or value == "":
continue
must.append(models.FieldCondition(key=key, match=models.MatchValue(value=value)))
if not must:
return None
return models.Filter(must=must)
+1 -4
View File
@@ -7,15 +7,12 @@ dependencies = [
"fastapi>=0.115.0",
"httpx>=0.27.0",
"pydantic>=2.8.0",
"qdrant-client>=1.10.0",
"pyyaml>=6.0.0",
"uvicorn[standard]>=0.30.0",
]
[project.optional-dependencies]
memory = [
"mem0ai>=0.1.0",
"qdrant-client>=1.10.0",
]
test = [
"pytest>=8.2.0",
]
@@ -0,0 +1,85 @@
from localdiplomacy_agent.config import VectorIndexConfig
from localdiplomacy_agent.embeddings import HashingEmbedder
from localdiplomacy_agent.lore import LoreStore
from localdiplomacy_agent.vector_index import VectorIndex
class ConsoleDemoAi:
def answer(self, npc_name: str, player_question: str, lore_chunks: list[dict[str, object]]) -> str:
lore_context = "\n\n".join(
f"{chunk['heading_path']}\n{chunk['text']}"
for chunk in lore_chunks
)
print("\n--- PROMPT LORE CONTEXT GIVEN TO AI ---")
print(lore_context)
print("--- END PROMPT LORE CONTEXT ---\n")
assert "Moonlit Compact" in lore_context
assert "silver bells" in lore_context
return (
f"{npc_name}: You ask why our envoys wear silver bells? "
"It is the sign of the Moonlit Compact. In Aurelian custom, "
"no envoy bearing those bells may be harmed before moonset."
)
def test_full_dummy_lore_execution_prints_console_trace(tmp_path):
dummy_world_lore = """
# Aurelian Marches
## Moonlit Compact
The Moonlit Compact is the oldest diplomatic law of the Aurelian Marches. Envoys who wear silver bells are protected until moonset, even when they cross enemy roads during wartime.
## Ember Host
The Ember Host is a militant order that guards mountain passes. Its captains mark treaties with red wax and broken arrowheads.
## Glass Abbey
The Glass Abbey records noble bloodlines and refuses entry to anyone carrying an unsheathed blade.
"""
print("\n=== FULL LORE EXECUTION DEMO ===")
print(f"SQLite path: {tmp_path / 'lore.sqlite3'}")
print(f"Qdrant path: {tmp_path / 'qdrant'}")
vector_index = VectorIndex(
VectorIndexConfig(mode="embedded", path=str(tmp_path / "qdrant")),
HashingEmbedder(),
)
vector_index.initialize()
print(f"Vector index status: {vector_index.status}")
lore = LoreStore(str(tmp_path / "lore.sqlite3"), vector_index)
lore.initialize()
imported_count = lore.import_markdown(
"aurelian_marches",
"Aurelian Marches Demo Lore",
dummy_world_lore,
)
print(f"Imported lore chunks: {imported_count}")
player_question = "Why does this envoy wear silver bells during war?"
retrieved = lore.search(player_question, source_key="aurelian_marches", limit=2)
print(f"Query: {player_question}")
print(f"Retrieved chunks: {len(retrieved)}")
for index, chunk in enumerate(retrieved, start=1):
print(f"\nRetrieved #{index}")
print(f" id: {chunk['id']}")
print(f" title: {chunk['title']}")
print(f" heading_path: {chunk['heading_path']}")
print(f" qdrant_point_id: {chunk['qdrant_point_id']}")
print(f" summary: {chunk['summary']}")
answer = ConsoleDemoAi().answer(
npc_name="Lady Maravel",
player_question=player_question,
lore_chunks=retrieved,
)
print("--- FINAL AI-STYLE ANSWER ---")
print(answer)
print("=== END FULL LORE EXECUTION DEMO ===\n")
assert imported_count == 3
assert retrieved[0]["title"] == "Moonlit Compact"
assert "silver bells" in answer
@@ -1,21 +0,0 @@
from localdiplomacy_agent.config import AppConfig, GenerationConfig
from localdiplomacy_agent.koboldcpp_client import KoboldCppClient
def test_suppress_thinking_prefixes_text_messages_once():
client = KoboldCppClient(
AppConfig(generation=GenerationConfig(suppress_thinking=True))
)
messages = [
{"role": "system", "content": "Use tools when needed."},
{"role": "user", "content": "/no_think Already prefixed."},
{"role": "tool", "tool_call_id": "call_1", "content": "{}"},
]
prepared = client._prepare_messages(messages)
assert prepared[0]["content"] == "/no_think Use tools when needed."
assert prepared[1]["content"] == "/no_think Already prefixed."
assert prepared[2]["content"] == "{}"
assert messages[0]["content"] == "Use tools when needed."
@@ -1,13 +0,0 @@
from pathlib import Path
from localdiplomacy_agent.config import KoboldCppConfig
from localdiplomacy_agent.koboldcpp_process import KoboldCppProcess
def test_runtime_file_validation_reports_missing_files(tmp_path: Path):
process = KoboldCppProcess(KoboldCppConfig(), cwd=tmp_path)
errors = process.validate_runtime_files()
assert any("koboldcpp.exe" in error for error in errors)
assert any("model.gguf" in error for error in errors)
@@ -0,0 +1,44 @@
from localdiplomacy_agent.config import VectorIndexConfig
from localdiplomacy_agent.embeddings import HashingEmbedder
from localdiplomacy_agent.lore import LoreStore
from localdiplomacy_agent.vector_index import VectorIndex
class LoreAwareFakeAi:
def answer(self, player_question: str, lore_chunks: list[dict[str, object]]) -> str:
context = "\n".join(str(chunk["text"]) for chunk in lore_chunks)
assert "Dawn Court" in context
assert "sun oath" in context
return "The Dawn Court binds its knights by the sun oath before they ride from Auric Gate."
def test_dummy_world_lore_retrieved_from_vector_database_and_used_by_ai(tmp_path):
dummy_lore = """
# Kingdoms of Testoria
## Dawn Court
The Dawn Court rules from Auric Gate. Its knights swear the sun oath, a vow to protect travelers at first light and never draw steel after sunset unless defending a guest.
## Ashen League
The Ashen League is a merchant republic that settles disputes through masked arbitration and river tolls.
"""
vector_index = VectorIndex(
VectorIndexConfig(mode="embedded", path=str(tmp_path / "qdrant")),
HashingEmbedder(),
)
vector_index.initialize()
lore = LoreStore(str(tmp_path / "lore.sqlite3"), vector_index)
lore.initialize()
imported = lore.import_markdown("dummy_testoria", "Testoria Dummy Lore", dummy_lore)
chunks = lore.search("Who swears the sun oath at Auric Gate?", source_key="dummy_testoria", limit=1)
answer = LoreAwareFakeAi().answer("Who swears the sun oath?", chunks)
assert imported == 2
assert vector_index.status == "embedded"
assert chunks[0]["title"] == "Dawn Court"
assert chunks[0]["qdrant_point_id"] == "1"
assert "Dawn Court" in answer
assert "sun oath" in answer
@@ -0,0 +1,151 @@
import sqlite3
from localdiplomacy_agent.config import MemoryConfig
from localdiplomacy_agent.config import VectorIndexConfig
from localdiplomacy_agent.embeddings import HashingEmbedder
from localdiplomacy_agent.event_log import EventLog
from localdiplomacy_agent.memory import MemoryStore
from localdiplomacy_agent.tools import ToolRegistry
from localdiplomacy_agent.vector_index import VectorIndex
def test_memory_store_persists_across_instances(tmp_path):
sqlite_path = tmp_path / "memory.sqlite3"
first = MemoryStore(MemoryConfig(sqlite_path=str(sqlite_path)))
first.initialize()
first.remember(
"The player promised Derthert they would defend Sargot from Battania.",
{
"save_id": "save-a",
"campaign_id": "campaign",
"character_id": "lord_derthert",
"kingdom_id": "kingdom_vlandia",
"category": "promise",
"importance": 8,
"tags": ["sargot", "battania"],
},
)
second = MemoryStore(MemoryConfig(sqlite_path=str(sqlite_path)))
second.initialize()
matches = second.search(
"defend Sargot",
{
"save_id": "save-a",
"campaign_id": "campaign",
"character_id": "lord_derthert",
},
)
assert second.count_estimate() == 1
assert matches[0]["text"] == "The player promised Derthert they would defend Sargot from Battania."
assert matches[0]["metadata"]["category"] == "promise"
assert matches[0]["metadata"]["importance"] == 8
def test_memory_store_isolates_saves(tmp_path):
store = MemoryStore(MemoryConfig(sqlite_path=str(tmp_path / "memory.sqlite3")))
store.initialize()
store.remember(
"The player promised Derthert they would defend Sargot.",
{"save_id": "save-a", "campaign_id": "campaign", "character_id": "lord_derthert"},
)
store.remember(
"The player betrayed Derthert near Sargot.",
{"save_id": "save-b", "campaign_id": "campaign", "character_id": "lord_derthert"},
)
matches = store.search(
"Derthert Sargot",
{"save_id": "save-a", "campaign_id": "campaign", "character_id": "lord_derthert"},
limit=5,
)
assert len(matches) == 1
assert "promised" in matches[0]["text"]
def test_tool_registry_injects_default_save_scope(tmp_path):
memory = MemoryStore(MemoryConfig(sqlite_path=str(tmp_path / "memory.sqlite3")))
registry = ToolRegistry(
memory,
EventLog(tmp_path / "events.sqlite3"),
{
"save_id": "save-a",
"campaign_id": "campaign",
"character_id": "lord_derthert",
"kingdom_id": "kingdom_vlandia",
},
)
registry.dispatch(
"remember_fact",
'{"text":"The player promised to defend Sargot.","category":"promise"}',
)
matches = memory.search(
"defend Sargot",
{"save_id": "save-a", "campaign_id": "campaign", "character_id": "lord_derthert"},
)
assert len(matches) == 1
assert matches[0]["metadata"]["save_id"] == "save-a"
assert matches[0]["metadata"]["campaign_id"] == "campaign"
def test_memory_store_upserts_embedded_vector_index(tmp_path):
sqlite_path = tmp_path / "memory.sqlite3"
vector_index = VectorIndex(
VectorIndexConfig(mode="embedded", path=str(tmp_path / "qdrant")),
HashingEmbedder(),
)
vector_index.initialize()
memory = MemoryStore(MemoryConfig(sqlite_path=str(sqlite_path)), vector_index)
memory.initialize()
memory.remember(
"The player promised Derthert they would defend Sargot from Battania.",
{
"save_id": "save-a",
"campaign_id": "campaign",
"character_id": "lord_derthert",
"kingdom_id": "kingdom_vlandia",
"category": "promise",
"tags": ["sargot", "battania"],
},
)
matches = memory.search(
"promise defend Sargot",
{
"save_id": "save-a",
"campaign_id": "campaign",
"character_id": "lord_derthert",
"category": "promise",
},
)
with sqlite3.connect(sqlite_path) as connection:
row = connection.execute("SELECT qdrant_point_id FROM memories").fetchone()
assert vector_index.status == "embedded"
assert row[0] == "1"
assert matches[0]["id"] == 1
def test_managed_qdrant_falls_back_to_embedded_when_unavailable(tmp_path):
vector_index = VectorIndex(
VectorIndexConfig(
mode="managed_server",
path=str(tmp_path / "qdrant"),
port=65333,
autostart=False,
fallback_mode="embedded",
),
HashingEmbedder(),
)
vector_index.initialize()
assert vector_index.status == "embedded"
@@ -0,0 +1,203 @@
from localdiplomacy_agent.config import AppConfig, GenerationConfig
from localdiplomacy_agent.config import OllamaConfig
from localdiplomacy_agent.ollama_client import OllamaClient
import pytest
def test_suppress_thinking_prefixes_text_messages_once():
client = OllamaClient(
AppConfig(generation=GenerationConfig(suppress_thinking=True))
)
messages = [
{"role": "system", "content": "Use tools when needed."},
{"role": "user", "content": "/no_think Already prefixed."},
{"role": "tool", "tool_call_id": "call_1", "content": "{}"},
]
prepared = client._prepare_messages(messages)
assert prepared[0]["content"] == "/no_think Use tools when needed."
assert prepared[1]["content"] == "/no_think Already prefixed."
assert prepared[2]["content"] == "{}"
assert messages[0]["content"] == "Use tools when needed."
def test_model_falls_back_to_first_installed_ollama_model(monkeypatch):
class FakeResponse:
def raise_for_status(self):
return None
def json(self):
return {"models": [{"name": "installed-model:latest"}]}
class FakeClient:
def __init__(self, timeout):
self.timeout = timeout
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def get(self, url):
return FakeResponse()
monkeypatch.setattr("localdiplomacy_agent.ollama_client.httpx.Client", FakeClient)
client = OllamaClient(AppConfig(ollama=OllamaConfig(model="missing-model", auto_pull_models=False)))
assert client._model() == "installed-model:latest"
def test_auto_model_uses_first_installed_ollama_model(monkeypatch):
class FakeResponse:
def raise_for_status(self):
return None
def json(self):
return {"models": [{"name": "installed-model:latest"}]}
class FakeClient:
def __init__(self, timeout):
self.timeout = timeout
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def get(self, url):
return FakeResponse()
monkeypatch.setattr("localdiplomacy_agent.ollama_client.httpx.Client", FakeClient)
client = OllamaClient(AppConfig(ollama=OllamaConfig(model="auto", auto_pull_models=True)))
assert client._model() == "installed-model:latest"
def test_auto_model_skips_embedding_models(monkeypatch):
class FakeResponse:
def raise_for_status(self):
return None
def json(self):
return {
"models": [
{"name": "nomic-embed-text:latest", "details": {"family": "bert", "families": ["bert"]}},
{"name": "chat-model:latest", "details": {"family": "qwen2", "families": ["qwen2"]}},
]
}
class FakeClient:
def __init__(self, timeout):
self.timeout = timeout
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def get(self, url):
return FakeResponse()
monkeypatch.setattr("localdiplomacy_agent.ollama_client.httpx.Client", FakeClient)
client = OllamaClient(AppConfig(ollama=OllamaConfig(model="auto", auto_pull_models=True)))
assert client._model() == "chat-model:latest"
def test_model_pulls_missing_configured_model(monkeypatch):
calls = []
class FakeResponse:
def __init__(self, data):
self.data = data
def raise_for_status(self):
return None
def json(self):
return self.data
class FakeClient:
def __init__(self, timeout):
self.timeout = timeout
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def get(self, url):
return FakeResponse({"models": []})
def post(self, url, json):
calls.append((url, json))
return FakeResponse({"status": "success"})
monkeypatch.setattr("localdiplomacy_agent.ollama_client.httpx.Client", FakeClient)
client = OllamaClient(AppConfig(ollama=OllamaConfig(model="llama3.1:8b", auto_pull_models=True)))
assert client._model() == "llama3.1:8b"
assert calls == [
(
"http://127.0.0.1:11434/api/pull",
{"model": "llama3.1:8b", "stream": False},
)
]
@pytest.mark.anyio
async def test_chat_retries_without_tools_when_model_does_not_support_tools(monkeypatch):
calls = []
class FakeResponse:
def __init__(self, status_code, text, data):
self.status_code = status_code
self.text = text
self.data = data
def raise_for_status(self):
if self.status_code >= 400:
raise AssertionError("fallback response should be successful")
def json(self):
return self.data
class FakeAsyncClient:
def __init__(self, timeout):
self.timeout = timeout
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def post(self, url, json):
calls.append(dict(json))
if len(calls) == 1:
return FakeResponse(400, "model does not support tools", {})
return FakeResponse(200, "ok", {"choices": [{"message": {"content": "Hello."}}]})
monkeypatch.setattr("localdiplomacy_agent.ollama_client.httpx.AsyncClient", FakeAsyncClient)
client = OllamaClient(AppConfig(ollama=OllamaConfig(model="Qwen3-4B-abliterated-q4_k_m")))
client._effective_model = "Qwen3-4B-abliterated-q4_k_m"
result = await client.chat(
[{"role": "user", "content": "Hello"}],
[{"type": "function", "function": {"name": "remember_fact", "parameters": {"type": "object"}}}],
)
assert result["choices"][0]["message"]["content"] == "Hello."
assert "tools" in calls[0]
assert "tools" not in calls[1]
+3 -433
View File
@@ -39,15 +39,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
name = "backoff"
version = "2.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" },
]
[[package]]
name = "certifi"
version = "2026.4.22"
@@ -57,95 +48,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" },
{ url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" },
{ url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" },
{ url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" },
{ url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" },
{ url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" },
{ url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" },
{ url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" },
{ url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" },
{ url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" },
{ url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" },
{ url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" },
{ url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" },
{ url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" },
{ url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" },
{ url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" },
{ url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" },
{ url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" },
{ url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" },
{ url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" },
{ url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" },
{ url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" },
{ url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" },
{ url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" },
{ url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" },
{ url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" },
{ url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" },
{ url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" },
{ url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" },
{ url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" },
{ url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" },
{ url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },
{ url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },
{ url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },
{ url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },
{ url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },
{ url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },
{ url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },
{ url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },
{ url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },
{ url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },
{ url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },
{ url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },
{ url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },
{ url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },
{ url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },
{ url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },
{ url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
{ url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
{ url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
{ url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
{ url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
{ url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
{ url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
{ url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
{ url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
{ url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
{ url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
{ url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
{ url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
{ url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
{ url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
{ url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
{ url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
{ url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
{ url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
{ url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
{ url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
{ url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
{ url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
{ url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
{ url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
{ url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
{ url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
{ url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
]
[[package]]
name = "click"
version = "8.3.3"
@@ -167,15 +69,6 @@ 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 = "fastapi"
version = "0.136.1"
@@ -192,63 +85,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" },
]
[[package]]
name = "greenlet"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3c/3f/dbf99fb14bfeb88c28f16729215478c0e265cacd6dc22270c8f31bb6892f/greenlet-3.5.0.tar.gz", hash = "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4", size = 196995, upload-time = "2026-04-27T13:37:15.544Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/0f/a91f143f356523ff682309732b175765a9bc2836fd7c081c2c67fedc1ad4/greenlet-3.5.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082", size = 284726, upload-time = "2026-04-27T12:20:51.402Z" },
{ url = "https://files.pythonhosted.org/packages/95/82/800646c7ffc5dbabd75ddd2f6b519bb898c0c9c969e5d0473bfe5d20bcce/greenlet-3.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3", size = 604264, upload-time = "2026-04-27T12:52:39.494Z" },
{ url = "https://files.pythonhosted.org/packages/ca/ac/354867c0bba812fc33b15bc55aedafedd0aee3c7dd91dfca22444157dc0c/greenlet-3.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c", size = 616099, upload-time = "2026-04-27T12:59:39.623Z" },
{ url = "https://files.pythonhosted.org/packages/c9/ab/192090c4a5b30df148c22bf4b8895457d739a7c7c5a7b9c41e5dd7f537f2/greenlet-3.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564", size = 623976, upload-time = "2026-04-27T13:02:37.363Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b0/815bece7399e01cadb69014219eebd0042339875c59a59b0820a46ece356/greenlet-3.5.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662", size = 615198, upload-time = "2026-04-27T12:25:25.928Z" },
{ url = "https://files.pythonhosted.org/packages/24/11/05eb2b9b188c6df7d68a89c99134d644a7af616a40b9808e8e6ced315d5d/greenlet-3.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc", size = 418379, upload-time = "2026-04-27T13:05:12.755Z" },
{ url = "https://files.pythonhosted.org/packages/10/80/3b2c0a895d6698f6ddb31b07942ebfa982f3e30888bc5546a5b5990de8b2/greenlet-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b", size = 1574927, upload-time = "2026-04-27T12:53:25.81Z" },
{ url = "https://files.pythonhosted.org/packages/44/0e/f354af514a4c61454dbc68e44d47544a5a4d6317e30b77ddfa3a09f4c5f3/greenlet-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4", size = 1642683, upload-time = "2026-04-27T12:25:23.9Z" },
{ url = "https://files.pythonhosted.org/packages/fa/6a/87f38255201e993a1915265ebb80cd7c2c78b04a45744995abbf6b259fd8/greenlet-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8", size = 238115, upload-time = "2026-04-27T12:21:48.845Z" },
{ url = "https://files.pythonhosted.org/packages/e3/f8/450fe3c5938fa737ea4d22699772e6e34e8e24431a47bf4e8a1ceed4a98e/greenlet-3.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:6c18dfb59c70f5a94acd271c72e90128c3c776e41e5f07767908c8c1b74ad339", size = 235017, upload-time = "2026-04-27T12:22:26.768Z" },
{ url = "https://files.pythonhosted.org/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228, upload-time = "2026-04-27T12:20:34.421Z" },
{ url = "https://files.pythonhosted.org/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775, upload-time = "2026-04-27T12:52:41.045Z" },
{ url = "https://files.pythonhosted.org/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436, upload-time = "2026-04-27T12:59:41.595Z" },
{ url = "https://files.pythonhosted.org/packages/3f/e9/4eeadf8cb3403ac274245ba75f07844abc7fa5f6787583fc9156ba741e0f/greenlet-3.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136", size = 620610, upload-time = "2026-04-27T13:02:39.194Z" },
{ url = "https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388, upload-time = "2026-04-27T12:25:28.008Z" },
{ url = "https://files.pythonhosted.org/packages/ee/ef/f913b3c0eb7d26d86a2401c5e1546c9d46b657efee724b06f6f4ac5d8824/greenlet-3.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d", size = 422775, upload-time = "2026-04-27T13:05:14.261Z" },
{ url = "https://files.pythonhosted.org/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768, upload-time = "2026-04-27T12:53:28.099Z" },
{ url = "https://files.pythonhosted.org/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983, upload-time = "2026-04-27T12:25:26.903Z" },
{ url = "https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840, upload-time = "2026-04-27T12:23:54.806Z" },
{ url = "https://files.pythonhosted.org/packages/cb/cb/baa584cb00532126ffe12d9787db0a60c5a4f55c27bfe2666df5d4c30a32/greenlet-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2", size = 235615, upload-time = "2026-04-27T12:21:38.57Z" },
{ url = "https://files.pythonhosted.org/packages/0c/58/fc576f99037ce19c5aa16628e4c3226b6d1419f72a62c79f5f40576e6eb3/greenlet-3.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106", size = 285066, upload-time = "2026-04-27T12:23:05.033Z" },
{ url = "https://files.pythonhosted.org/packages/4a/ba/b28ddbe6bfad6a8ac196ef0e8cff37bc65b79735995b9e410923fffeeb70/greenlet-3.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b", size = 604414, upload-time = "2026-04-27T12:52:42.358Z" },
{ url = "https://files.pythonhosted.org/packages/09/06/4b69f8f0b67603a8be2790e55107a190b376f2627fe0eaf5695d85ffb3cd/greenlet-3.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e", size = 617349, upload-time = "2026-04-27T12:59:43.32Z" },
{ url = "https://files.pythonhosted.org/packages/6a/15/a643b4ecd09969e30b8a150d5919960caae0abe4f5af75ab040b1ab85e78/greenlet-3.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d", size = 623234, upload-time = "2026-04-27T13:02:40.611Z" },
{ url = "https://files.pythonhosted.org/packages/8a/17/a3918541fd0ddefe024a69de6d16aa7b46d36ac19562adaa63c7fa180eff/greenlet-3.5.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13", size = 613927, upload-time = "2026-04-27T12:25:30.28Z" },
{ url = "https://files.pythonhosted.org/packages/77/18/3b13d5ef1275b0ffaf933b05efa21408ac4ca95823c7411d79682e4fdcff/greenlet-3.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae", size = 425243, upload-time = "2026-04-27T13:05:15.689Z" },
{ url = "https://files.pythonhosted.org/packages/ee/e1/bd0af6213c7dd33175d8a462d4c1fe1175124ebed4855bc1475a5b5242c2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba", size = 1570893, upload-time = "2026-04-27T12:53:29.483Z" },
{ url = "https://files.pythonhosted.org/packages/9b/2a/0789702f864f5382cb476b93d7a9c823c10472658102ccd65f415747d2e2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846", size = 1636060, upload-time = "2026-04-27T12:25:28.845Z" },
{ url = "https://files.pythonhosted.org/packages/b2/8f/22bf9df92bbff0eb07842b60f7e63bf7675a9742df628437a9f02d09137f/greenlet-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5", size = 238740, upload-time = "2026-04-27T12:24:01.341Z" },
{ url = "https://files.pythonhosted.org/packages/b6/b7/9c5c3d653bd4ff614277c049ac676422e2c557db47b4fe43e6313fc005dc/greenlet-3.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:47422135b1d308c14b2c6e758beedb1acd33bb91679f5670edf77bf46244722b", size = 235525, upload-time = "2026-04-27T12:23:12.308Z" },
{ url = "https://files.pythonhosted.org/packages/94/5e/a70f31e3e8d961c4ce589c15b28e4225d63704e431a23932a3808cbcc867/greenlet-3.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8", size = 285564, upload-time = "2026-04-27T12:23:08.555Z" },
{ url = "https://files.pythonhosted.org/packages/af/a6/046c0a28e21833e4086918218cfb3d8bed51c075a1b700f20b9d7861c0f4/greenlet-3.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1", size = 651166, upload-time = "2026-04-27T12:52:43.644Z" },
{ url = "https://files.pythonhosted.org/packages/47/f8/4af27f71c5ff32a7fbc516adb46370d9c4ae2bc7bd3dc7d066ac542b4b15/greenlet-3.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3", size = 663792, upload-time = "2026-04-27T12:59:44.93Z" },
{ url = "https://files.pythonhosted.org/packages/fb/89/2dadb89793c37ee8b4c237857188293e9060dc085f19845c292e00f8e091/greenlet-3.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37", size = 668086, upload-time = "2026-04-27T13:02:42.314Z" },
{ url = "https://files.pythonhosted.org/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7", size = 660933, upload-time = "2026-04-27T12:25:33.276Z" },
{ url = "https://files.pythonhosted.org/packages/82/35/75722be7e26a2af4cbd2dc35b0ed382dacf9394b7e75551f76ed1abe87f2/greenlet-3.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2", size = 470799, upload-time = "2026-04-27T13:05:17.094Z" },
{ url = "https://files.pythonhosted.org/packages/83/e4/b903e5a5fae1e8a28cdd32a0cfbfd560b668c25b692f67768822ddc5f40f/greenlet-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf", size = 1618401, upload-time = "2026-04-27T12:53:31.062Z" },
{ url = "https://files.pythonhosted.org/packages/0e/e3/5ec408a329acb854fb607a122e1ee5fb3ff649f9a97952948a90803c0d8e/greenlet-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16", size = 1682038, upload-time = "2026-04-27T12:25:31.838Z" },
{ url = "https://files.pythonhosted.org/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", size = 239835, upload-time = "2026-04-27T12:24:54.136Z" },
{ url = "https://files.pythonhosted.org/packages/4e/62/1c498375cee177b55d980c1db319f26470e5309e54698c8f8fc06c0fd539/greenlet-3.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:a96fcee45e03fe30a62669fd16ab5c9d3c172660d3085605cb1e2d1280d3c988", size = 236862, upload-time = "2026-04-27T12:23:24.957Z" },
{ url = "https://files.pythonhosted.org/packages/78/a8/4522939255bb5409af4e87132f915446bf3622c2c292d14d3c38d128ae82/greenlet-3.5.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853", size = 293614, upload-time = "2026-04-27T12:24:12.874Z" },
{ url = "https://files.pythonhosted.org/packages/15/5e/8744c52e2c027b5a8772a01561934c8835f869733e101f62075c60430340/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f", size = 650723, upload-time = "2026-04-27T12:52:45.412Z" },
{ url = "https://files.pythonhosted.org/packages/00/ef/7b4c39c03cf46ceca512c5d3f914afd85aa30b2cc9a93015b0dd73e4be6c/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7", size = 656529, upload-time = "2026-04-27T12:59:46.295Z" },
{ url = "https://files.pythonhosted.org/packages/5f/5c/0602239503b124b70e39355cbdb39361ecfe65b87a5f2f63752c32f5286f/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce", size = 657015, upload-time = "2026-04-27T13:02:43.973Z" },
{ url = "https://files.pythonhosted.org/packages/0b/b5/c7768f352f5c010f92064d0063f987e7dc0cd290a6d92a34109015ce4aa1/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112", size = 654364, upload-time = "2026-04-27T12:25:35.64Z" },
{ url = "https://files.pythonhosted.org/packages/38/51/8699f865f125dc952384cb432b0f7138aa4d8f2969a7d12d0df5b94d054d/greenlet-3.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2", size = 488275, upload-time = "2026-04-27T13:05:18.28Z" },
{ url = "https://files.pythonhosted.org/packages/ef/d0/079ebe12e4b1fc758857ce5be1a5e73f06870f2101e52611d1e71925ce54/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2", size = 1614204, upload-time = "2026-04-27T12:53:32.618Z" },
{ url = "https://files.pythonhosted.org/packages/6d/89/6c2fb63df3596552d20e58fb4d96669243388cf680cff222758812c7bfaa/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2", size = 1675480, upload-time = "2026-04-27T12:25:34.168Z" },
{ url = "https://files.pythonhosted.org/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" },
]
[[package]]
name = "grpcio"
version = "1.80.0"
@@ -427,96 +263,6 @@ 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.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6e/c1/0cddc6eb17d4c53a99840953f95dd3accdc5cfc7a337b0e9b26476276be9/jiter-0.14.0.tar.gz", hash = "sha256:e8a39e66dac7153cf3f964a12aad515afa8d74938ec5cc0018adcdae5367c79e", size = 165725, upload-time = "2026-04-10T14:28:42.01Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/1f/198ae537fccb7080a0ed655eb56abf64a92f79489dfbf79f40fa34225bcd/jiter-0.14.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7e791e247b8044512e070bd1f3633dc08350d32776d2d6e7473309d0edf256a2", size = 316896, upload-time = "2026-04-10T14:26:01.986Z" },
{ url = "https://files.pythonhosted.org/packages/cf/34/da67cff3fce964a36d03c3e365fb0f8726ade2a6cfd4d3c70107e216ead6/jiter-0.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71527ce13fd5a0c4e40ad37331f8c547177dbb2dd0a93e5278b6a5eecf748804", size = 321085, upload-time = "2026-04-10T14:26:03.364Z" },
{ url = "https://files.pythonhosted.org/packages/ed/36/4c72e67180d4e71a4f5dcf7886d0840e83c49ab11788172177a77570326e/jiter-0.14.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:02c4a7ab56f746014874f2c525584c0daca1dec37f66fd707ecef3b7e5c2228c", size = 347393, upload-time = "2026-04-10T14:26:05.314Z" },
{ url = "https://files.pythonhosted.org/packages/bc/db/9b39e09ceafa9878235c0fc29e3e3f9b12a4c6a98ea3085b998cadf3accc/jiter-0.14.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:376e9dafff914253bb9d46cdc5f7965607fbe7feb0a491c34e35f92b2770702e", size = 372937, upload-time = "2026-04-10T14:26:06.884Z" },
{ url = "https://files.pythonhosted.org/packages/b0/96/0dcba1d7a82c1b720774b48ef239376addbaf30df24c34742ac4a57b67b2/jiter-0.14.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23ad2a7a9da1935575c820428dd8d2490ce4d23189691ce33da1fc0a58e14e1c", size = 463646, upload-time = "2026-04-10T14:26:08.345Z" },
{ url = "https://files.pythonhosted.org/packages/f1/e3/f61b71543e746e6b8b805e7755814fc242715c16f1dba58e1cbccb8032c2/jiter-0.14.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54b3ddf5786bc7732d293bba3411ac637ecfa200a39983166d1df86a59a43c9f", size = 380225, upload-time = "2026-04-10T14:26:10.161Z" },
{ url = "https://files.pythonhosted.org/packages/ad/5e/0ddeb7096aca099114abe36c4921016e8d251e6f35f5890240b31f1f60ae/jiter-0.14.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c001d5a646c2a50dc055dd526dad5d5245969e8234d2b1131d0451e81f3a373", size = 358682, upload-time = "2026-04-10T14:26:11.574Z" },
{ url = "https://files.pythonhosted.org/packages/e9/d1/fe0c46cd7fda9cad8f1ff9ad217dc61f1e4280b21052ec6dfe88c1446ef2/jiter-0.14.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:834bb5bdabca2e91592a03d373838a8d0a1b8bbde7077ae6913fd2fc51812d00", size = 359973, upload-time = "2026-04-10T14:26:13.316Z" },
{ url = "https://files.pythonhosted.org/packages/ac/21/f5317f91729b501019184771c80d60abd89907009e7bfa6c7e348c5bdd44/jiter-0.14.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4e9178be60e229b1b2b0710f61b9e24d1f4f8556985a83ff4c4f95920eea7314", size = 397568, upload-time = "2026-04-10T14:26:15.212Z" },
{ url = "https://files.pythonhosted.org/packages/e9/05/79d8f33fb2bf168db0df5c9cd16fe440a8ada57e929d3677b22712c2568f/jiter-0.14.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a7e4ccff04ec03614e62c613e976a3a5860dc9714ce8266f44328bdc8b1cab2c", size = 522535, upload-time = "2026-04-10T14:26:16.956Z" },
{ url = "https://files.pythonhosted.org/packages/5c/00/d1e3ff3d2a465e67f08507d74bafb2dcd29eba91dc939820e39e8dea38b8/jiter-0.14.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:69539d936fb5d55caf6ecd33e2e884de083ff0ea28579780d56c4403094bb8d9", size = 556709, upload-time = "2026-04-10T14:26:18.5Z" },
{ url = "https://files.pythonhosted.org/packages/60/5b/bbb2189f62ace8d95e869aa4c84c9946616f301e2d02895a6f20dcc3bba3/jiter-0.14.0-cp311-cp311-win32.whl", hash = "sha256:4927d09b3e572787cc5e0a5318601448e1ab9391bcef95677f5840c2d00eaa6d", size = 208660, upload-time = "2026-04-10T14:26:20.511Z" },
{ url = "https://files.pythonhosted.org/packages/b8/86/c500b53dcbf08575f5963e536ebd757a1f7c568272ba5d180b212c9a87fb/jiter-0.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:42d6ed359ac49eb922fdd565f209c57340aa06d589c84c8413e42a0f9ae1b842", size = 204659, upload-time = "2026-04-10T14:26:22.152Z" },
{ url = "https://files.pythonhosted.org/packages/75/4a/a676249049d42cb29bef82233e4fe0524d414cbe3606c7a4b311193c2f77/jiter-0.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:6dd689f5f4a5a33747b28686e051095beb214fe28cfda5e9fe58a295a788f593", size = 194772, upload-time = "2026-04-10T14:26:23.458Z" },
{ url = "https://files.pythonhosted.org/packages/5a/68/7390a418f10897da93b158f2d5a8bd0bcd73a0f9ec3bb36917085bb759ef/jiter-0.14.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:2fb2ce3a7bc331256dfb14cefc34832366bb28a9aca81deaf43bbf2a5659e607", size = 316295, upload-time = "2026-04-10T14:26:24.887Z" },
{ url = "https://files.pythonhosted.org/packages/60/a0/5854ac00ff63551c52c6c89534ec6aba4b93474e7924d64e860b1c94165b/jiter-0.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5252a7ca23785cef5d02d4ece6077a1b556a410c591b379f82091c3001e14844", size = 315898, upload-time = "2026-04-10T14:26:26.601Z" },
{ url = "https://files.pythonhosted.org/packages/41/a1/4f44832650a16b18e8391f1bf1d6ca4909bc738351826bcc198bba4357f4/jiter-0.14.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c409578cbd77c338975670ada777add4efd53379667edf0aceea730cabede6fb", size = 343730, upload-time = "2026-04-10T14:26:28.326Z" },
{ url = "https://files.pythonhosted.org/packages/48/64/a329e9d469f86307203594b1707e11ae51c3348d03bfd514a5f997870012/jiter-0.14.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ede4331a1899d604463369c730dbb961ffdc5312bc7f16c41c2896415b1304a", size = 370102, upload-time = "2026-04-10T14:26:30.089Z" },
{ url = "https://files.pythonhosted.org/packages/94/c1/5e3dfc59635aa4d4c7bd20a820ac1d09b8ed851568356802cf1c08edb3cf/jiter-0.14.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92cd8b6025981a041f5310430310b55b25ca593972c16407af8837d3d7d2ca01", size = 461335, upload-time = "2026-04-10T14:26:31.911Z" },
{ url = "https://files.pythonhosted.org/packages/e3/1b/dd157009dbc058f7b00108f545ccb72a2d56461395c4fc7b9cfdccb00af4/jiter-0.14.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:351bf6eda4e3a7ceb876377840c702e9a3e4ecc4624dbfb2d6463c67ae52637d", size = 378536, upload-time = "2026-04-10T14:26:33.595Z" },
{ url = "https://files.pythonhosted.org/packages/91/78/256013667b7c10b8834f8e6e54cd3e562d4c6e34227a1596addccc05e38c/jiter-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1dcfbeb93d9ecd9ca128bbf8910120367777973fa193fb9a39c31237d8df165", size = 353859, upload-time = "2026-04-10T14:26:35.098Z" },
{ url = "https://files.pythonhosted.org/packages/de/d9/137d65ade9093a409fe80955ce60b12bb753722c986467aeda47faf450ad/jiter-0.14.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:ae039aaef8de3f8157ecc1fdd4d85043ac4f57538c245a0afaecb8321ec951c3", size = 357626, upload-time = "2026-04-10T14:26:36.685Z" },
{ url = "https://files.pythonhosted.org/packages/2e/48/76750835b87029342727c1a268bea8878ab988caf81ee4e7b880900eeb5a/jiter-0.14.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7d9d51eb96c82a9652933bd769fe6de66877d6eb2b2440e281f2938c51b5643e", size = 393172, upload-time = "2026-04-10T14:26:38.097Z" },
{ url = "https://files.pythonhosted.org/packages/a6/60/456c4e81d5c8045279aefe60e9e483be08793828800a4e64add8fdde7f2a/jiter-0.14.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d824ca4148b705970bf4e120924a212fdfca9859a73e42bd7889a63a4ea6bb98", size = 520300, upload-time = "2026-04-10T14:26:39.532Z" },
{ url = "https://files.pythonhosted.org/packages/a8/9f/2020e0984c235f678dced38fe4eec3058cf528e6af36ebf969b410305941/jiter-0.14.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ff3a6465b3a0f54b1a430f45c3c0ba7d61ceb45cbc3e33f9e1a7f638d690baf3", size = 553059, upload-time = "2026-04-10T14:26:40.991Z" },
{ url = "https://files.pythonhosted.org/packages/ef/32/e2d298e1a22a4bbe6062136d1c7192db7dba003a6975e51d9a9eecabc4c2/jiter-0.14.0-cp312-cp312-win32.whl", hash = "sha256:5dec7c0a3e98d2a3f8a2e67382d0d7c3ac60c69103a4b271da889b4e8bb1e129", size = 206030, upload-time = "2026-04-10T14:26:42.517Z" },
{ url = "https://files.pythonhosted.org/packages/36/ac/96369141b3d8a4a8e4590e983085efe1c436f35c0cda940dd76d942e3e40/jiter-0.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:fc7e37b4b8bc7e80a63ad6cfa5fc11fab27dbfea4cc4ae644b1ab3f273dc348f", size = 201603, upload-time = "2026-04-10T14:26:44.328Z" },
{ url = "https://files.pythonhosted.org/packages/01/c3/75d847f264647017d7e3052bbcc8b1e24b95fa139c320c5f5066fa7a0bdd/jiter-0.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:ee4a72f12847ef29b072aee9ad5474041ab2924106bdca9fcf5d7d965853e057", size = 191525, upload-time = "2026-04-10T14:26:46Z" },
{ url = "https://files.pythonhosted.org/packages/97/2a/09f70020898507a89279659a1afe3364d57fc1b2c89949081975d135f6f5/jiter-0.14.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:af72f204cf4d44258e5b4c1745130ac45ddab0e71a06333b01de660ab4187a94", size = 315502, upload-time = "2026-04-10T14:26:47.697Z" },
{ url = "https://files.pythonhosted.org/packages/d6/be/080c96a45cd74f9fce5db4fd68510b88087fb37ffe2541ff73c12db92535/jiter-0.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4b77da71f6e819be5fbcec11a453fde5b1d0267ef6ed487e2a392fd8e14e4e3a", size = 314870, upload-time = "2026-04-10T14:26:49.149Z" },
{ url = "https://files.pythonhosted.org/packages/7d/5e/2d0fee155826a968a832cc32438de5e2a193292c8721ca70d0b53e58245b/jiter-0.14.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f4ea612fe8b84b8b04e51d0e78029ecf3466348e25973f953de6e6a59aa4c1", size = 343406, upload-time = "2026-04-10T14:26:50.762Z" },
{ url = "https://files.pythonhosted.org/packages/70/af/bf9ee0d3a4f8dc0d679fc1337f874fe60cdbf841ebbb304b374e1c9aaceb/jiter-0.14.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62fe2451f8fcc0240261e6a4df18ecbcd58327857e61e625b2393ea3b468aac9", size = 369415, upload-time = "2026-04-10T14:26:52.188Z" },
{ url = "https://files.pythonhosted.org/packages/0f/83/8e8561eadba31f4d3948a5b712fb0447ec71c3560b57a855449e7b8ddc98/jiter-0.14.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6112f26f5afc75bcb475787d29da3aa92f9d09c7858f632f4be6ffe607be82e9", size = 461456, upload-time = "2026-04-10T14:26:53.611Z" },
{ url = "https://files.pythonhosted.org/packages/f6/c9/c5299e826a5fe6108d172b344033f61c69b1bb979dd8d9ddd4278a160971/jiter-0.14.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:215a6cb8fb7dc702aa35d475cc00ddc7f970e5c0b1417fb4b4ac5d82fa2a29db", size = 378488, upload-time = "2026-04-10T14:26:55.211Z" },
{ url = "https://files.pythonhosted.org/packages/5d/37/c16d9d15c0a471b8644b1abe3c82668092a707d9bedcf076f24ff2e380cd/jiter-0.14.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc4ab96a30fb3cb2c7e0cd33f7616c8860da5f5674438988a54ac717caccdbaa", size = 353242, upload-time = "2026-04-10T14:26:56.705Z" },
{ url = "https://files.pythonhosted.org/packages/58/ea/8050cb0dc654e728e1bfacbc0c640772f2181af5dedd13ae70145743a439/jiter-0.14.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3a99c1387b1f2928f799a9de899193484d66206a50e98233b6b088a7f0c1edb2", size = 356823, upload-time = "2026-04-10T14:26:58.281Z" },
{ url = "https://files.pythonhosted.org/packages/b0/3b/cf71506d270e5f84d97326bf220e47aed9b95e9a4a060758fb07772170ab/jiter-0.14.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ab18d11074485438695f8d34a1b6da61db9754248f96d51341956607a8f39985", size = 392564, upload-time = "2026-04-10T14:27:00.018Z" },
{ url = "https://files.pythonhosted.org/packages/b0/cc/8c6c74a3efb5bd671bfd14f51e8a73375464ca914b1551bc3b40e26ac2c9/jiter-0.14.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:801028dcfc26ac0895e4964cbc0fd62c73be9fd4a7d7b1aaf6e5790033a719b7", size = 520322, upload-time = "2026-04-10T14:27:01.664Z" },
{ url = "https://files.pythonhosted.org/packages/41/24/68d7b883ec959884ddf00d019b2e0e82ba81b167e1253684fa90519ce33c/jiter-0.14.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ad425b087aafb4a1c7e1e98a279200743b9aaf30c3e0ba723aec93f061bd9bc8", size = 552619, upload-time = "2026-04-10T14:27:03.316Z" },
{ url = "https://files.pythonhosted.org/packages/b6/89/b1a0985223bbf3150ff9e8f46f98fc9360c1de94f48abe271bbe1b465682/jiter-0.14.0-cp313-cp313-win32.whl", hash = "sha256:882bcb9b334318e233950b8be366fe5f92c86b66a7e449e76975dfd6d776a01f", size = 205699, upload-time = "2026-04-10T14:27:04.662Z" },
{ url = "https://files.pythonhosted.org/packages/4c/19/3f339a5a7f14a11730e67f6be34f9d5105751d547b615ef593fa122a5ded/jiter-0.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:9b8c571a5dba09b98bd3462b5a53f27209a5cbbe85670391692ede71974e979f", size = 201323, upload-time = "2026-04-10T14:27:06.139Z" },
{ url = "https://files.pythonhosted.org/packages/50/56/752dd89c84be0e022a8ea3720bcfa0a8431db79a962578544812ce061739/jiter-0.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:34f19dcc35cb1abe7c369b3756babf8c7f04595c0807a848df8f26ef8298ef92", size = 191099, upload-time = "2026-04-10T14:27:07.564Z" },
{ url = "https://files.pythonhosted.org/packages/91/28/292916f354f25a1fe8cf2c918d1415c699a4a659ae00be0430e1c5d9ffea/jiter-0.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e89bcd7d426a75bb4952c696b267075790d854a07aad4c9894551a82c5b574ab", size = 320880, upload-time = "2026-04-10T14:27:09.326Z" },
{ url = "https://files.pythonhosted.org/packages/ad/c7/b002a7d8b8957ac3d469bd59c18ef4b1595a5216ae0de639a287b9816023/jiter-0.14.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b25beaa0d4447ea8c7ae0c18c688905d34840d7d0b937f2f7bdd52162c98a40", size = 346563, upload-time = "2026-04-10T14:27:11.287Z" },
{ url = "https://files.pythonhosted.org/packages/f9/3b/f8d07580d8706021d255a6356b8fab13ee4c869412995550ce6ed4ddf97d/jiter-0.14.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:651a8758dd413c51e3b7f6557cdc6921faf70b14106f45f969f091f5cda990ea", size = 357928, upload-time = "2026-04-10T14:27:12.729Z" },
{ url = "https://files.pythonhosted.org/packages/47/5b/ac1a974da29e35507230383110ffec59998b290a8732585d04e19a9eb5ba/jiter-0.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:e1a7eead856a5038a8d291f1447176ab0b525c77a279a058121b5fccee257f6f", size = 203519, upload-time = "2026-04-10T14:27:14.125Z" },
{ url = "https://files.pythonhosted.org/packages/96/6d/9fc8433d667d2454271378a79747d8c76c10b51b482b454e6190e511f244/jiter-0.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e692633a12cda97e352fdcd1c4acc971b1c28707e1e33aeef782b0cbf051975", size = 190113, upload-time = "2026-04-10T14:27:16.638Z" },
{ url = "https://files.pythonhosted.org/packages/4f/1e/354ed92461b165bd581f9ef5150971a572c873ec3b68a916d5aa91da3cc2/jiter-0.14.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:6f396837fc7577871ca8c12edaf239ed9ccef3bbe39904ae9b8b63ce0a48b140", size = 315277, upload-time = "2026-04-10T14:27:18.109Z" },
{ url = "https://files.pythonhosted.org/packages/a6/95/8c7c7028aa8636ac21b7a55faef3e34215e6ed0cbf5ae58258427f621aa3/jiter-0.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a4d50ea3d8ba4176f79754333bd35f1bbcd28e91adc13eb9b7ca91bc52a6cef9", size = 315923, upload-time = "2026-04-10T14:27:19.603Z" },
{ url = "https://files.pythonhosted.org/packages/47/40/e2a852a44c4a089f2681a16611b7ce113224a80fd8504c46d78491b47220/jiter-0.14.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce17f8a050447d1b4153bda4fb7d26e6a9e74eb4f4a41913f30934c5075bf615", size = 344943, upload-time = "2026-04-10T14:27:21.262Z" },
{ url = "https://files.pythonhosted.org/packages/fc/1f/670f92adee1e9895eac41e8a4d623b6da68c4d46249d8b556b60b63f949e/jiter-0.14.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4f1c4b125e1652aefbc2e2c1617b60a160ab789d180e3d423c41439e5f32850", size = 369725, upload-time = "2026-04-10T14:27:22.766Z" },
{ url = "https://files.pythonhosted.org/packages/01/2f/541c9ba567d05de1c4874a0f8f8c5e3fd78e2b874266623da9a775cf46e0/jiter-0.14.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be808176a6a3a14321d18c603f2d40741858a7c4fc982f83232842689fe86dd9", size = 461210, upload-time = "2026-04-10T14:27:24.315Z" },
{ url = "https://files.pythonhosted.org/packages/ce/a9/c31cbec09627e0d5de7aeaec7690dba03e090caa808fefd8133137cf45bc/jiter-0.14.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26679d58ba816f88c3849306dd58cb863a90a1cf352cdd4ef67e30ccf8a77994", size = 380002, upload-time = "2026-04-10T14:27:26.155Z" },
{ url = "https://files.pythonhosted.org/packages/50/02/3c05c1666c41904a2f607475a73e7a4763d1cbde2d18229c4f85b22dc253/jiter-0.14.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80381f5a19af8fa9aef743f080e34f6b25ebd89656475f8cf0470ec6157052aa", size = 354678, upload-time = "2026-04-10T14:27:27.701Z" },
{ url = "https://files.pythonhosted.org/packages/7d/97/e15b33545c2b13518f560d695f974b9891b311641bdcf178d63177e8801e/jiter-0.14.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:004df5fdb8ecbd6d99f3227df18ba1a259254c4359736a2e6f036c944e02d7c5", size = 358920, upload-time = "2026-04-10T14:27:29.256Z" },
{ url = "https://files.pythonhosted.org/packages/ad/d2/8b1461def6b96ba44530df20d07ef7a1c7da22f3f9bf1727e2d611077bf1/jiter-0.14.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cff5708f7ed0fa098f2b53446c6fa74c48469118e5cd7497b4f1cd569ab06928", size = 394512, upload-time = "2026-04-10T14:27:31.344Z" },
{ url = "https://files.pythonhosted.org/packages/e3/88/837566dd6ed6e452e8d3205355afd484ce44b2533edfa4ed73a298ea893e/jiter-0.14.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:2492e5f06c36a976d25c7cc347a60e26d5470178d44cde1b9b75e60b4e519f28", size = 521120, upload-time = "2026-04-10T14:27:33.299Z" },
{ url = "https://files.pythonhosted.org/packages/89/6b/b00b45c4d1b4c031777fe161d620b755b5b02cdade1e316dcb46e4471d63/jiter-0.14.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:7609cfbe3a03d37bfdbf5052012d5a879e72b83168a363deae7b3a26564d57de", size = 553668, upload-time = "2026-04-10T14:27:34.868Z" },
{ url = "https://files.pythonhosted.org/packages/ad/d8/6fe5b42011d19397433d345716eac16728ac241862a2aac9c91923c7509a/jiter-0.14.0-cp314-cp314-win32.whl", hash = "sha256:7282342d32e357543565286b6450378c3cd402eea333fc1ebe146f1fabb306fc", size = 207001, upload-time = "2026-04-10T14:27:36.455Z" },
{ url = "https://files.pythonhosted.org/packages/e5/43/5c2e08da1efad5e410f0eaaabeadd954812612c33fbbd8fd5328b489139d/jiter-0.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:bd77945f38866a448e73b0b7637366afa814d4617790ecd88a18ca74377e6c02", size = 202187, upload-time = "2026-04-10T14:27:38Z" },
{ url = "https://files.pythonhosted.org/packages/aa/1f/6e39ac0b4cdfa23e606af5b245df5f9adaa76f35e0c5096790da430ca506/jiter-0.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:f2d4c61da0821ee42e0cdf5489da60a6d074306313a377c2b35af464955a3611", size = 192257, upload-time = "2026-04-10T14:27:39.504Z" },
{ url = "https://files.pythonhosted.org/packages/05/57/7dbc0ffbbb5176a27e3518716608aa464aee2e2887dc938f0b900a120449/jiter-0.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1bf7ff85517dd2f20a5750081d2b75083c1b269cf75afc7511bdf1f9548beb3b", size = 323441, upload-time = "2026-04-10T14:27:41.039Z" },
{ url = "https://files.pythonhosted.org/packages/83/6e/7b3314398d8983f06b557aa21b670511ec72d3b79a68ee5e4d9bff972286/jiter-0.14.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8ef8791c3e78d6c6b157c6d360fbb5c715bebb8113bc6a9303c5caff012754a", size = 348109, upload-time = "2026-04-10T14:27:42.552Z" },
{ url = "https://files.pythonhosted.org/packages/ae/4f/8dc674bcd7db6dba566de73c08c763c337058baff1dbeb34567045b27cdc/jiter-0.14.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e74663b8b10da1fe0f4e4703fd7980d24ad17174b6bb35d8498d6e3ebce2ae6a", size = 368328, upload-time = "2026-04-10T14:27:44.574Z" },
{ url = "https://files.pythonhosted.org/packages/3b/5f/188e09a1f20906f98bbdec44ed820e19f4e8eb8aff88b9d1a5a497587ff3/jiter-0.14.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1aca29ba52913f78362ec9c2da62f22cdc4c3083313403f90c15460979b84d9b", size = 463301, upload-time = "2026-04-10T14:27:46.717Z" },
{ url = "https://files.pythonhosted.org/packages/ac/f0/19046ef965ed8f349e8554775bb12ff4352f443fbe12b95d31f575891256/jiter-0.14.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b39b7d87a952b79949af5fef44d2544e58c21a28da7f1bae3ef166455c61746", size = 378891, upload-time = "2026-04-10T14:27:48.32Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c3/da43bd8431ee175695777ee78cf0e93eacbb47393ff493f18c45231b427d/jiter-0.14.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d918a68b26e9fab068c2b5453577ef04943ab2807b9a6275df2a812599a310", size = 360749, upload-time = "2026-04-10T14:27:49.88Z" },
{ url = "https://files.pythonhosted.org/packages/72/26/e054771be889707c6161dbdec9c23d33a9ec70945395d70f07cfea1e9a6f/jiter-0.14.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:b08997c35aee1201c1a5361466a8fb9162d03ae7bf6568df70b6c859f1e654a4", size = 358526, upload-time = "2026-04-10T14:27:51.504Z" },
{ url = "https://files.pythonhosted.org/packages/c3/0f/7bea65ea2a6d91f2bf989ff11a18136644392bf2b0497a1fa50934c30a9c/jiter-0.14.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:260bf7ca20704d58d41f669e5e9fe7fe2fa72901a6b324e79056f5d52e9c9be2", size = 393926, upload-time = "2026-04-10T14:27:53.368Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/b1ff7d70deef61ac0b7c6c2f12d2ace950cdeecb4fdc94500a0926802857/jiter-0.14.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:37826e3df29e60f30a382f9294348d0238ef127f4b5d7f5f8da78b5b9e050560", size = 521052, upload-time = "2026-04-10T14:27:55.058Z" },
{ url = "https://files.pythonhosted.org/packages/0b/7b/3b0649983cbaf15eda26a414b5b1982e910c67bd6f7b1b490f3cfc76896a/jiter-0.14.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:645be49c46f2900937ba0eaf871ad5183c96858c0af74b6becc7f4e367e36e06", size = 553716, upload-time = "2026-04-10T14:27:57.269Z" },
{ url = "https://files.pythonhosted.org/packages/97/f8/33d78c83bd93ae0c0af05293a6660f88a1977caef39a6d72a84afab94ce0/jiter-0.14.0-cp314-cp314t-win32.whl", hash = "sha256:2f7877ed45118de283786178eceaf877110abacd04fde31efff3940ae9672674", size = 207957, upload-time = "2026-04-10T14:27:59.285Z" },
{ url = "https://files.pythonhosted.org/packages/d6/ac/2b760516c03e2227826d1f7025d89bf6bf6357a28fe75c2a2800873c50bf/jiter-0.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:14c0cb10337c49f5eafe8e7364daca5e29a020ea03580b8f8e6c597fed4e1588", size = 204690, upload-time = "2026-04-10T14:28:00.962Z" },
{ url = "https://files.pythonhosted.org/packages/dc/2e/a44c20c58aeed0355f2d326969a181696aeb551a25195f47563908a815be/jiter-0.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5419d4aa2024961da9fe12a9cfe7484996735dca99e8e090b5c88595ef1951ff", size = 191338, upload-time = "2026-04-10T14:28:02.853Z" },
{ url = "https://files.pythonhosted.org/packages/32/a1/ef34ca2cab2962598591636a1804b93645821201cc0095d4a93a9a329c9d/jiter-0.14.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:a25ffa2dbbdf8721855612f6dca15c108224b12d0c4024d0ac3d7902132b4211", size = 311366, upload-time = "2026-04-10T14:28:27.943Z" },
{ url = "https://files.pythonhosted.org/packages/60/bb/520576a532a6b8a6f42747afed289c8448c879a34d7802fe2c832d4fd38f/jiter-0.14.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ac9cbaa86c10996b92bd12c91659b60f939f8e28fcfa6bc11a0e90a774ce95b", size = 309873, upload-time = "2026-04-10T14:28:29.688Z" },
{ url = "https://files.pythonhosted.org/packages/b2/7c/c16db114ea1f2f532f198aa8dc39585026af45af362c69a0492f31bc4821/jiter-0.14.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:844e73b6c56b505e9e169234ea3bdea2ea43f769f847f47ac559ba1d2361ebea", size = 344816, upload-time = "2026-04-10T14:28:31.348Z" },
{ url = "https://files.pythonhosted.org/packages/99/8f/15e7741ff19e9bcd4d753f7ff22f988fd54592f134ca13701c13ea8c20e0/jiter-0.14.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e52c076f187405fc21523c746c04399c9af8ece566077ed147b2126f2bcba577", size = 351445, upload-time = "2026-04-10T14:28:33.093Z" },
{ url = "https://files.pythonhosted.org/packages/21/42/9042c3f3019de4adcb8c16591c325ec7255beea9fcd33a42a43f3b0b1000/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:fbd9e482663ca9d005d051330e4d2d8150bb208a209409c10f7e7dfdf7c49da9", size = 308810, upload-time = "2026-04-10T14:28:34.673Z" },
{ url = "https://files.pythonhosted.org/packages/60/cf/a7e19b308bd86bb04776803b1f01a5f9a287a4c55205f4708827ee487fbf/jiter-0.14.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:33a20d838b91ef376b3a56896d5b04e725c7df5bc4864cc6569cf046a8d73b6d", size = 308443, upload-time = "2026-04-10T14:28:36.658Z" },
{ url = "https://files.pythonhosted.org/packages/ca/44/e26ede3f0caeff93f222559cb0cc4ca68579f07d009d7b6010c5b586f9b1/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:432c4db5255d86a259efde91e55cb4c8d18c0521d844c9e2e7efcce3899fb016", size = 343039, upload-time = "2026-04-10T14:28:38.356Z" },
{ url = "https://files.pythonhosted.org/packages/da/e9/1f9ada30cef7b05e74bb06f52127e7a724976c225f46adb65c37b1dadfb6/jiter-0.14.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67f00d94b281174144d6532a04b66a12cb866cbdc47c3af3bfe2973677f9861a", size = 349613, upload-time = "2026-04-10T14:28:40.066Z" },
]
[[package]]
name = "localdiplomacy-agent"
version = "0.1.0"
@@ -526,14 +272,11 @@ dependencies = [
{ name = "httpx" },
{ name = "pydantic" },
{ name = "pyyaml" },
{ name = "qdrant-client" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.optional-dependencies]
memory = [
{ name = "mem0ai" },
{ name = "qdrant-client" },
]
test = [
{ name = "pytest" },
]
@@ -542,32 +285,13 @@ test = [
requires-dist = [
{ name = "fastapi", specifier = ">=0.115.0" },
{ name = "httpx", specifier = ">=0.27.0" },
{ name = "mem0ai", marker = "extra == 'memory'", specifier = ">=0.1.0" },
{ name = "pydantic", specifier = ">=2.8.0" },
{ name = "pytest", marker = "extra == 'test'", specifier = ">=8.2.0" },
{ name = "pyyaml", specifier = ">=6.0.0" },
{ name = "qdrant-client", marker = "extra == 'memory'", specifier = ">=1.10.0" },
{ name = "qdrant-client", specifier = ">=1.10.0" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" },
]
provides-extras = ["memory", "test"]
[[package]]
name = "mem0ai"
version = "2.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "openai" },
{ name = "posthog" },
{ name = "protobuf" },
{ name = "pydantic" },
{ name = "pytz" },
{ name = "qdrant-client" },
{ name = "sqlalchemy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ef/03/3dc535b98310912e4f10083acdbbca2c5e2dfccb3921230a460464f9f4d0/mem0ai-2.0.1.tar.gz", hash = "sha256:070dbc3f1f332c8908379b42a81ab3a96ab169f2f9fa537e6ac719df02478f9c", size = 211820, upload-time = "2026-04-25T17:39:06.744Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/96/e6153262f1464f4d412208732fea31496d9983ade155dd2c5c5492f8f8a4/mem0ai-2.0.1-py3-none-any.whl", hash = "sha256:63da5f50ad0c2514e27c2f380ef03f2ceea47c97873096ddfd997785b58043ec", size = 299461, upload-time = "2026-04-25T17:39:04.143Z" },
]
provides-extras = ["test"]
[[package]]
name = "numpy"
@@ -648,25 +372,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" },
]
[[package]]
name = "openai"
version = "2.33.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "distro" },
{ name = "httpx" },
{ name = "jiter" },
{ name = "pydantic" },
{ name = "sniffio" },
{ name = "tqdm" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f0/ee/d056c82f63c05f06baac0cffb4a90952d8274f90c49dfe244f20497b9bbd/openai-2.33.0.tar.gz", hash = "sha256:f850c435e2a4685bba3295bd54912dd26315d9c1b7733068186134d6e0599f9a", size = 693254, upload-time = "2026-04-28T14:04:42.428Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7d/32/37734d769bc8b42e4938785313cc05aade6cb0fa72479d3220a0d61a4e78/openai-2.33.0-py3-none-any.whl", hash = "sha256:03ac37d70e8c9e3a8124214e3afa785e2cbc12e627fbd98177a086ef2fd87ad5", size = 1162695, upload-time = "2026-04-28T14:04:40.482Z" },
]
[[package]]
name = "packaging"
version = "26.2"
@@ -697,22 +402,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/a6/38c8e2f318bf67d338f4d629e93b0b4b9af331f455f0390ea8ce4a099b26/portalocker-3.2.0-py3-none-any.whl", hash = "sha256:3cdc5f565312224bc570c49337bd21428bba0ef363bbcf58b9ef4a9f11779968", size = 22424, upload-time = "2025-06-14T13:20:38.083Z" },
]
[[package]]
name = "posthog"
version = "7.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backoff" },
{ name = "distro" },
{ name = "python-dateutil" },
{ name = "requests" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2a/09/ecc82b5ba5876164a3807adcc5101466da1e4416600075bdbd2071327457/posthog-7.13.1.tar.gz", hash = "sha256:5e53c57db076807530bbec5634c96673ceae8e8e58b99c983af26f02bb4759aa", size = 194124, upload-time = "2026-04-24T19:08:32.56Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/bf/eafd5e7508b03264b7deb4db6563c4a2830de7114e01ccbf369756b779d1/posthog-7.13.1-py3-none-any.whl", hash = "sha256:fc0f4b4a8878957e1ea8d319b2e4038b66a19625837f59b020cddaaf59fce982", size = 228291, upload-time = "2026-04-24T19:08:30.822Z" },
]
[[package]]
name = "protobuf"
version = "6.33.6"
@@ -870,18 +559,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
@@ -891,15 +568,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "pytz"
version = "2026.1.post1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" },
]
[[package]]
name = "pywin32"
version = "311"
@@ -992,92 +660,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/68/69/77d1a971c4b933e8c79403e99bcbb790463da5e48333cc4fd5d412c63c98/qdrant_client-1.17.1-py3-none-any.whl", hash = "sha256:6cda4064adfeaf211c751f3fbc00edbbdb499850918c7aff4855a9a759d56cbd", size = 389947, upload-time = "2026-03-13T17:13:43.156Z" },
]
[[package]]
name = "requests"
version = "2.33.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[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 = "sqlalchemy"
version = "2.0.49"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/60/b5/e3617cc67420f8f403efebd7b043128f94775e57e5b84e7255203390ceae/sqlalchemy-2.0.49-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5070135e1b7409c4161133aa525419b0062088ed77c92b1da95366ec5cbebbe", size = 2159126, upload-time = "2026-04-03T16:50:13.242Z" },
{ url = "https://files.pythonhosted.org/packages/20/9b/91ca80403b17cd389622a642699e5f6564096b698e7cdcbcbb6409898bc4/sqlalchemy-2.0.49-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ac7a3e245fd0310fd31495eb61af772e637bdf7d88ee81e7f10a3f271bff014", size = 3315509, upload-time = "2026-04-03T16:54:49.332Z" },
{ url = "https://files.pythonhosted.org/packages/b1/61/0722511d98c54de95acb327824cb759e8653789af2b1944ab1cc69d32565/sqlalchemy-2.0.49-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d4e5a0ceba319942fa6b585cf82539288a61e314ef006c1209f734551ab9536", size = 3315014, upload-time = "2026-04-03T16:56:56.376Z" },
{ url = "https://files.pythonhosted.org/packages/46/55/d514a653ffeb4cebf4b54c47bec32ee28ad89d39fafba16eeed1d81dccd5/sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ddcb27fb39171de36e207600116ac9dfd4ae46f86c82a9bf3934043e80ebb88", size = 3267388, upload-time = "2026-04-03T16:54:51.272Z" },
{ url = "https://files.pythonhosted.org/packages/2f/16/0dcc56cb6d3335c1671a2258f5d2cb8267c9a2260e27fde53cbfb1b3540a/sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:32fe6a41ad97302db2931f05bb91abbcc65b5ce4c675cd44b972428dd2947700", size = 3289602, upload-time = "2026-04-03T16:56:57.63Z" },
{ url = "https://files.pythonhosted.org/packages/51/6c/f8ab6fb04470a133cd80608db40aa292e6bae5f162c3a3d4ab19544a67af/sqlalchemy-2.0.49-cp311-cp311-win32.whl", hash = "sha256:46d51518d53edfbe0563662c96954dc8fcace9832332b914375f45a99b77cc9a", size = 2119044, upload-time = "2026-04-03T17:00:53.455Z" },
{ url = "https://files.pythonhosted.org/packages/c4/59/55a6d627d04b6ebb290693681d7683c7da001eddf90b60cfcc41ee907978/sqlalchemy-2.0.49-cp311-cp311-win_amd64.whl", hash = "sha256:951d4a210744813be63019f3df343bf233b7432aadf0db54c75802247330d3af", size = 2143642, upload-time = "2026-04-03T17:00:54.769Z" },
{ url = "https://files.pythonhosted.org/packages/49/b3/2de412451330756aaaa72d27131db6dde23995efe62c941184e15242a5fa/sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b", size = 2157681, upload-time = "2026-04-03T16:53:07.132Z" },
{ url = "https://files.pythonhosted.org/packages/50/84/b2a56e2105bd11ebf9f0b93abddd748e1a78d592819099359aa98134a8bf/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982", size = 3338976, upload-time = "2026-04-03T17:07:40Z" },
{ url = "https://files.pythonhosted.org/packages/2c/fa/65fcae2ed62f84ab72cf89536c7c3217a156e71a2c111b1305ab6f0690e2/sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672", size = 3351937, upload-time = "2026-04-03T17:12:23.374Z" },
{ url = "https://files.pythonhosted.org/packages/f8/2f/6fd118563572a7fe475925742eb6b3443b2250e346a0cc27d8d408e73773/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e", size = 3281646, upload-time = "2026-04-03T17:07:41.949Z" },
{ url = "https://files.pythonhosted.org/packages/c5/d7/410f4a007c65275b9cf82354adb4bb8ba587b176d0a6ee99caa16fe638f8/sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750", size = 3316695, upload-time = "2026-04-03T17:12:25.642Z" },
{ url = "https://files.pythonhosted.org/packages/d9/95/81f594aa60ded13273a844539041ccf1e66c5a7bed0a8e27810a3b52d522/sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0", size = 2117483, upload-time = "2026-04-03T17:05:40.896Z" },
{ url = "https://files.pythonhosted.org/packages/47/9e/fd90114059175cac64e4fafa9bf3ac20584384d66de40793ae2e2f26f3bb/sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4", size = 2144494, upload-time = "2026-04-03T17:05:42.282Z" },
{ url = "https://files.pythonhosted.org/packages/ae/81/81755f50eb2478eaf2049728491d4ea4f416c1eb013338682173259efa09/sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120", size = 2154547, upload-time = "2026-04-03T16:53:08.64Z" },
{ url = "https://files.pythonhosted.org/packages/a2/bc/3494270da80811d08bcfa247404292428c4fe16294932bce5593f215cad9/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2", size = 3280782, upload-time = "2026-04-03T17:07:43.508Z" },
{ url = "https://files.pythonhosted.org/packages/cd/f5/038741f5e747a5f6ea3e72487211579d8cbea5eb9827a9cbd61d0108c4bd/sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3", size = 3297156, upload-time = "2026-04-03T17:12:27.697Z" },
{ url = "https://files.pythonhosted.org/packages/88/50/a6af0ff9dc954b43a65ca9b5367334e45d99684c90a3d3413fc19a02d43c/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7", size = 3228832, upload-time = "2026-04-03T17:07:45.38Z" },
{ url = "https://files.pythonhosted.org/packages/bc/d1/5f6bdad8de0bf546fc74370939621396515e0cdb9067402d6ba1b8afbe9a/sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33", size = 3267000, upload-time = "2026-04-03T17:12:29.657Z" },
{ url = "https://files.pythonhosted.org/packages/f7/30/ad62227b4a9819a5e1c6abff77c0f614fa7c9326e5a3bdbee90f7139382b/sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b", size = 2115641, upload-time = "2026-04-03T17:05:43.989Z" },
{ url = "https://files.pythonhosted.org/packages/17/3a/7215b1b7d6d49dc9a87211be44562077f5f04f9bb5a59552c1c8e2d98173/sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148", size = 2141498, upload-time = "2026-04-03T17:05:45.7Z" },
{ url = "https://files.pythonhosted.org/packages/28/4b/52a0cb2687a9cd1648252bb257be5a1ba2c2ded20ba695c65756a55a15a4/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518", size = 3560807, upload-time = "2026-04-03T16:58:31.666Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d8/fda95459204877eed0458550d6c7c64c98cc50c2d8d618026737de9ed41a/sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d", size = 3527481, upload-time = "2026-04-03T17:06:00.155Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0a/2aac8b78ac6487240cf7afef8f203ca783e8796002dc0cf65c4ee99ff8bb/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0", size = 3468565, upload-time = "2026-04-03T16:58:33.414Z" },
{ url = "https://files.pythonhosted.org/packages/a5/3d/ce71cfa82c50a373fd2148b3c870be05027155ce791dc9a5dcf439790b8b/sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08", size = 3477769, upload-time = "2026-04-03T17:06:02.787Z" },
{ url = "https://files.pythonhosted.org/packages/d5/e8/0a9f5c1f7c6f9ca480319bf57c2d7423f08d31445974167a27d14483c948/sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d", size = 2143319, upload-time = "2026-04-03T17:02:04.328Z" },
{ url = "https://files.pythonhosted.org/packages/0e/51/fb5240729fbec73006e137c4f7a7918ffd583ab08921e6ff81a999d6517a/sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba", size = 2175104, upload-time = "2026-04-03T17:02:05.989Z" },
{ url = "https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e", size = 2156356, upload-time = "2026-04-03T16:53:09.914Z" },
{ url = "https://files.pythonhosted.org/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a", size = 3276486, upload-time = "2026-04-03T17:07:46.9Z" },
{ url = "https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066", size = 3281479, upload-time = "2026-04-03T17:12:32.226Z" },
{ url = "https://files.pythonhosted.org/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187", size = 3226269, upload-time = "2026-04-03T17:07:48.678Z" },
{ url = "https://files.pythonhosted.org/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401", size = 3248260, upload-time = "2026-04-03T17:12:34.381Z" },
{ url = "https://files.pythonhosted.org/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5", size = 2118463, upload-time = "2026-04-03T17:05:47.093Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", size = 2144204, upload-time = "2026-04-03T17:05:48.694Z" },
{ url = "https://files.pythonhosted.org/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977", size = 3565474, upload-time = "2026-04-03T16:58:35.128Z" },
{ url = "https://files.pythonhosted.org/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01", size = 3530567, upload-time = "2026-04-03T17:06:04.587Z" },
{ url = "https://files.pythonhosted.org/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61", size = 3474282, upload-time = "2026-04-03T16:58:37.131Z" },
{ url = "https://files.pythonhosted.org/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a", size = 3480406, upload-time = "2026-04-03T17:06:07.176Z" },
{ url = "https://files.pythonhosted.org/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158", size = 2149151, upload-time = "2026-04-03T17:02:07.281Z" },
{ url = "https://files.pythonhosted.org/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7", size = 2184178, upload-time = "2026-04-03T17:02:08.623Z" },
{ url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" },
]
[[package]]
name = "starlette"
version = "1.0.0"
@@ -1091,18 +673,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" },
]
[[package]]
name = "tqdm"
version = "4.67.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"