Merge pull request #17 from darth-veitcher/007-llm-provider-abstraction
LLM Provider Abstraction: generic adapter pattern for Ollama (ADR-007)
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"feature_directory": "specs/006-ollama-model-integration"
|
||||
"feature_directory": "specs/007-llm-provider-abstraction"
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
- BEACON framework bootstrap: problem statement, constitution, roadmap, architecture document
|
||||
- `CHANGELOG.md` (this file)
|
||||
- README: What-is-this, Install, and Quickstart sections
|
||||
- `LLMProvider` protocol (`comfydv._llm`) — a shared adapter boundary so ComfyUI LLM nodes work with any backend that implements it, starting with `OllamaProvider`. Structured output now goes through `pydantic-ai` (ADR-007), superseding the hand-rolled Ollama tool-calling approach.
|
||||
|
||||
### Changed
|
||||
- **Breaking:** `OllamaChatCompletion` → `ChatCompletion`, `OllamaModelSelector` → `LLMModelSelector`, `OllamaLoadModel` → `LLMLoadModel`, `OllamaUnloadModel` → `LLMUnloadModel`, and the `OLLAMA_CLIENT` socket type → `LLM_CLIENT` — these nodes are now backend-generic. `OllamaClient` is unchanged by name but now outputs an `OllamaProvider` rather than a plain string; existing saved workflows using the old node/socket names need reconnecting (see `comfydv.ollama.MIGRATION_MAP` for the full old→new mapping).
|
||||
|
||||
## [0.1.0] — 2026-06-01
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- SPECKIT START -->
|
||||
For additional context about technologies to be used, project structure,
|
||||
shell commands, and other important information, read the current plan
|
||||
at specs/006-ollama-model-integration/plan.md
|
||||
at specs/007-llm-provider-abstraction/plan.md
|
||||
<!-- SPECKIT END -->
|
||||
|
||||
@@ -11,11 +11,11 @@ A collection of workflow efficiency and quality-of-life nodes built out of neces
|
||||
| **Format String** | Formats a string from a Python f-string or Jinja2 template. Detects variables in the template and automatically adds/removes input sockets. |
|
||||
| **Random Choice** | Accepts any number of typed inputs and outputs one at random, with a configurable seed for reproducibility. |
|
||||
| **Circuit Breaker** | Halts the current ComfyUI queue run gracefully without crashing the server. Wire the `status` toggle to a boolean condition to skip the rest of the queue when a condition isn't met. |
|
||||
| **Ollama Client** | Configures a connection to an Ollama server (default: `http://localhost:11434`). Threads the host URL through the graph as an `OLLAMA_CLIENT` socket. |
|
||||
| **Ollama Model Selector** | Fetches the live model list from Ollama and presents it as a dropdown. Outputs the selected model name. |
|
||||
| **Ollama Load Model** | Loads a model into Ollama's memory using `/api/generate` with `keep_alive=-1`. |
|
||||
| **Ollama Unload Model** | Evicts a model from Ollama's memory using `/api/generate` with `keep_alive=0`. |
|
||||
| **Ollama Chat Completion** | Sends a prompt (and optional conversation history) to Ollama `/api/chat`. Response and history are shown inline in the node body and available as output sockets. |
|
||||
| **Ollama Client** | Configures a connection to an Ollama server (default: `http://localhost:11434`). Threads the connection through the graph as an `LLM_CLIENT` socket — the same generic socket any future backend's client node will emit. |
|
||||
| **LLM Model Selector** | Fetches the live model list from the connected server and presents it as a dropdown. Outputs the selected model name. |
|
||||
| **LLM Load Model** | Loads a model into memory using `/api/generate` with `keep_alive=-1`. |
|
||||
| **LLM Unload Model** | Evicts a model from memory using `/api/generate` with `keep_alive=0`. |
|
||||
| **Chat Completion** | Sends a prompt (and optional conversation history) to the connected server. Response and history are shown inline in the node body and available as output sockets. |
|
||||
| **Ollama Option — \*** | Seven composable option nodes (Temperature, Seed, Max Tokens, Top P, Top K, Repeat Penalty, Extra Body) that merge into an `OLLAMA_OPTIONS` dict wired into Chat Completion. |
|
||||
| **Ollama Debug History** | Serialises an `OLLAMA_HISTORY` list to a pretty-printed JSON string for inspection. |
|
||||
| **Ollama History Length** | Returns the number of messages in an `OLLAMA_HISTORY` list as an integer. |
|
||||
@@ -101,33 +101,33 @@ Typical use: skip an expensive upscale step when a quality-check node says the d
|
||||
|
||||
## Ollama
|
||||
|
||||
14 nodes for integrating a local Ollama LLM into your ComfyUI workflow. The host URL is configured once in **Ollama Client** and threaded through the graph — all downstream nodes receive it via the `OLLAMA_CLIENT` socket.
|
||||
Nodes for integrating a local Ollama LLM into your ComfyUI workflow. The host is configured once in **Ollama Client** and threaded through the graph as an `LLM_CLIENT` socket — a generic connection type any future backend's client node can also emit, so the chat/model-management nodes below aren't Ollama-specific.
|
||||
|
||||
### Ollama Client node
|
||||
|
||||
Configure the server address once; all downstream Ollama nodes inherit it automatically.
|
||||
Configure the server address once; all downstream nodes inherit it automatically.
|
||||
|
||||

|
||||
|
||||
### Model lifecycle (load and unload)
|
||||
|
||||
On memory-constrained machines and single-GPU setups, explicitly loading and unloading the model before and after inference is critical. **Ollama Load Model** pins the model into VRAM (`keep_alive=-1`); **Ollama Unload Model** evicts it immediately (`keep_alive=0`), freeing memory for image generation or other models.
|
||||
On memory-constrained machines and single-GPU setups, explicitly loading and unloading the model before and after inference is critical. **LLM Load Model** pins the model into VRAM (`keep_alive=-1`); **LLM Unload Model** evicts it immediately (`keep_alive=0`), freeing memory for image generation or other models.
|
||||
|
||||

|
||||
|
||||
The correct chain is **Load → Chat → Unload**, enforced through data dependencies:
|
||||
|
||||
1. Wire `OllamaLoadModel.model_name` → `OllamaChatCompletion.model`. This creates the data dependency that guarantees Load runs before Chat and passes the model name into the Chat node's plain-string `model` input.
|
||||
2. Wire `OllamaChatCompletion.model_name` → `OllamaUnloadModel.model`. This guarantees Unload runs after Chat completes.
|
||||
3. Optionally wire `OllamaChatCompletion.response` → `OllamaUnloadModel.passthrough` — Unload returns the response unchanged so the rest of your workflow can still consume it.
|
||||
1. Wire `LLMLoadModel.model_name` → `ChatCompletion.model`. This creates the data dependency that guarantees Load runs before Chat and passes the model name into the Chat node's plain-string `model` input.
|
||||
2. Wire `ChatCompletion.model_name` → `LLMUnloadModel.model`. This guarantees Unload runs after Chat completes.
|
||||
3. Optionally wire `ChatCompletion.response` → `LLMUnloadModel.passthrough` — Unload returns the response unchanged so the rest of your workflow can still consume it.
|
||||
|
||||
### Minimal chat workflow
|
||||
|
||||
1. **Ollama Client** → set host (default `http://localhost:11434`)
|
||||
2. **Ollama Model Selector** → pick a model from the live dropdown (or type/wire a model name directly into Chat Completion's `model` input)
|
||||
3. **Ollama Chat Completion** → wire client + model + prompt; the response appears inline in the node body and is also available as an output socket
|
||||
2. **LLM Model Selector** → pick a model from the live dropdown (or type/wire a model name directly into Chat Completion's `model` input)
|
||||
3. **Chat Completion** → wire client + model + prompt; the response appears inline in the node body and is also available as an output socket
|
||||
|
||||

|
||||

|
||||
|
||||
Wire multiple nodes together for a complete end-to-end workflow:
|
||||
|
||||
@@ -152,3 +152,17 @@ Chain any combination of **Ollama Option —** nodes before Chat Completion to o
|
||||
### Multi-turn conversations
|
||||
|
||||
`OLLAMA_HISTORY` flows out of Chat Completion as a list of `{"role", "content"}` dicts. Wire it back into the next Chat Completion for multi-turn conversations, or inspect it with **Ollama Debug History** / **Ollama History Length**.
|
||||
|
||||
### Upgrading an older workflow
|
||||
|
||||
If you saved a workflow before this rename, ComfyUI will report the old node types as missing when you reopen it. Reconnect using this mapping, then re-run — behavior is unchanged, only the names and the client socket type are different:
|
||||
|
||||
| Old | New |
|
||||
|-----|-----|
|
||||
| `OllamaChatCompletion` | `ChatCompletion` |
|
||||
| `OllamaModelSelector` | `LLMModelSelector` |
|
||||
| `OllamaLoadModel` | `LLMLoadModel` |
|
||||
| `OllamaUnloadModel` | `LLMUnloadModel` |
|
||||
| `OLLAMA_CLIENT` socket | `LLM_CLIENT` socket |
|
||||
|
||||
`OllamaClient` keeps its name — just delete and re-add any downstream node showing as missing, then rewire it to the same `OllamaClient` node.
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
"Random Choice",
|
||||
"Circuit Breaker",
|
||||
"Ollama Client",
|
||||
"Ollama Model Selector",
|
||||
"Ollama Load Model",
|
||||
"Ollama Unload Model",
|
||||
"Ollama Chat Completion",
|
||||
"LLM Model Selector",
|
||||
"LLM Load Model",
|
||||
"LLM Unload Model",
|
||||
"Chat Completion",
|
||||
"Ollama Option — Temperature",
|
||||
"Ollama Option — Seed",
|
||||
"Ollama Option — Max Tokens",
|
||||
|
||||
+28
-14
@@ -7,11 +7,11 @@ A collection of workflow efficiency and quality-of-life nodes built out of neces
|
||||
| **Format String** | Formats a string from a Python f-string or Jinja2 template. Detects variables in the template and automatically adds/removes input sockets. |
|
||||
| **Random Choice** | Accepts any number of typed inputs and outputs one at random, with a configurable seed for reproducibility. |
|
||||
| **Circuit Breaker** | Halts the current ComfyUI queue run gracefully without crashing the server. Wire the `status` toggle to a boolean condition to skip the rest of the queue when a condition isn't met. |
|
||||
| **Ollama Client** | Configures a connection to an Ollama server (default: `http://localhost:11434`). Threads the host URL through the graph as an `OLLAMA_CLIENT` socket. |
|
||||
| **Ollama Model Selector** | Fetches the live model list from Ollama and presents it as a dropdown. Outputs the selected model name. |
|
||||
| **Ollama Load Model** | Loads a model into Ollama's memory using `/api/generate` with `keep_alive=-1`. |
|
||||
| **Ollama Unload Model** | Evicts a model from Ollama's memory using `/api/generate` with `keep_alive=0`. |
|
||||
| **Ollama Chat Completion** | Sends a prompt (and optional conversation history) to Ollama `/api/chat`. Response and history are shown inline in the node body and available as output sockets. |
|
||||
| **Ollama Client** | Configures a connection to an Ollama server (default: `http://localhost:11434`). Threads the connection through the graph as an `LLM_CLIENT` socket — the same generic socket any future backend's client node will emit. |
|
||||
| **LLM Model Selector** | Fetches the live model list from the connected server and presents it as a dropdown. Outputs the selected model name. |
|
||||
| **LLM Load Model** | Loads a model into memory using `/api/generate` with `keep_alive=-1`. |
|
||||
| **LLM Unload Model** | Evicts a model from memory using `/api/generate` with `keep_alive=0`. |
|
||||
| **Chat Completion** | Sends a prompt (and optional conversation history) to the connected server. Response and history are shown inline in the node body and available as output sockets. |
|
||||
| **Ollama Option — \*** | Seven composable option nodes (Temperature, Seed, Max Tokens, Top P, Top K, Repeat Penalty, Extra Body) that merge into an `OLLAMA_OPTIONS` dict wired into Chat Completion. |
|
||||
| **Ollama Debug History** | Serialises an `OLLAMA_HISTORY` list to a pretty-printed JSON string for inspection. |
|
||||
| **Ollama History Length** | Returns the number of messages in an `OLLAMA_HISTORY` list as an integer. |
|
||||
@@ -85,33 +85,33 @@ Typical use: skip an expensive upscale step when a quality-check node says the d
|
||||
|
||||
## Ollama
|
||||
|
||||
14 nodes for integrating a local Ollama LLM into your ComfyUI workflow. The host URL is configured once in **Ollama Client** and threaded through the graph — all downstream nodes receive it via the `OLLAMA_CLIENT` socket.
|
||||
Nodes for integrating a local Ollama LLM into your ComfyUI workflow. The host is configured once in **Ollama Client** and threaded through the graph as an `LLM_CLIENT` socket — a generic connection type any future backend's client node can also emit, so the chat/model-management nodes below aren't Ollama-specific.
|
||||
|
||||
### Ollama Client node
|
||||
|
||||
Configure the server address once; all downstream Ollama nodes inherit it automatically.
|
||||
Configure the server address once; all downstream nodes inherit it automatically.
|
||||
|
||||

|
||||
|
||||
### Model lifecycle (load and unload)
|
||||
|
||||
On memory-constrained machines and single-GPU setups, explicitly loading and unloading the model before and after inference is critical. **Ollama Load Model** pins the model into VRAM (`keep_alive=-1`); **Ollama Unload Model** evicts it immediately (`keep_alive=0`), freeing memory for image generation or other models.
|
||||
On memory-constrained machines and single-GPU setups, explicitly loading and unloading the model before and after inference is critical. **LLM Load Model** pins the model into VRAM (`keep_alive=-1`); **LLM Unload Model** evicts it immediately (`keep_alive=0`), freeing memory for image generation or other models.
|
||||
|
||||

|
||||
|
||||
The correct chain is **Load → Chat → Unload**, enforced through data dependencies:
|
||||
|
||||
1. Wire `OllamaLoadModel.model_name` → `OllamaChatCompletion.model`. This creates the data dependency that guarantees Load runs before Chat and passes the model name into the Chat node's plain-string `model` input.
|
||||
2. Wire `OllamaChatCompletion.model_name` → `OllamaUnloadModel.model`. This guarantees Unload runs after Chat completes.
|
||||
3. Optionally wire `OllamaChatCompletion.response` → `OllamaUnloadModel.passthrough` — Unload returns the response unchanged so the rest of your workflow can still consume it.
|
||||
1. Wire `LLMLoadModel.model_name` → `ChatCompletion.model`. This creates the data dependency that guarantees Load runs before Chat and passes the model name into the Chat node's plain-string `model` input.
|
||||
2. Wire `ChatCompletion.model_name` → `LLMUnloadModel.model`. This guarantees Unload runs after Chat completes.
|
||||
3. Optionally wire `ChatCompletion.response` → `LLMUnloadModel.passthrough` — Unload returns the response unchanged so the rest of your workflow can still consume it.
|
||||
|
||||
### Minimal chat workflow
|
||||
|
||||
1. **Ollama Client** → set host (default `http://localhost:11434`)
|
||||
2. **Ollama Model Selector** → pick a model from the live dropdown (or type/wire a model name directly into Chat Completion's `model` input)
|
||||
3. **Ollama Chat Completion** → wire client + model + prompt; the response appears inline in the node body and is also available as an output socket
|
||||
2. **LLM Model Selector** → pick a model from the live dropdown (or type/wire a model name directly into Chat Completion's `model` input)
|
||||
3. **Chat Completion** → wire client + model + prompt; the response appears inline in the node body and is also available as an output socket
|
||||
|
||||

|
||||

|
||||
|
||||
Wire multiple nodes together for a complete end-to-end workflow:
|
||||
|
||||
@@ -136,3 +136,17 @@ Chain any combination of **Ollama Option —** nodes before Chat Completion to o
|
||||
### Multi-turn conversations
|
||||
|
||||
`OLLAMA_HISTORY` flows out of Chat Completion as a list of `{"role", "content"}` dicts. Wire it back into the next Chat Completion for multi-turn conversations, or inspect it with **Ollama Debug History** / **Ollama History Length**.
|
||||
|
||||
### Upgrading an older workflow
|
||||
|
||||
If you saved a workflow before this rename, ComfyUI will report the old node types as missing when you reopen it. Reconnect using this mapping, then re-run — behavior is unchanged, only the names and the client socket type are different:
|
||||
|
||||
| Old | New |
|
||||
|-----|-----|
|
||||
| `OllamaChatCompletion` | `ChatCompletion` |
|
||||
| `OllamaModelSelector` | `LLMModelSelector` |
|
||||
| `OllamaLoadModel` | `LLMLoadModel` |
|
||||
| `OllamaUnloadModel` | `LLMUnloadModel` |
|
||||
| `OLLAMA_CLIENT` socket | `LLM_CLIENT` socket |
|
||||
|
||||
`OllamaClient` keeps its name — just delete and re-add any downstream node showing as missing, then rewire it to the same `OllamaClient` node.
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
## Status
|
||||
|
||||
> Accepted
|
||||
> Superseded by [ADR-007](ADR-007-llm-provider-adapter-pattern.md)
|
||||
|
||||
_Date:_ 2026-07-09
|
||||
_Deciders:_ darth-veitcher
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
# ADR-007: LLMProvider adapter pattern shared across Ollama and llama.cpp
|
||||
|
||||
## Status
|
||||
|
||||
> Accepted
|
||||
|
||||
_Date:_ 2026-07-11
|
||||
_Deciders:_ darth-veitcher
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
GitHub issue #15 asks comfydv to add ComfyUI nodes for llama.cpp, mirroring
|
||||
the existing Ollama integration
|
||||
(`project-management/Roadmap/epics/archive/ollama-integration.md`), and
|
||||
explicitly poses the design question: separate nodes per backend, or an
|
||||
adapter pattern that shares code? It's motivated by llama.cpp's new "router
|
||||
mode" (`llama-server --models-dir <dir>`, via
|
||||
[llama.cpp PR #18228](https://github.com/ggml-org/llama.cpp/pull/18228),
|
||||
merged 2025-12-21), which exposes `GET /models`, `POST /models/load`,
|
||||
`POST /models/unload`, and `--sleep-idle-seconds` auto-unload — giving
|
||||
llama.cpp the same manual load/unload memory-management primitives comfydv
|
||||
already relies on for Ollama.
|
||||
|
||||
`src/comfydv/ollama.py` (1056 lines, 17 node classes) has no abstraction
|
||||
layer today: two module-level free functions (`_post_json`, `_fetch_models`)
|
||||
are called directly by every node, with Ollama endpoint paths hardcoded
|
||||
inline; socket types (`OLLAMA_CLIENT`, `OLLAMA_OPTIONS`, `OLLAMA_HISTORY`)
|
||||
and `PromptServer` routes (`/dv/ollama/...`) are Ollama-named throughout.
|
||||
|
||||
**Two things needed resolving to answer issue #15's question honestly:**
|
||||
|
||||
1. **Does model lifecycle management (list/load/unload) actually converge
|
||||
between backends, or not?** At the wire-protocol level, no: Ollama uses
|
||||
`/api/tags` + a per-request `keep_alive` TTL on `/api/generate`;
|
||||
llama.cpp router mode uses `/models` + explicit `/models/load` /
|
||||
`/models/unload` + named status states
|
||||
(`unloaded`/`loading`/`loaded`/`sleeping`/`downloading`). Judged at that
|
||||
level, a shared interface looks forced. But at the *conceptual* level,
|
||||
both APIs support exactly the same four operations — list models with
|
||||
status, load a model, unload a model, and generate/chat against a loaded
|
||||
model — just with different mechanics. Ollama's `keep_alive`-based
|
||||
load/unload already *is* `load_model()`/`unload_model()`, mechanically
|
||||
implemented as a side effect of a `/api/generate` call rather than a
|
||||
dedicated endpoint. A `Protocol` boundary at the operation level, not the
|
||||
wire-format level, fits both backends without forcing anything.
|
||||
|
||||
2. **Does `pydantic-ai` make sense now that a second backend exists?**
|
||||
[ADR-006](ADR-006-structured-ollama-output-tool-calling-not-pydantic-ai.md)
|
||||
(2026-07-09) rejected `pydantic-ai` for a single backend because its
|
||||
Ollama support pulls in `httpx` + the `openai` SDK, reversing
|
||||
[ADR-004](ADR-004-aiohttp-over-httpx-for-ollama.md)'s aiohttp-only
|
||||
stance. A research pass against current `pydantic-ai` docs/source (this
|
||||
moves fast and postdates training data, so verified live rather than
|
||||
assumed) found: `httpx` is a **base dependency of `pydantic-ai-slim`
|
||||
itself**, not merely pulled in by an OpenAI-specific extra; `openai` SDK
|
||||
+ `tiktoken` are additionally required to reach any OpenAI-compatible
|
||||
backend; there is no aiohttp transport option anywhere in pydantic-ai.
|
||||
This is a **fixed, one-time dependency tax**, not one that grows per
|
||||
backend. Separately, `pydantic.create_model()`-built `BaseModel`
|
||||
subclasses (comfydv's existing pattern for validating against a
|
||||
user-supplied JSON-Schema string at workflow-execution time) work as
|
||||
pydantic-ai's `output_type` with no special-casing — the dynamic-schema
|
||||
requirement is not a blocker. And `OpenAIProvider(base_url=...)` is the
|
||||
exact generic mechanism pydantic-ai's own `OllamaProvider` is built on
|
||||
internally, so llama.cpp's `/v1/chat/completions` reaches an identical
|
||||
code path with a different `base_url` — genuinely shared implementation,
|
||||
not just a shared shape.
|
||||
|
||||
Both findings point the same direction: a real `Protocol`-based adapter,
|
||||
with `pydantic-ai` as the mechanism behind its structured-output method.
|
||||
|
||||
## Decision
|
||||
|
||||
Define a `LLMProvider` `Protocol` (new internal module, e.g.
|
||||
`src/comfydv/_llm/provider.py`) with the common surface:
|
||||
|
||||
```python
|
||||
class LLMProvider(Protocol):
|
||||
async def list_models(self) -> list[ModelInfo]: ...
|
||||
async def load_model(self, model: str) -> None: ...
|
||||
async def unload_model(self, model: str) -> None: ...
|
||||
async def chat(self, model: str, messages: ..., options: ...) -> str: ...
|
||||
async def chat_structured(self, model: str, messages: ..., schema: type[BaseModel], options: ...) -> BaseModel: ...
|
||||
```
|
||||
|
||||
`OllamaProvider` and `LlamaCppProvider` each implement it, absorbing their
|
||||
own REST mechanics internally (Ollama: `/api/tags`, `/api/generate` with
|
||||
`keep_alive`; llama.cpp: `/models`, `/models/load`, `/models/unload`) via
|
||||
`aiohttp`, unchanged from ADR-004's stance for non-chat calls. Both
|
||||
implement `chat_structured()` via the same `pydantic-ai` `Agent`/
|
||||
`output_type` call through `OpenAIProvider(base_url=...)` — one shared
|
||||
implementation, differing only in `base_url` and model name.
|
||||
|
||||
ComfyUI nodes become **generic, not per-backend**: `OllamaClient` and
|
||||
`LlamaCppClient` both output the same `LLM_CLIENT` socket type (each
|
||||
internally constructs the matching provider); a single `LLMModelSelector`,
|
||||
`LLMLoadModel`, `LLMUnloadModel`, and `ChatCompletion` node operate against
|
||||
`LLM_CLIENT` generically. Swapping providers on the canvas means rewiring
|
||||
which client node feeds the chat/management nodes, not swapping node
|
||||
classes — this is the direct answer to issue #15's question: **adapter
|
||||
pattern**, implemented as a protocol boundary at the operation level.
|
||||
|
||||
This **supersedes ADR-006**: `OllamaChatCompletion`'s `structured_output=True`
|
||||
path moves from hand-rolled tool-calling to `pydantic-ai` via the protocol.
|
||||
|
||||
This **narrows ADR-004's scope**: aiohttp remains the transport for every
|
||||
non-chat REST call inside each provider; `httpx`/`openai` enter the
|
||||
dependency tree scoped specifically to `chat_structured()`, via
|
||||
`pydantic-ai`.
|
||||
|
||||
**Documented approximation:** `ModelStatus` includes `sleeping` and
|
||||
`downloading`, states that exist in llama.cpp router mode but not in
|
||||
Ollama's API. `OllamaProvider.list_models()` normalizes into the same enum
|
||||
rather than inventing Ollama-specific states — a model that's resident and
|
||||
idle maps to `loaded` (Ollama has no distinct "kept warm but not serving"
|
||||
signal via this API), and `downloading` is simply never emitted by
|
||||
`OllamaProvider` (Ollama's pull/download flow is out of scope per the
|
||||
original Ollama epic's non-goals). This is an accepted, explicit
|
||||
approximation, not a silent gap.
|
||||
|
||||
**Confirmed:** adopting generic node/socket names (`LLM_CLIENT`,
|
||||
`ChatCompletion`, etc.) means renaming away from `OLLAMA_CLIENT`,
|
||||
`OllamaChatCompletion`, and similar — a breaking change for any saved
|
||||
workflow using the current names. The Ollama integration shipped
|
||||
2026-07-04, so the blast radius is small. Confirmed 2026-07-11: rename in
|
||||
place now rather than carry Ollama-prefixed names forward or maintain
|
||||
deprecated aliases indefinitely.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Easier:**
|
||||
- Issue #15's question gets a real answer: one generic node set works with
|
||||
any backend that implements `LLMProvider`, including future ones (a third
|
||||
local server, or a hosted OpenAI/Anthropic provider) without new node
|
||||
classes.
|
||||
- One implementation of tool-calling/structured-output logic instead of
|
||||
duplicating it per backend; `pydantic-ai` brings built-in retry/validation
|
||||
machinery, replacing ADR-006's hand-rolled retry loop.
|
||||
- The protocol boundary keeps each backend's REST quirks contained inside
|
||||
its provider — the graph never has to know Ollama uses `keep_alive` while
|
||||
llama.cpp uses explicit load/unload endpoints.
|
||||
|
||||
**Harder / constrained:**
|
||||
- New dependencies (`pydantic-ai`, `openai`, `tiktoken`, `httpx`) land in a
|
||||
project that was previously aiohttp-only.
|
||||
- This is a nontrivial migration of tested, shipped Ollama code (the
|
||||
`ollama-integration` epic is Done) — not purely additive work. Must be
|
||||
proven regression-safe before it's trusted as the foundation for
|
||||
llama.cpp.
|
||||
- `ModelStatus` is not perfectly symmetric across backends — the
|
||||
`sleeping`/`downloading` states are llama.cpp-only in practice; documented
|
||||
above, but still a leak of llama.cpp's richer vocabulary into a
|
||||
nominally-generic type.
|
||||
- The node/socket rename is a breaking change for existing saved workflows —
|
||||
confirmed acceptable given the small blast radius (see above).
|
||||
|
||||
**Debt introduced:**
|
||||
- None deliberately, contingent on the migration preserving existing
|
||||
Ollama behavior exactly (verified against `tests/test_ollama.py`).
|
||||
|
||||
## Considered Alternatives
|
||||
|
||||
### Alternative A: Shared `pydantic-ai` chat layer only; separate per-backend management nodes
|
||||
|
||||
**Why rejected:** This was the first-pass design — judged convergence at
|
||||
the REST wire-protocol level (Ollama's `/api/tags`+`keep_alive` vs.
|
||||
llama.cpp's `/models`+`/models/load`+`/models/unload` don't look alike) and
|
||||
concluded a shared interface would be forced. That framing was wrong: the
|
||||
right level to judge convergence is the *operation* (list/load/unload/chat),
|
||||
not the wire format. Both backends genuinely support the same four
|
||||
operations; only their REST mechanics differ, and those differences belong
|
||||
inside each provider implementation, not on the graph.
|
||||
|
||||
### Alternative B: Hand-roll llama.cpp's structured output too (duplicate ADR-006's approach)
|
||||
|
||||
**Why rejected:** Two independent implementations of the same
|
||||
OpenAI-compatible tool-calling mechanism is the DRY violation issue #15
|
||||
raises in the first place, with no offsetting benefit now that a second
|
||||
backend exists to justify a shared layer.
|
||||
|
||||
### Alternative C: Keep `pydantic-ai` rejected; extract a shared aiohttp-based internal helper instead
|
||||
|
||||
**Why rejected:** Avoids new dependencies entirely, but forces re-deriving
|
||||
`pydantic-ai`'s retry/validation machinery by hand for no benefit beyond
|
||||
dependency-avoidance — and doesn't change the model-management convergence
|
||||
question at all (that's orthogonal to which HTTP client the chat path
|
||||
uses). The one-time dependency tax is judged worth paying for the fuller
|
||||
abstraction, now that two backends exist to amortize it against.
|
||||
|
||||
---
|
||||
|
||||
## Links
|
||||
|
||||
- Related epics: `project-management/Roadmap/epics/llm-provider-abstraction.md`, `project-management/Roadmap/epics/llamacpp-integration.md`
|
||||
- Related ADRs: [ADR-004](ADR-004-aiohttp-over-httpx-for-ollama.md) (narrowed), [ADR-005](ADR-005-ollama-host-config-via-client-node.md) (client-node pattern generalized to `LLM_CLIENT`), [ADR-006](ADR-006-structured-ollama-output-tool-calling-not-pydantic-ai.md) (superseded)
|
||||
- External reference: [llama.cpp PR #18228](https://github.com/ggml-org/llama.cpp/pull/18228) (router mode, merged 2025-12-21), GitHub issue #15
|
||||
@@ -26,6 +26,8 @@
|
||||
- **BEACON bootstrap** — `epics/archive/beacon-bootstrap.md` — ✅ DONE — BEACON framework wired up; problem statement, constitution, roadmap, and ADR template populated; quality gates clean
|
||||
- **Logging modernisation** — `epics/logging-modernisation.md` — ✅ DONE — stdlib logging, NullHandler, silent-by-default; colorama/rich/termcolor removed; 11 tests
|
||||
- **ComfyUI UX Polish & Manager Compatibility** — `epics/ux-and-install.md` — 🔄 ACTIVE — Fix installation, core UX bugs (debounce, connection drops, alert dialogs), correctness bugs (class-level mutation, IS_CHANGED, seed=0), and metadata drift
|
||||
- **LLM Provider Abstraction** — `epics/llm-provider-abstraction.md` — 🔄 ACTIVE — Introduce a shared `LLMProvider` protocol (list/load/unload/chat/structured-output) and generic ComfyUI nodes; migrate the existing Ollama integration onto it (ADR-007, supersedes ADR-006)
|
||||
- **llama.cpp Model Integration** — `epics/llamacpp-integration.md` — 📋 PROPOSED — Add a `LlamaCppProvider` implementing the shared protocol via llama-server's router mode; depends on LLM Provider Abstraction landing first (GitHub issue #15)
|
||||
|
||||
For the live rollup (specs per epic, % tasks complete, last-commit age):
|
||||
|
||||
@@ -40,6 +42,7 @@ beacon epic list --detailed
|
||||
- **BEACON bootstrap** is a prerequisite for all other epics (quality gates need to pass before new work merges)
|
||||
- **Test hardening** is independent of documentation and can run in parallel
|
||||
- **Documentation** depends on the final node API (output order, input names) being stable — start after test hardening locks the contracts
|
||||
- **llama.cpp Model Integration** depends on **LLM Provider Abstraction** landing first — its `LlamaCppProvider` implements the protocol that epic defines, and reuses its generic nodes as-is
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Epic: llama.cpp Model Integration
|
||||
|
||||
## Status
|
||||
Planning — started 2026-07-11
|
||||
|
||||
## Why now
|
||||
|
||||
GitHub issue #15 requests llama.cpp support "similar to Ollama." This is now
|
||||
practical because llama.cpp's `llama-server` gained a "router mode" via
|
||||
[llama.cpp PR #18228](https://github.com/ggml-org/llama.cpp/pull/18228)
|
||||
(merged 2025-12-21): launched with `--models-dir <dir>` (or
|
||||
`--models-preset <file>.ini`) instead of `-m`, it exposes `GET /models`
|
||||
(with live status: `unloaded`/`loading`/`loaded`/`sleeping`/`downloading`),
|
||||
`POST /models/load`, `POST /models/unload`, and `--sleep-idle-seconds`
|
||||
auto-unload — giving llama.cpp the same manual load/unload
|
||||
memory-management primitives comfydv already relies on for Ollama. With the
|
||||
`llm-provider-abstraction` epic in place, adding llama.cpp is now a matter
|
||||
of implementing one more `LLMProvider`, not building a parallel set of
|
||||
ComfyUI nodes.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Depends on the `llm-provider-abstraction` epic landing first. This epic's
|
||||
`LlamaCppProvider` implements the `LLMProvider` protocol that epic defines,
|
||||
and its `LlamaCppClient` node emits the same `LLM_CLIENT` socket type the
|
||||
generic `ChatCompletion`/`LLMModelSelector`/`LLMLoadModel`/`LLMUnloadModel`
|
||||
nodes already consume — none of those node classes are touched by this
|
||||
epic.
|
||||
|
||||
## Specs
|
||||
|
||||
_Filled by `beacon specify --epic llamacpp-integration` / `/speckit-specify`
|
||||
once this epic is accepted._
|
||||
|
||||
## ADRs
|
||||
|
||||
- project-management/ADRs/ADR-007-llm-provider-adapter-pattern.md — decided during the prerequisite epic; this epic implements the second `LLMProvider` the ADR anticipated
|
||||
|
||||
## Success criteria
|
||||
|
||||
- `LlamaCppProvider` implements the `LLMProvider` protocol from the prerequisite epic:
|
||||
- `list_models()` via `GET /models`, surfacing native status (`unloaded`/`loading`/`loaded`/`sleeping`/`downloading`) directly — no normalization needed, since llama.cpp's vocabulary is the `ModelStatus` enum's superset
|
||||
- `load_model()` / `unload_model()` via `POST /models/load` / `POST /models/unload`
|
||||
- `chat_structured()` via the same shared `pydantic-ai` mechanism as `OllamaProvider`, `OpenAIProvider(base_url=<llama-server host>/v1)` — no new structured-output code, just a different `base_url`
|
||||
- `LlamaCppClient` config node (reuses the [ADR-005](../../ADRs/ADR-005-ollama-host-config-via-client-node.md) config-node pattern), outputs the same `LLM_CLIENT` socket type `OllamaClient` does
|
||||
- No new node classes for model selection, load/unload, or chat — the generic `LLMModelSelector`, `LLMLoadModel`, `LLMUnloadModel`, and `ChatCompletion` nodes from the prerequisite epic work unchanged once a `LlamaCppClient` is wired in
|
||||
- `LlamaCppClient` registered in `NODE_CLASS_MAPPINGS` / `NODE_DISPLAY_NAME_MAPPINGS`
|
||||
- Test coverage for `LlamaCppProvider` mirrors the `OllamaProvider` test conventions established in the prerequisite epic
|
||||
- No new runtime dependencies beyond what the prerequisite epic already introduced (`aiohttp` for model management, `pydantic-ai`/`openai` for chat)
|
||||
- CI smoke test passes
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No support for llama-server's non-router single-model launch mode (`-m`) — router mode only, since that's what gives load/unload parity with Ollama
|
||||
- No GPU inference optimisation or quantisation tuning — CPU-first dev harness, consistent with the Ollama epic's own non-goal
|
||||
- No auth/TLS/remote-serving hardening — localhost/configurable host via client node only, consistent with the Ollama epic
|
||||
- No ComfyUI Manager registry listing in this epic
|
||||
- No changes to the generic nodes or `LLMProvider` protocol themselves — if llama.cpp's router mode needs something the protocol doesn't support, that's a protocol change scoped back into the prerequisite epic's follow-up, not silently special-cased here
|
||||
|
||||
## Notes
|
||||
|
||||
Router mode is a deployment prerequisite, not something comfydv configures:
|
||||
the user must launch `llama-server` with `--models-dir`/`--models-preset`
|
||||
themselves. Document this clearly in the eventual spec/node tooltips.
|
||||
|
||||
Reference: [llama.cpp PR #18228](https://github.com/ggml-org/llama.cpp/pull/18228), GitHub issue #15.
|
||||
@@ -0,0 +1,119 @@
|
||||
# Epic: LLM Provider Abstraction
|
||||
|
||||
## Status
|
||||
Active — started 2026-07-11
|
||||
|
||||
## Why now
|
||||
|
||||
GitHub issue #15 asks for llama.cpp support "similar to Ollama," and
|
||||
explicitly raises the question of separate nodes vs. an adapter pattern.
|
||||
[ADR-007](../../ADRs/ADR-007-llm-provider-adapter-pattern.md) answers that
|
||||
with a real adapter: a `LLMProvider` protocol (`list_models`/`load_model`/
|
||||
`unload_model`/`chat`/`chat_structured`) that any backend implements, backing
|
||||
a set of generic ComfyUI nodes (`ChatCompletion`, `LLMModelSelector`,
|
||||
`LLMLoadModel`, `LLMUnloadModel`) that work with whichever provider is wired
|
||||
in. For that to be real — not just aspirational — the existing Ollama
|
||||
integration has to migrate onto the protocol first, including moving its
|
||||
structured-output mechanism from ADR-006's hand-rolled tool-calling onto
|
||||
`pydantic-ai`. This epic is that migration; it's a prerequisite for
|
||||
`llamacpp-integration`, not additive scope on top of it — building
|
||||
llama.cpp against generic nodes that don't exist yet isn't possible.
|
||||
|
||||
## Dependencies
|
||||
|
||||
_None to start — this epic can begin immediately._ The
|
||||
`llamacpp-integration` epic depends on this one landing first: its
|
||||
`LlamaCppProvider` implements the protocol this epic defines, and its
|
||||
`LlamaCppClient` node emits the same `LLM_CLIENT` socket type this epic
|
||||
introduces.
|
||||
|
||||
## Specs
|
||||
|
||||
_Filled by `beacon specify --epic llm-provider-abstraction` /
|
||||
`/speckit-specify` once this epic is accepted._
|
||||
|
||||
- specs/007-llm-provider-abstraction/
|
||||
## ADRs
|
||||
|
||||
- project-management/ADRs/ADR-007-llm-provider-adapter-pattern.md — defines the `LLMProvider` protocol as the adapter boundary; supersedes ADR-006; narrows ADR-004's scope to non-chat REST calls per-provider
|
||||
|
||||
## Success criteria
|
||||
|
||||
- `LLMProvider` protocol defined (new internal module, e.g. `src/comfydv/_llm/provider.py`): `list_models()`, `load_model(name)`, `unload_model(name)`, `chat(...)`, `chat_structured(..., schema)`
|
||||
- `ModelStatus` enum defined (`unloaded`/`loading`/`loaded`/`sleeping`/`downloading`) per ADR-007's documented approximation
|
||||
- `OllamaProvider` implements the protocol, wrapping all existing Ollama REST logic (`/api/tags`, `/api/generate` with `keep_alive`) over `aiohttp` — behavior-preserving port of the current `_post_json`/`_fetch_models` logic, not a rewrite of the underlying calls
|
||||
- `chat_structured()` implemented via `pydantic-ai`'s `Agent`/`output_type`, called through `OpenAIProvider(base_url=<host>/v1)`, using the same dynamic `pydantic.create_model()`-from-JSON-Schema pattern as ADR-006 — same dynamic-socket UX, same retry/validation contract (bounded `max_retries`, required-string-non-empty check, clear `RuntimeError` on exhaustion)
|
||||
- `pydantic-ai` and `openai` added to `pyproject.toml`, curated into `requirements.txt` per [ADR-003](../../ADRs/ADR-003-requirements-txt-authoring-policy.md)
|
||||
- Generic ComfyUI nodes replace the current Ollama-specific ones: `OllamaClient` now outputs `LLM_CLIENT` (constructing an `OllamaProvider` internally); `LLMModelSelector`, `LLMLoadModel`, `LLMUnloadModel`, `ChatCompletion` operate against `LLM_CLIENT` generically
|
||||
- The non-structured-output chat path (native `/api/chat`) is behavior-unchanged
|
||||
- All existing `tests/test_ollama.py` coverage passes against the migrated implementation (adjusted for renamed node/socket types, unchanged in behavior otherwise)
|
||||
- Model-management calls remain entirely on `aiohttp`, inside `OllamaProvider` — untouched transport-wise
|
||||
- CI smoke test passes
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No `LlamaCppProvider` or llama.cpp nodes in this epic — that's `llamacpp-integration`
|
||||
- No behavior change to non-structured-output chat
|
||||
- No tracing/observability integration (e.g. Logfire), even though `pydantic-ai` supports it
|
||||
- No multi-turn agentic tool use beyond the existing single structured-output call
|
||||
- No backward-compat aliases for the old `Ollama`-prefixed node/socket names — confirmed 2026-07-11 to rename in place (see ADR-007)
|
||||
|
||||
## Notes
|
||||
|
||||
This is the riskiest part of the whole llama.cpp proposal: it changes the
|
||||
implementation of tested, shipped code from a Done epic
|
||||
(`archive/ollama-integration.md`), not just adding new code, and it's a
|
||||
breaking rename (`OLLAMA_CLIENT`→`LLM_CLIENT`,
|
||||
`OllamaChatCompletion`→`ChatCompletion`, etc.) for anyone with saved
|
||||
workflows using the current node/socket names. Confirmed 2026-07-11 (per
|
||||
ADR-007): rename in place now — the Ollama integration only shipped
|
||||
2026-07-04, so the blast radius is small — rather than carrying
|
||||
`Ollama`-prefixed generic nodes forward or maintaining deprecated aliases
|
||||
indefinitely.
|
||||
|
||||
Recommend an adversarial pass (`/beacon:review` or `/beacon:engineering`)
|
||||
before merging, specifically checking that the retry/validation contract
|
||||
from ADR-006's `## Decision` section is preserved exactly by the
|
||||
`pydantic-ai` reimplementation, and that `OllamaProvider`'s REST calls are a
|
||||
faithful port of the current `_post_json`/`_fetch_models` logic.
|
||||
|
||||
**2026-07-11 — mid-build correction, tracked in [issue #16](https://github.com/darth-veitcher/comfydv/issues/16):**
|
||||
the Foundational layer (`LLMProvider` protocol + `OllamaProvider` skeleton)
|
||||
shipped safely, but the planned per-user-story incremental cutover doesn't
|
||||
hold — `OllamaClient` is a single shared producer for every downstream
|
||||
Ollama node, so the node-layer rename/cutover (`tasks.md`'s US1 + US3) must
|
||||
land as one atomic change, not four independent ones. Confirmed by
|
||||
independent product + engineering review. Re-scoped as its own dedicated
|
||||
follow-up BUILD session — see `specs/007-llm-provider-abstraction/tasks.md`'s
|
||||
correction note for full detail. Open question for the next session: does
|
||||
this take priority over `ux-and-install` (active, 1/4 specs shipped), since
|
||||
llama.cpp (issue #15) has no deadline.
|
||||
|
||||
**2026-07-11 — properly specced:** the deferred cutover is now fully
|
||||
inventoried and planned in
|
||||
`specs/007-llm-provider-abstraction/atomic-cutover-plan.md` — a full
|
||||
line-by-line read of the ~125 affected references (not an estimate), the
|
||||
design decisions it surfaced (cache-singleton duplication, `client ==
|
||||
"<string>"` equality breaking, bare-string-client backward compat removal,
|
||||
and a test-layer split so the 35 `_post_json` monkeypatches land at the
|
||||
right architectural seam), and a 12-step sequenced task list (T-CUT-01 …
|
||||
T-CUT-12). This is now the authoritative implementation plan for the
|
||||
cutover — the next BUILD session executes it directly rather than
|
||||
re-deriving the approach.
|
||||
|
||||
`pydantic-ai`'s `StructuredDict` (raw-JSON-Schema output, no Python class)
|
||||
was considered as a lighter-weight alternative to `create_model()` during
|
||||
research and rejected: it performs no pydantic validation at all, which
|
||||
would silently drop the "reject blank required strings" safeguard ADR-006
|
||||
introduced. Stick with `create_model()`-built `BaseModel` subclasses.
|
||||
|
||||
**2026-07-11 — cutover executed, PR open:** T-CUT-01 through T-CUT-12
|
||||
complete (`specs/007-llm-provider-abstraction/tasks.md` — every task `[x]`
|
||||
or explicitly `[-]` superseded/deferred with a reason). `beacon epic
|
||||
refresh` reports 1/1 owned specs complete. An independent `beacon-reviewer`
|
||||
pass caught one real regression (`options` silently dropped in
|
||||
structured-output mode) before merge — fixed and re-verified clear. README
|
||||
and docs/index.md updated for the rename, including the FR-009 migration
|
||||
table. **PR: [#17](https://github.com/darth-veitcher/comfydv/pull/17)** —
|
||||
not yet merged; `beacon epic finish` waits for that, per
|
||||
`beacon epic refresh`'s own guidance.
|
||||
@@ -11,6 +11,7 @@ dependencies = [
|
||||
"aiohttp>=3.9.0",
|
||||
"jinja2>=3.1.6",
|
||||
"pydantic>=2.0",
|
||||
"pydantic-ai-slim[openai]>=2.9.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
# See ADR-003: never auto-generate this file with `uv export`.
|
||||
jinja2>=3.1.6
|
||||
pydantic>=2.0
|
||||
pydantic-ai-slim[openai]>=2.9.0
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
epic = "llm-provider-abstraction"
|
||||
@@ -0,0 +1,178 @@
|
||||
# Atomic Cutover Plan: Ollama Node Rename → Generic LLM Nodes
|
||||
|
||||
Companion to `tasks.md`'s "⚠️ Correction (2026-07-11)" section and
|
||||
[issue #16](https://github.com/darth-veitcher/comfydv/issues/16). This is
|
||||
the properly-specced version of that deferred work — based on a full,
|
||||
line-by-line inventory of every one of the ~125 affected references in
|
||||
`src/comfydv/ollama.py` and `tests/test_ollama.py` (1820 lines, read in
|
||||
full), not an estimate.
|
||||
|
||||
The inventory surfaced that this isn't one mechanical find-and-replace —
|
||||
several genuine design decisions were implicit in "rename it" and needed
|
||||
resolving before any code changes. Those decisions are below, followed by
|
||||
the sequenced task list that implements them.
|
||||
|
||||
## Resolved decisions
|
||||
|
||||
### D1 — Single source of truth for HTTP/cache infra
|
||||
|
||||
`comfydv._llm.ollama_provider` owns `_post_json`, `_run_async`,
|
||||
`_fetch_models`, `_TTLLRUCache`, `_cache_key`, `_MODEL_LIST_CACHE` (already
|
||||
ported there — see `ollama_provider.py`). `ollama.py` stops defining its
|
||||
own copies of these. The one remaining non-node use case —
|
||||
`_load_default_models()` (combo-widget population at import time, before
|
||||
any `OllamaClient` node exists) and the `/dv/ollama/models` refresh route —
|
||||
imports `_fetch_models`/`_run_async` from `comfydv._llm.ollama_provider`
|
||||
instead of duplicating them. `_CHAT_RESPONSE_CACHE` and `_post_json`
|
||||
disappear from `ollama.py` entirely — nothing there needs them once
|
||||
load/unload/chat delegate to `client.*`.
|
||||
|
||||
**Why not keep two copies:** they were already flagged as byte-identical by
|
||||
the inventory (§2.13) — the only reason `ollama.py` still has them is that
|
||||
nothing has repointed the imports yet. Keeping a second copy "just in case"
|
||||
is exactly the kind of duplication ADR-007 exists to eliminate.
|
||||
|
||||
### D2 — `client == "<host string>"` equality is removed, not preserved
|
||||
|
||||
`OllamaProvider` is a plain object, not a `str` subclass — this is a
|
||||
deliberate consequence of the adapter boundary (ADR-007), not an oversight
|
||||
to work around. Two tests assert string equality on `client`
|
||||
(`test_client_outputs_ollama_client_type:208-209`,
|
||||
`test_client_carries_headers:1233-1234`) — both get rewritten to assert
|
||||
`client.host == "..."` and `isinstance(client, OllamaProvider)`.
|
||||
`OllamaClientType` (the `str`-subclass, `ollama.py:97-109`) is left in
|
||||
place but becomes unused by `OllamaClient.create_client()` — not deleted in
|
||||
this cutover (no test depends on deleting it, and removing a class nothing
|
||||
references is a separate, lower-risk cleanup, not part of this bullet's
|
||||
scope).
|
||||
|
||||
### D3 — Bare-string `client` backward compatibility is removed
|
||||
|
||||
`test_plain_string_client_has_no_headers` (`test_ollama.py:1328-1344`)
|
||||
documents and tests that wiring a plain `STRING` node directly into
|
||||
`client` (skipping `OllamaClient` entirely) silently works, because
|
||||
`f"{client}/..."` succeeds on any string. This was never a documented,
|
||||
intended feature — it's a side effect of `OllamaClientType` being a `str`
|
||||
subclass, not mentioned in ADR-005 or the original Ollama epic's spec. Once
|
||||
node methods call `client.chat(...)`/`client.load_model(...)`, a bare
|
||||
string raises `AttributeError`. **This test is deleted**, not rewritten —
|
||||
its premise (bare-string clients are supported) is being intentionally
|
||||
removed, and asserting the new failure mode would just be testing that
|
||||
Python raises `AttributeError` on missing methods, which isn't
|
||||
comfydv-specific behavior worth a test.
|
||||
|
||||
The same bare-string pattern appears incidentally in ~8 other tests
|
||||
(`ollama_host` fixture returns a plain string, used as `client=ollama_host`
|
||||
in several integration tests — `test_ollama.py:220, 386, 407, 588, ...`).
|
||||
These need `client=OllamaClient().create_client(ollama_host)[0]` instead of
|
||||
`client=ollama_host` — a required edit, not optional, since they'll raise
|
||||
`AttributeError` otherwise. See task T-CUT-08 below.
|
||||
|
||||
### D4 — Test layer split (resolves all 35 relocated `_post_json` monkeypatches)
|
||||
|
||||
This is the biggest structural decision. Today, `test_ollama.py` tests
|
||||
ComfyUI node behavior by mocking `aiohttp` at the `ollama_mod._post_json`
|
||||
seam and asserting on the exact Ollama wire payload (`keep_alive`,
|
||||
`/api/generate`, tool-calling JSON shape, retry counts) *through* the node.
|
||||
That seam moves — nodes no longer call `_post_json` directly, they call
|
||||
`client.chat(...)` etc. Two options: (a) keep patching at whatever the new
|
||||
seam is, 1:1 per test, or (b) recognize this is an architectural boundary
|
||||
and split coverage accordingly. Going with **(b)**:
|
||||
|
||||
- **`tests/test_ollama.py`** — ComfyUI node **contract + delegation** only.
|
||||
A new `_FakeProvider` test double (implements `list_models`/
|
||||
`load_model`/`unload_model`/`chat`/`chat_structured`, records calls made
|
||||
to it) stands in for `client`. Tests assert: right method called, right
|
||||
arguments passed, return value flows through to the node's output tuple
|
||||
correctly. **No `aiohttp`/`_post_json` mocking at this layer anymore.**
|
||||
This directly matches the protocol contract's own rule ("generic nodes
|
||||
MUST NOT branch on which concrete provider type they received") — if the
|
||||
node tests don't need to know Ollama's wire format, they shouldn't mock
|
||||
it either.
|
||||
- **`tests/test_ollama_provider.py`** (new file) — `OllamaProvider`'s
|
||||
actual Ollama-wire-protocol behavior: `/api/generate`+`keep_alive` int
|
||||
shape, `/api/tags` parsing into `ModelInfo`, header/timeout forwarding,
|
||||
response caching, cache-key composition. This is where the *substance* of
|
||||
today's 35 `_post_json` monkeypatches lands — not 1:1, since several
|
||||
collapse or move (see D5).
|
||||
- **`tests/test_llm_chat_structured.py`** (exists, unchanged) — already
|
||||
covers the shared retry/validation/error-contract mechanism.
|
||||
|
||||
### D5 — Structured-output retry tests are not ported 1:1
|
||||
|
||||
`TestStructuredOutput` has 15 tests monkeypatching `_post_json` to assert
|
||||
exact retry-count behavior (`test_retries_on_invalid_json_then_succeeds`,
|
||||
`test_exhausts_retries_raises_runtime_error`, `test_max_retries_clamped_*`,
|
||||
etc.). Once `OllamaProvider.chat_structured()` delegates to the already-
|
||||
tested shared `chat_structured()` helper (`src/comfydv/_llm/chat.py`,
|
||||
covered by `tests/test_llm_chat_structured.py`'s 6 tests), re-asserting
|
||||
retry counts at the Ollama-node layer duplicates that coverage without
|
||||
adding confidence. Replaced with:
|
||||
- A handful of `test_ollama.py` delegation tests: `ChatCompletion` with
|
||||
`structured_output=True` calls `client.chat_structured(model, messages,
|
||||
schema, ...)` with the right schema/model/messages.
|
||||
- One `test_ollama_provider.py` test: `OllamaProvider.chat_structured()`
|
||||
builds `base_url=f"{self.host}/v1"` and forwards to the shared helper
|
||||
with the right arguments.
|
||||
- `test_structured_output_true_sends_tool_call_payload` (asserts the exact
|
||||
`tools`/`tool_choice` JSON shape) is **deleted** — that's `pydantic-ai`'s
|
||||
internal tool-calling mechanism now, not comfydv's; asserting on a
|
||||
third-party library's internals isn't a test worth keeping.
|
||||
- Pure schema-parsing tests that don't touch HTTP at all (`_parse_output_schema`
|
||||
fail-fast checks, `_coerce_structured_value`, dynamic-socket
|
||||
`RETURN_TYPES` mutation) are unaffected — they test code that stays in
|
||||
`ollama.py` unchanged, only need the class-name rename.
|
||||
|
||||
### D6 — `_client_headers()` is deleted
|
||||
|
||||
Dead code once `OllamaLoadModel`/`OllamaUnloadModel`/`OllamaChatCompletion`
|
||||
delegate to `client.*` (headers become internal to `OllamaProvider`,
|
||||
captured once at construction). No test calls it directly.
|
||||
|
||||
### D7 — Contract doc gets a small fix
|
||||
|
||||
`contracts/llm_provider_protocol.md`'s illustrative code sample is missing
|
||||
`timeout_secs` on `chat()`/`chat_structured()` — the actual `provider.py`
|
||||
(built after the doc) has it. Fix the doc to match the real protocol; docs
|
||||
follow code here, not the reverse.
|
||||
|
||||
### D8 — `conftest.py`'s `_clear_ollama_caches` fixture repoints
|
||||
|
||||
Per D1, there's now one cache source (`comfydv._llm.ollama_provider`).
|
||||
Fixture imports `_CHAT_RESPONSE_CACHE`/`_MODEL_LIST_CACHE` from there
|
||||
instead of `comfydv.ollama`, and `ChatCompletion` instead of
|
||||
`OllamaChatCompletion`. This is the single highest-priority fixture change
|
||||
— every `TestResponseCache` test (12) and every `structured_output`-
|
||||
toggling test depends on it for isolation.
|
||||
|
||||
## Sequenced task list
|
||||
|
||||
Replaces `tasks.md`'s Phase 3 (US1) + Phase 5 (US3) + the deferred T014.
|
||||
One coordinated PR/session, ordered so the codebase stays important at each
|
||||
step even though it can't be split across separate merges (per the
|
||||
2026-07-11 correction — this is genuinely atomic).
|
||||
|
||||
1. **T-CUT-01** — `ollama.py`: add `from comfydv._llm.ollama_provider import OllamaProvider, _fetch_models, _run_async` (drop the local `_post_json`, `_TTLLRUCache`, `_cache_key`, `_MODEL_LIST_CACHE`, `_CHAT_RESPONSE_CACHE`, `_run_async`, `_fetch_models`, `_post_json` definitions — lines 42-194 collapse to the import). Repoint `_load_default_models()` and the `/dv/ollama/models` route to the imported `_fetch_models`. (D1)
|
||||
2. **T-CUT-02** — `ollama_provider.py`: implement `OllamaProvider.list_models()` (port `_fetch_models`'s `/api/tags` logic, map to `ModelInfo`/`ModelStatus.UNLOADED`/`LOADED` — Ollama never emits `SLEEPING`/`DOWNLOADING`, per ADR-007's documented approximation), `load_model()` (port `/api/generate` + `keep_alive: -1`), `unload_model()` (port `/api/generate` + `keep_alive: 0`), `chat()` (port native `/api/chat` non-structured path), `chat_structured()` (build `base_url=f"{self.host}/v1"`, delegate to `comfydv._llm.chat.chat_structured()`).
|
||||
3. **T-CUT-03** — `tests/test_ollama_provider.py` (new): tests for T-CUT-02's method bodies, mocking at `ollama_provider_mod._post_json`/`aiohttp.ClientSession` — ports the *substance* of the 35 relocated monkeypatches per D4/D5 (not 1:1 — collapses redundant retry-count tests per D5).
|
||||
4. **T-CUT-04** — `ollama.py`: `OllamaClient.RETURN_TYPES = ("LLM_CLIENT",)`, `create_client()` returns `OllamaProvider(host, headers)`. (D2)
|
||||
5. **T-CUT-05** — `ollama.py`: rename `OllamaModelSelector`→`LLMModelSelector`, `OllamaLoadModel`→`LLMLoadModel`, `OllamaUnloadModel`→`LLMUnloadModel`, `OllamaChatCompletion`→`ChatCompletion`; every `"OLLAMA_CLIENT"` input socket → `"LLM_CLIENT"`; rewrite the 3 method bodies (`load_model`, `unload_model`, `chat`) to delegate to `client.*` instead of `_post_json`/f-string URLs; delete `_client_headers` (D6); update the 3 `OllamaChatCompletion.*` references in the `/dv/ollama/update_structured_outputs` route body.
|
||||
6. **T-CUT-06** — `src/comfydv/__init__.py`: update imports and `NODE_CLASS_MAPPINGS`/`NODE_DISPLAY_NAME_MAPPINGS` for the 4 renamed classes.
|
||||
7. **T-CUT-07** — `tests/conftest.py`: repoint `_clear_ollama_caches` (D8) and `first_generative_model`'s `_fetch_models` import (D1).
|
||||
8. **T-CUT-08** — `tests/test_ollama.py`: update the import block (4 class renames); add `_FakeProvider` test double; convert every `_post_json`-monkeypatched test to use `_FakeProvider` as `client` instead (D4); replace bare-string `client=ollama_host`/`client="http://..."` usages with a constructed provider (D3); rewrite the 2 `client == "<string>"` assertions (D2); delete `test_plain_string_client_has_no_headers` (D3) and `test_structured_output_true_sends_tool_call_payload` (D5); collapse the 15 `TestStructuredOutput` retry-count tests per D5; update `TestNodeContracts`'s `NODE_CLASSES` list (4 renames).
|
||||
9. **T-CUT-09** — `contracts/llm_provider_protocol.md`: add missing `timeout_secs` params (D7).
|
||||
10. **T-CUT-10** — Full suite green (`uv run pytest -m "not integration and not system"`), `ruff check --fix && ruff format`, `ty check`, `beacon doctor --strict`.
|
||||
11. **T-CUT-11** — `tasks.md`: mark T007-T010/T015-T018/T014 done, referencing this plan; migration mapping (old→new names, FR-009) as a module-level constant/docstring in `ollama.py`.
|
||||
12. **T-CUT-12** — Manual smoke test against a live local Ollama server per `quickstart.md`.
|
||||
|
||||
## What stays exactly as originally scoped
|
||||
|
||||
`OllamaHeader*`, `OllamaOption*`, `OllamaDebugHistory`, `OllamaHistoryLength`
|
||||
classes and the `OLLAMA_HEADERS`/`OLLAMA_OPTIONS`/`OLLAMA_HISTORY` socket
|
||||
types are **out of scope** — confirmed zero test dependencies force a
|
||||
change, and ADR-007 never proposed touching them (only the
|
||||
model-management/chat surface generalizes). `_parse_output_schema`,
|
||||
`_comfy_types_for_schema`, `_build_structured_model`,
|
||||
`_coerce_structured_value` stay in `ollama.py` unchanged — pure/local
|
||||
schema logic with no network dependency, still needed by the live-preview
|
||||
route.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Specification Quality Checklist: LLM Provider Abstraction
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-07-11
|
||||
**Feature**: [spec.md](../spec.md)
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details (languages, frameworks, APIs)
|
||||
- [x] Focused on user value and business needs
|
||||
- [x] Written for non-technical stakeholders
|
||||
- [x] All mandatory sections completed
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||
- [x] Requirements are testable and unambiguous
|
||||
- [x] Success criteria are measurable
|
||||
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||
- [x] All acceptance scenarios are defined
|
||||
- [x] Edge cases are identified
|
||||
- [x] Scope is clearly bounded
|
||||
- [x] Dependencies and assumptions identified
|
||||
|
||||
## Feature Readiness
|
||||
|
||||
- [x] All functional requirements have clear acceptance criteria
|
||||
- [x] User scenarios cover primary flows
|
||||
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||
- [x] No implementation details leak into specification
|
||||
|
||||
## Notes
|
||||
|
||||
All items pass on first pass — no [NEEDS CLARIFICATION] markers were needed;
|
||||
scope boundaries (llama.cpp out of scope, no automatic workflow migration,
|
||||
no new tracing capability) came directly from the parent epic's Non-goals
|
||||
(`project-management/Roadmap/epics/llm-provider-abstraction.md`) and
|
||||
ADR-007, so no ambiguity required flagging back to the user.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Contract: `LLMProvider` protocol
|
||||
|
||||
This is the interface the follow-on `llamacpp-integration` epic implements
|
||||
against (`LlamaCppProvider`) — it's the actual deliverable that makes ADR-007's
|
||||
adapter pattern real, not internal implementation detail. Treat changes to
|
||||
this contract as requiring epic-level sign-off (per ADR-007's own scope),
|
||||
not a routine refactor.
|
||||
|
||||
```python
|
||||
class ModelStatus(str, Enum):
|
||||
UNLOADED = "unloaded"
|
||||
LOADING = "loading"
|
||||
LOADED = "loaded"
|
||||
SLEEPING = "sleeping" # not all providers emit this
|
||||
DOWNLOADING = "downloading" # not all providers emit this
|
||||
|
||||
class ModelInfo(BaseModel):
|
||||
name: str
|
||||
status: ModelStatus
|
||||
size: int | None = None
|
||||
|
||||
class Message(BaseModel):
|
||||
role: Literal["system", "user", "assistant"]
|
||||
content: str
|
||||
|
||||
class LLMProvider(Protocol):
|
||||
async def list_models(self) -> list[ModelInfo]: ...
|
||||
async def load_model(self, model: str) -> None: ...
|
||||
async def unload_model(self, model: str) -> None: ...
|
||||
async def chat(
|
||||
self, model: str, messages: list[Message], options: dict | None = None,
|
||||
timeout_secs: float = 300.0,
|
||||
) -> str: ...
|
||||
async def chat_structured(
|
||||
self, model: str, messages: list[Message], schema: type[BaseModel],
|
||||
options: dict | None = None, timeout_secs: float = 300.0, max_retries: int = 2,
|
||||
) -> BaseModel: ...
|
||||
```
|
||||
|
||||
## Behavioral requirements (every implementation MUST satisfy)
|
||||
|
||||
- `load_model`/`unload_model` are **idempotent** — calling either on a model
|
||||
already in that state is not an error.
|
||||
- `chat_structured` **MUST NOT** return a `BaseModel` instance with a blank
|
||||
required `str` field — validate and retry (bounded, provider-internal)
|
||||
rather than pass through invalid data. On exhausted retries, raise
|
||||
`RuntimeError` naming the model, attempt count, and a truncated snippet of
|
||||
the last invalid response (FR-004 in `../spec.md`).
|
||||
- `list_models` MUST return every model the server currently knows about,
|
||||
including ones not currently loaded — this is a status listing, not a
|
||||
"loaded models only" filter.
|
||||
- A provider that cannot represent a given `ModelStatus` value (e.g. Ollama
|
||||
has no `sleeping`/`downloading` concept) MUST normalize to the closest
|
||||
applicable status rather than omit the model or invent a new status value
|
||||
outside this enum.
|
||||
- Connection state (host, auth headers, or equivalent) is captured once at
|
||||
provider-construction time; no method takes connection details as a
|
||||
parameter.
|
||||
|
||||
## Non-requirements (explicitly not part of this contract)
|
||||
|
||||
- No requirement that every provider support every `ModelStatus` value —
|
||||
see `data-model.md`'s per-provider emission notes.
|
||||
- No streaming contract — `chat`/`chat_structured` return a complete result,
|
||||
not a stream. (Not requested by the parent spec; a future contract change
|
||||
if ever needed.)
|
||||
- No multi-turn agent/tool-use contract beyond a single structured-output
|
||||
call — out of scope per the parent epic's Non-goals.
|
||||
|
||||
## ComfyUI-facing contract: `LLM_CLIENT` socket
|
||||
|
||||
An `LLMProvider`-implementing instance is the value carried by ComfyUI's
|
||||
`LLM_CLIENT` custom socket type. Any node that outputs `LLM_CLIENT` (e.g.
|
||||
`OllamaClient`, and later `LlamaCppClient`) is committing to have constructed
|
||||
a fully-configured provider instance — no partial/lazy construction that
|
||||
defers connection details to the consuming node.
|
||||
|
||||
Generic nodes (`LLMModelSelector`, `LLMLoadModel`, `LLMUnloadModel`,
|
||||
`ChatCompletion`) accept `LLM_CLIENT` as their only connection-related input
|
||||
and MUST NOT branch on which concrete provider type they received — doing so
|
||||
would defeat the point of the protocol boundary (ADR-007).
|
||||
@@ -0,0 +1,69 @@
|
||||
# Data Model: LLM Provider Abstraction
|
||||
|
||||
## `ModelStatus` (enum)
|
||||
|
||||
Residency status of a model on a provider's server.
|
||||
|
||||
| Value | Meaning | Emitted by |
|
||||
|---|---|---|
|
||||
| `unloaded` | Known to the server, not resident in memory | all providers |
|
||||
| `loading` | Transitioning into memory | all providers |
|
||||
| `loaded` | Resident and ready to serve requests | all providers |
|
||||
| `sleeping` | Resident but idle-parked | llama.cpp only; Ollama has no distinct signal for this via its API and normalizes resident-and-idle to `loaded` (documented approximation, ADR-007) |
|
||||
| `downloading` | Server is fetching model weights | llama.cpp only; `OllamaProvider` never emits this (Ollama's pull/download flow is out of scope, per the original Ollama epic's non-goals) |
|
||||
|
||||
## `ModelInfo`
|
||||
|
||||
One entry returned by `list_models()`.
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `name` | `str` | Model identifier as the provider's server knows it |
|
||||
| `status` | `ModelStatus` | See above |
|
||||
| `size` | `int \| None` | Bytes, if the provider reports it; `None` otherwise |
|
||||
|
||||
## `LLMProvider` (Protocol)
|
||||
|
||||
The adapter boundary. Every backend (`OllamaProvider` now, `LlamaCppProvider`
|
||||
in the follow-on epic) implements this shape; ComfyUI nodes depend only on
|
||||
the protocol, never on a concrete provider class.
|
||||
|
||||
| Method | Signature | Notes |
|
||||
|---|---|---|
|
||||
| `list_models` | `async def list_models(self) -> list[ModelInfo]` | |
|
||||
| `load_model` | `async def load_model(self, model: str) -> None` | Idempotent: loading an already-loaded model is not an error |
|
||||
| `unload_model` | `async def unload_model(self, model: str) -> None` | Idempotent: unloading an already-unloaded model is not an error |
|
||||
| `chat` | `async def chat(self, model: str, messages: list[Message], options: dict) -> str` | Free-text response |
|
||||
| `chat_structured` | `async def chat_structured(self, model: str, messages: list[Message], schema: type[BaseModel], options: dict) -> BaseModel` | Validated response; raises on exhausted retries (see FR-004) |
|
||||
|
||||
A concrete provider instance is constructed once per ComfyUI client node with
|
||||
its connection's host/headers as instance state (Constitution Principle V
|
||||
justification — see `research.md`), and that instance is the value carried
|
||||
by the `LLM_CLIENT` ComfyUI socket type.
|
||||
|
||||
## `Message`
|
||||
|
||||
One turn in a chat request, matching the existing shape already sent to
|
||||
Ollama's `/api/chat`/`/v1/chat/completions` (`role` + `content`); unchanged
|
||||
by this feature, carried forward as-is.
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `role` | `Literal["system", "user", "assistant"]` | |
|
||||
| `content` | `str` | |
|
||||
|
||||
## Relationships
|
||||
|
||||
```
|
||||
ProviderConnection (ComfyUI client node)
|
||||
└─ produces → LLM_CLIENT socket value (an LLMProvider instance)
|
||||
└─ consumed by → LLMModelSelector, LLMLoadModel, LLMUnloadModel, ChatCompletion (ComfyUI nodes)
|
||||
├─ list_models() → ModelInfo[]
|
||||
├─ load_model()/unload_model() → mutates server-side residency, no return value
|
||||
└─ chat()/chat_structured() → str | BaseModel
|
||||
```
|
||||
|
||||
No new persistent storage is introduced — every entity above is
|
||||
constructed per-request or per-node-execution from the connected server's
|
||||
live state; the only caching is the existing in-memory TTL cache for model
|
||||
listing (`_TTLLRUCache`, unchanged, reused inside `OllamaProvider`).
|
||||
@@ -0,0 +1,16 @@
|
||||
Feature: US1 — Connect to a local inference server and get chat responses
|
||||
|
||||
Scenario: Client node feeds a chat node
|
||||
Given a running local inference server and a workflow with a client node wired into a chat node
|
||||
When the workflow executes
|
||||
Then the chat node returns the model's text response
|
||||
|
||||
Scenario: Unreachable server surfaces a clear error
|
||||
Given a client node configured with an unreachable server address
|
||||
When the workflow executes
|
||||
Then the chat node reports a clear connection error rather than hanging indefinitely or crashing the workflow
|
||||
|
||||
Scenario: One client node configures multiple chat nodes
|
||||
Given two chat nodes in the same workflow wired to the same client node
|
||||
When the host address is changed on the client node
|
||||
Then both chat nodes use the new address without being edited individually
|
||||
@@ -0,0 +1,16 @@
|
||||
Feature: US2 — Get structured, validated output instead of parsing raw text
|
||||
|
||||
Scenario: Valid structured response exposes typed fields
|
||||
Given a chat node with structured output enabled and a valid schema
|
||||
When the workflow executes and the model responds correctly
|
||||
Then each schema field is available as its own typed output, and no required field is blank
|
||||
|
||||
Scenario: Invalid response triggers automatic retry
|
||||
Given a model that returns invalid, incomplete, or empty-required-field output
|
||||
When the workflow executes
|
||||
Then the node automatically retries the request up to a configured limit
|
||||
|
||||
Scenario: Exhausted retries fail clearly instead of passing through bad data
|
||||
Given a model that continues to return invalid output after all retries are exhausted
|
||||
When the workflow executes
|
||||
Then the node fails with a clear, specific error rather than silently passing through invalid or partial data
|
||||
@@ -0,0 +1,16 @@
|
||||
Feature: US3 — Manage which models are resident in memory
|
||||
|
||||
Scenario: List models with current status
|
||||
Given a running local server with at least one available model
|
||||
When a workflow author uses the model-listing node
|
||||
Then they see each available model along with its current status
|
||||
|
||||
Scenario: Load a model into memory
|
||||
Given a model that is not currently loaded
|
||||
When a workflow author runs the load-model node against it
|
||||
Then the model becomes loaded and is then usable by the chat node
|
||||
|
||||
Scenario: Unload a model from memory
|
||||
Given a model that is loaded and idle
|
||||
When a workflow author runs the unload-model node against it
|
||||
Then the model is freed from memory and its reported status updates accordingly
|
||||
@@ -0,0 +1,12 @@
|
||||
Feature: US4 — Reconnect an existing workflow after upgrading
|
||||
|
||||
Scenario: Renamed nodes are reported with a documented replacement
|
||||
Given a saved workflow using the current Ollama-specific node and connection-socket names
|
||||
When it is opened after upgrading
|
||||
Then ComfyUI reports the now-missing node types
|
||||
And documentation identifies the replacement node for each one
|
||||
|
||||
Scenario: Reconnected workflow produces equivalent output
|
||||
Given a workflow that has been reconnected to the new generic nodes
|
||||
When it executes with the same inputs and model as before the upgrade
|
||||
Then it produces equivalent output
|
||||
@@ -0,0 +1,121 @@
|
||||
# Implementation Plan: LLM Provider Abstraction
|
||||
|
||||
**Branch**: `007-llm-provider-abstraction` | **Date**: 2026-07-11 | **Spec**: [spec.md](./spec.md)
|
||||
|
||||
**Input**: Feature specification from `/specs/007-llm-provider-abstraction/spec.md`
|
||||
|
||||
**Note**: This template is filled in by the `/speckit-plan` command. See `.specify/templates/plan-template.md` for the execution workflow.
|
||||
|
||||
## Summary
|
||||
|
||||
Define a shared `LLMProvider` protocol (list/load/unload/chat/structured-chat)
|
||||
and generic ComfyUI nodes so workflow authors can connect any supported local
|
||||
inference backend the same way. Migrate the existing Ollama integration onto
|
||||
it — `OllamaProvider` becomes the first (and, in this feature, only)
|
||||
implementation — including moving structured-output from ADR-006's
|
||||
hand-rolled tool-calling onto `pydantic-ai`, per ADR-007. This is a
|
||||
behavior-preserving mechanism swap for existing capability, plus the new
|
||||
protocol boundary that the follow-on `llamacpp-integration` epic builds a
|
||||
second provider against.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: Python ≥3.11 (per `pyproject.toml`)
|
||||
|
||||
**Primary Dependencies**: `aiohttp` (existing, unchanged — model-management
|
||||
REST calls), `pydantic` (existing, unchanged — validation), `pydantic-ai` +
|
||||
`openai` (new, per ADR-007 — powers `chat_structured()` only)
|
||||
|
||||
**Storage**: N/A — no persistent storage; the existing in-memory
|
||||
`_TTLLRUCache` for model listing is reused unchanged inside `OllamaProvider`
|
||||
|
||||
**Testing**: `pytest` via `uv run pytest`, following `tests/test_ollama.py`'s
|
||||
existing conventions (mocked `aiohttp`/`pydantic-ai` calls, no live server
|
||||
required for unit tests; the existing `integration` pytest marker — "requiring
|
||||
live Ollama at localhost:11434" — is reused for tests that exercise a real
|
||||
server)
|
||||
|
||||
**Target Platform**: ComfyUI custom-node runtime, cross-platform wherever
|
||||
ComfyUI runs; CPU-only dev harness per the project's stated vision
|
||||
|
||||
**Project Type**: Library / ComfyUI custom-node pack (single project,
|
||||
existing `src/comfydv/` layout — no new top-level project)
|
||||
|
||||
**Performance Goals**: No new numeric target; must not add latency beyond the
|
||||
existing bounded retry loop already in ADR-006 (`max_retries`, 0–5)
|
||||
|
||||
**Constraints**: Behavior-preserving for existing Ollama structured/
|
||||
non-structured chat and all model-management calls (FR-007, FR-008); no new
|
||||
dependency beyond what ADR-007 already accepted (`pydantic-ai`, `openai`,
|
||||
their transitive `httpx`/`tiktoken`); model-management stays on `aiohttp`
|
||||
|
||||
**Scale/Scope**: One new internal package (`src/comfydv/_llm/`), migration of
|
||||
the existing 1056-line `ollama.py` node/HTTP logic to consume it, five
|
||||
ComfyUI node classes renamed to generic names — no change to the project's
|
||||
single-repo, single-package scope
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
| Principle | Verdict | Notes |
|
||||
|---|---|---|
|
||||
| I. ComfyUI Contract First | PASS | Generic nodes (`ChatCompletion`, `LLMModelSelector`, `LLMLoadModel`, `LLMUnloadModel`, `OllamaClient`) still expose `INPUT_TYPES`/`RETURN_TYPES`/`RETURN_NAMES`/`FUNCTION`/`CATEGORY`; `NODE_CLASS_MAPPINGS` in `__init__.py` remains the only install-time interface. `LLMProvider` is internal, not a ComfyUI-facing contract change beyond the node/socket rename. |
|
||||
| II. Sandbox All User-Supplied Code | N/A | No template/expression evaluation in this feature — structured-output schemas are parsed as JSON Schema by `pydantic`, never `eval`/`exec`. |
|
||||
| III. Test-First | PASS (binding on tasks/implement phases) | `tests/test_ollama.py`'s existing assertions are the regression oracle (see `research.md`); new `_llm` package gets tests written before implementation, red→green→refactor. |
|
||||
| IV. Graceful Degradation Outside ComfyUI | PASS (binding on implementation) | `src/comfydv/_llm/` must not import `comfy`/`server` at module scope, matching `ollama.py`'s existing guarded-import pattern. |
|
||||
| V. Simplicity — Function Before Class | **Justified exception — see Complexity Tracking** | `LLMProvider` is a `Protocol` implemented by stateful provider classes, not module-level functions. |
|
||||
| VI. Fixed Output Positions | PASS (binding on implementation) | `ChatCompletion`'s (renamed from `OllamaChatCompletion`) `RETURN_TYPES`/`RETURN_NAMES` positions 0/1 carry forward unchanged — only the class/node name and internal mechanism change. |
|
||||
|
||||
Re-checked post-Phase 1 design (data-model.md, contracts/): unchanged — the
|
||||
`Protocol`-based design in `contracts/llm_provider_protocol.md` is exactly
|
||||
what was justified below, no new gate violations introduced by the detailed
|
||||
design.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/[###-feature]/
|
||||
├── plan.md # This file (/speckit-plan command output)
|
||||
├── research.md # Phase 0 output (/speckit-plan command)
|
||||
├── data-model.md # Phase 1 output (/speckit-plan command)
|
||||
├── quickstart.md # Phase 1 output (/speckit-plan command)
|
||||
├── contracts/ # Phase 1 output (/speckit-plan command)
|
||||
└── tasks.md # Phase 2 output (/speckit-tasks command - NOT created by /speckit-plan)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
src/comfydv/
|
||||
├── ollama.py # existing — node classes renamed to generic names,
|
||||
│ # delegates HTTP/chat logic to _llm/ internally
|
||||
├── _llm/ # new internal package (not a ComfyUI node module)
|
||||
│ ├── __init__.py
|
||||
│ ├── provider.py # LLMProvider Protocol, ModelStatus, ModelInfo, Message
|
||||
│ ├── ollama_provider.py # OllamaProvider — wraps existing aiohttp REST logic
|
||||
│ └── chat.py # shared chat_structured() pydantic-ai helper
|
||||
└── __init__.py # NODE_CLASS_MAPPINGS updated for renamed nodes
|
||||
|
||||
tests/
|
||||
├── test_ollama.py # existing — updated for renamed nodes; behavior-
|
||||
│ # preserving assertions carried forward unchanged
|
||||
└── test_llm_provider.py # new — protocol conformance + OllamaProvider unit tests
|
||||
```
|
||||
|
||||
**Structure Decision**: Single project (existing `src/comfydv/` layout, no new
|
||||
top-level project). New internal package `src/comfydv/_llm/` (underscore
|
||||
prefix marks it as internal, consistent with existing internal helpers like
|
||||
`_TTLLRUCache` that already live inside `ollama.py`) hosts the protocol and
|
||||
shared chat logic; `ollama.py` keeps the actual ComfyUI-registered node
|
||||
classes and becomes a thin caller into `_llm`.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||
|
||||
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||
|-----------|------------|-------------------------------------|
|
||||
| `LLMProvider` as a `Protocol` implemented by stateful classes (Principle V: Function Before Class) | Every one of the five protocol methods (`list_models`/`load_model`/`unload_model`/`chat`/`chat_structured`) needs the same connection state (host, auth headers) — genuine shared state, the exact condition under which the constitution allows a class. A `Protocol` also lets ComfyUI's `LLM_CLIENT` socket carry one opaque object satisfying the shape, which is what makes the adapter pattern (ADR-007) work on the canvas. | Module-level functions taking host/headers as explicit parameters on every call were considered and rejected: they'd reintroduce the exact per-call-site repetition [ADR-005](../../project-management/ADRs/ADR-005-ollama-host-config-via-client-node.md)'s config-node pattern was built to eliminate, and a bare function can't be the typed payload of a ComfyUI socket the way an object implementing a `Protocol` can. |
|
||||
@@ -0,0 +1,49 @@
|
||||
# Quickstart: LLM Provider Abstraction
|
||||
|
||||
A minimal ComfyUI workflow using the generic nodes this feature introduces.
|
||||
|
||||
## 1. Connect to a local server
|
||||
|
||||
Add an **Ollama Client** node. Set its host widget (default
|
||||
`http://localhost:11434`). This is the only node that knows it's talking to
|
||||
Ollama specifically — everything downstream just sees `LLM_CLIENT`.
|
||||
|
||||
## 2. Chat
|
||||
|
||||
Add a **Chat Completion** node. Wire the client node's `LLM_CLIENT` output
|
||||
into it. Set a model name (or feed one from a model-selector node — see
|
||||
below) and a prompt. Run the workflow: the node returns the model's text
|
||||
response.
|
||||
|
||||
## 3. Get structured output instead of free text
|
||||
|
||||
On the same **Chat Completion** node, enable `structured_output` and supply
|
||||
a JSON Schema (e.g. `{"type": "object", "properties": {"summary": {"type": "string"}, "score": {"type": "number"}}, "required": ["summary", "score"]}`).
|
||||
Re-run: the node now exposes one typed output socket per schema property
|
||||
(`summary`, `score`) instead of a single text blob, and guarantees neither
|
||||
is blank.
|
||||
|
||||
## 4. Manage what's loaded in memory
|
||||
|
||||
Add an **LLM Model Selector** node wired to the same client, to see every
|
||||
model the server knows about and its current status (`unloaded` /
|
||||
`loading` / `loaded` / …). Add **LLM Load Model** / **LLM Unload Model**
|
||||
nodes, wired to the same client, to explicitly control residency before a
|
||||
chat node needs a model.
|
||||
|
||||
## 5. (Follow-on epic) Swap backends without touching downstream nodes
|
||||
|
||||
Once `llamacpp-integration` ships a **Llama.cpp Client** node, replacing the
|
||||
**Ollama Client** node in step 1 with it — and nothing else — is the whole
|
||||
migration: it emits the same `LLM_CLIENT` socket type, so every node from
|
||||
steps 2–4 keeps working unmodified. That's the point of this feature.
|
||||
|
||||
## Migrating an existing pre-upgrade workflow
|
||||
|
||||
If you have a saved workflow using the old node names (`OllamaClient`,
|
||||
`OllamaChatCompletion`, `OllamaModelSelector`, `OllamaLoadModel`,
|
||||
`OllamaUnloadModel`), ComfyUI will report those node types as missing on
|
||||
load. Replace each with its generic equivalent from the list above and
|
||||
reconnect — behavior is unchanged, only the node names and the
|
||||
`LLM_CLIENT` socket type (replacing `OLLAMA_CLIENT`) are different. See
|
||||
`spec.md`'s User Story 4 and Edge Cases for the full detail.
|
||||
@@ -0,0 +1,94 @@
|
||||
# Research: LLM Provider Abstraction
|
||||
|
||||
All unknowns below were already resolved during DESIGN-phase work on
|
||||
[ADR-007](../../project-management/ADRs/ADR-007-llm-provider-adapter-pattern.md);
|
||||
this file consolidates that research for the plan gate rather than re-deriving it.
|
||||
|
||||
## Decision: `pydantic-ai` for `chat_structured()`, not hand-rolled tool-calling
|
||||
|
||||
**Decision**: Both `OllamaProvider` and the future `LlamaCppProvider` implement
|
||||
`chat_structured()` via `pydantic-ai`'s `Agent`/`output_type`, called through
|
||||
`OpenAIProvider(base_url=<host>/v1)`.
|
||||
|
||||
**Rationale**: A live research pass against current `pydantic-ai` docs/source
|
||||
found `httpx` is a base dependency of `pydantic-ai-slim` itself (not merely
|
||||
pulled in by an OpenAI extra), and `openai`+`tiktoken` are required for any
|
||||
OpenAI-compatible provider — a fixed, one-time dependency tax rather than a
|
||||
per-backend one. `pydantic.create_model()`-built `BaseModel` subclasses
|
||||
(comfydv's existing dynamic-schema pattern) work as `output_type` with no
|
||||
special-casing. `OpenAIProvider(base_url=...)` is one generic code path both
|
||||
Ollama's and llama.cpp's OpenAI-compatible `/v1/chat/completions` reach
|
||||
identically.
|
||||
|
||||
**Alternatives considered**: hand-roll llama.cpp's structured output too
|
||||
(duplicates [ADR-006](../../project-management/ADRs/ADR-006-structured-ollama-output-tool-calling-not-pydantic-ai.md)'s
|
||||
mechanism — rejected, defeats the DRY goal); extract a shared aiohttp-based
|
||||
helper with no new dependencies (rejected — forces re-deriving pydantic-ai's
|
||||
retry/validation machinery by hand for no benefit now that two backends exist
|
||||
to amortize the dependency cost against).
|
||||
|
||||
## Decision: aiohttp stays authoritative for model-management REST calls
|
||||
|
||||
**Decision**: `list_models()` / `load_model()` / `unload_model()` on every
|
||||
provider use `aiohttp` — no dependency change from the existing Ollama
|
||||
integration for this surface.
|
||||
|
||||
**Rationale**: [ADR-004](../../project-management/ADRs/ADR-004-aiohttp-over-httpx-for-ollama.md)'s
|
||||
reasoning (ComfyUI's own server is aiohttp-based; httpx was an unjustified
|
||||
addition) still applies fully to REST calls that don't need pydantic-ai's
|
||||
machinery. ADR-007 narrows ADR-004's scope to exactly this surface, rather
|
||||
than superseding it.
|
||||
|
||||
**Alternatives considered**: route everything (including model management)
|
||||
through `pydantic-ai`/httpx for consistency — rejected, pydantic-ai has no
|
||||
model-lifecycle-management concept (it's a chat/agent framework, not a
|
||||
generic REST client) and would add no value over plain aiohttp calls that
|
||||
already exist and work.
|
||||
|
||||
## Decision: `LLMProvider` as a `Protocol` implemented by stateful provider classes
|
||||
|
||||
**Decision**: `list_models`/`load_model`/`unload_model`/`chat`/`chat_structured`
|
||||
are defined as a `typing.Protocol`, implemented by `OllamaProvider` (and later
|
||||
`LlamaCppProvider`) classes, each constructed once per ComfyUI client node
|
||||
with the connection's host/headers as instance state.
|
||||
|
||||
**Rationale**: This is a Constitution Principle V ("Function Before Class")
|
||||
gate — classes are only justified when there's shared state a group of
|
||||
functions would otherwise have to thread through every call. Here there is:
|
||||
every one of the five protocol methods needs the same host/headers, exactly
|
||||
the connection config [ADR-005](../../project-management/ADRs/ADR-005-ollama-host-config-via-client-node.md)'s
|
||||
config-node pattern centralizes. A `Protocol` (structural typing, no
|
||||
inheritance required) keeps this lightweight — `LlamaCppProvider` doesn't
|
||||
need to import or subclass `OllamaProvider`, it only needs to match the
|
||||
method shapes.
|
||||
|
||||
**Alternatives considered**: module-level functions taking host/headers as
|
||||
explicit parameters on every call — rejected, this reintroduces the exact
|
||||
per-call-site repetition ADR-005 eliminated, and loses the ability for a
|
||||
ComfyUI `LLM_CLIENT` socket to carry one opaque object implementing the
|
||||
protocol (functions can't be typed as a socket payload the way an object
|
||||
implementing a `Protocol` can).
|
||||
|
||||
## Decision: behavior-preserving migration, verified against existing tests
|
||||
|
||||
**Decision**: `tests/test_ollama.py`'s existing assertions (retry bounds,
|
||||
required-string validation, error messages) are the acceptance bar for the
|
||||
migrated `OllamaProvider.chat_structured()` — this is a mechanism swap, not a
|
||||
new capability, per the parent epic's Non-goals and spec FR-008.
|
||||
|
||||
**Rationale**: Constitution Principle III (Test-First) and the epic's
|
||||
explicit framing of this as the riskiest change in the whole llama.cpp
|
||||
proposal (touches a Done, shipped epic's code) both point the same way: the
|
||||
existing test suite is the regression oracle, not a new one written from
|
||||
scratch.
|
||||
|
||||
## Testing approach
|
||||
|
||||
Per Constitution Principle IV (Graceful Degradation Outside ComfyUI), the new
|
||||
`src/comfydv/_llm/` package must not import `comfy`/`server` at module scope,
|
||||
matching `ollama.py`'s existing runtime-guarded pattern. Unit tests mock
|
||||
`aiohttp`/`pydantic-ai` calls (no live server required, matching
|
||||
`tests/test_ollama.py`'s existing convention); the `integration` pytest
|
||||
marker (already defined in `pyproject.toml`, "requiring live Ollama at
|
||||
localhost:11434") is reused, not redefined, for tests that exercise a real
|
||||
local server.
|
||||
@@ -0,0 +1,119 @@
|
||||
# Feature Specification: LLM Provider Abstraction
|
||||
|
||||
**Feature Branch**: `007-llm-provider-abstraction`
|
||||
|
||||
**Created**: 2026-07-11
|
||||
|
||||
**Status**: Draft
|
||||
|
||||
**Input**: User description: "Introduce a shared LLMProvider protocol for comfydv's LLM backend nodes so ComfyUI workflow authors can swap between local inference servers (starting with Ollama, with llama.cpp planned next) without changing their chat/model-management nodes. Migrate the existing Ollama integration onto generic nodes (client config, model list, load, unload, chat completion with optional structured/validated output) backed by this shared interface, per ADR-007."
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Connect to a local inference server and get chat responses (Priority: P1)
|
||||
|
||||
As a ComfyUI workflow author, I want to point a single configuration node at my local LLM server and get chat responses through a generic chat node, so I can generate text without hardcoding a server address into every node that needs one.
|
||||
|
||||
**Why this priority**: This is the minimum viable path — without a working connection and a basic chat response, nothing else in this feature has value. It also directly replaces the most-used capability of the existing Ollama integration, so it carries the highest regression risk.
|
||||
|
||||
**Independent Test**: Wire a client configuration node into a chat node, run the workflow against a running local server, and confirm the chat node returns the model's text response.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a running local inference server and a workflow with a client node wired into a chat node, **When** the workflow executes, **Then** the chat node returns the model's text response.
|
||||
2. **Given** a client node configured with an unreachable server address, **When** the workflow executes, **Then** the chat node reports a clear connection error rather than hanging indefinitely or crashing the workflow.
|
||||
3. **Given** two chat nodes in the same workflow wired to the same client node, **When** the host address is changed on the client node, **Then** both chat nodes use the new address without being edited individually.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Get structured, validated output instead of parsing raw text (Priority: P1)
|
||||
|
||||
As a workflow author, I want to describe the shape of data I need and turn on structured output for a chat node, so downstream nodes receive individually typed fields I can trust are present and non-empty, instead of me parsing free text myself.
|
||||
|
||||
**Why this priority**: This is an existing, relied-upon capability of the current Ollama integration (structured output with retry-on-invalid-response). Preserving it exactly is required for this migration to be considered safe, so it's equal priority to basic chat.
|
||||
|
||||
**Independent Test**: Enable structured output on a chat node with a schema describing two or three fields, run the workflow against a model, and confirm each schema field is exposed as its own typed output socket with a valid value.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a chat node with structured output enabled and a valid schema, **When** the workflow executes and the model responds correctly, **Then** each schema field is available as its own typed output, and no required field is blank.
|
||||
2. **Given** a model that returns invalid, incomplete, or empty-required-field output, **When** the workflow executes, **Then** the node automatically retries the request up to a configured limit.
|
||||
3. **Given** a model that continues to return invalid output after all retries are exhausted, **When** the workflow executes, **Then** the node fails with a clear, specific error rather than silently passing through invalid or partial data.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Manage which models are resident in memory (Priority: P2)
|
||||
|
||||
As a workflow author running models locally, I want to see which models are currently loaded, loading, or unloaded, and explicitly load or unload a model, so I can control memory usage on my machine without leaving ComfyUI or using a separate terminal.
|
||||
|
||||
**Why this priority**: Valuable and already present in the current Ollama integration, but a workflow can still generate output without ever calling load/unload explicitly (servers can auto-load on first use) — so this is lower risk to defer than basic chat.
|
||||
|
||||
**Independent Test**: Use a model-listing node against a running server, confirm it shows each available model with a current status; use load/unload nodes against one model and confirm its reported status changes accordingly.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a running local server with at least one available model, **When** a workflow author uses the model-listing node, **Then** they see each available model along with its current status.
|
||||
2. **Given** a model that is not currently loaded, **When** a workflow author runs the load-model node against it, **Then** the model becomes loaded and is then usable by the chat node.
|
||||
3. **Given** a model that is loaded and idle, **When** a workflow author runs the unload-model node against it, **Then** the model is freed from memory and its reported status updates accordingly.
|
||||
|
||||
---
|
||||
|
||||
### User Story 4 - Reconnect an existing workflow after upgrading (Priority: P3)
|
||||
|
||||
As an existing user of the current Ollama nodes, when I open a workflow I saved before this change, I want it to be clear which new node replaces each renamed one, so I can reconnect my workflow with minimal effort and get the same results as before.
|
||||
|
||||
**Why this priority**: This is migration friction, not new capability — it matters for a good upgrade experience but doesn't block anyone building a new workflow from scratch, so it's the lowest priority of the four.
|
||||
|
||||
**Independent Test**: Open a workflow saved against the current Ollama-specific node names, follow the provided migration guidance to reconnect it to the new generic nodes, and confirm it produces the same output as before, given the same inputs and model.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a saved workflow using the current Ollama-specific node and connection-socket names, **When** it is opened after upgrading, **Then** ComfyUI reports the now-missing node types (standard ComfyUI behavior for renamed nodes), and documentation identifies the replacement node for each one.
|
||||
2. **Given** a workflow that has been reconnected to the new generic nodes, **When** it executes with the same inputs and model as before the upgrade, **Then** it produces equivalent output.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- What happens when the configured server address is unreachable at the moment a model-listing, load, or unload node runs (not just the chat node)?
|
||||
- What happens when a workflow author supplies an invalid or malformed schema to structured output, rather than an invalid model response?
|
||||
- What happens when the connected server does not support structured/validated output at all?
|
||||
- What happens to an in-flight chat request if the model it depends on is unloaded by another node in the same workflow run?
|
||||
- What happens when a workflow author tries to wire a pre-upgrade Ollama-specific node's output into a new generic node, or vice versa? (Expected: ComfyUI's own type-checking refuses the connection, since the socket types differ — this is the intended, safe failure mode, not a bug to work around.)
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST allow a workflow author to configure a connection to a local inference server once and reuse that single configuration across multiple nodes in the same workflow.
|
||||
- **FR-002**: The system MUST allow a workflow author to request either free-text or schema-validated structured output from the same chat node, choosing per request.
|
||||
- **FR-003**: When structured output is requested, the system MUST validate the response against the supplied schema and MUST NOT deliver output to downstream nodes where a required field is missing or empty.
|
||||
- **FR-004**: When validation fails, the system MUST retry the request automatically up to a configurable limit before reporting a clear, actionable error that identifies the model, the number of attempts made, and a snippet of the last invalid response.
|
||||
- **FR-005**: The system MUST allow a workflow author to list available models on a connected server along with each model's current residency status.
|
||||
- **FR-006**: The system MUST allow a workflow author to explicitly load a model into memory and explicitly unload a model from memory.
|
||||
- **FR-007**: The system's chat and model-management nodes MUST behave identically regardless of which supported local inference server is connected, given equivalent inputs.
|
||||
- **FR-008**: The existing chat and structured-output behavior for the currently-supported local inference server (Ollama) MUST be unchanged in outcome after this migration — same retry limits, same validation rules, same error conditions — since this feature changes the underlying mechanism, not the capability.
|
||||
- **FR-009**: The system MUST document, for each node type renamed or removed by this change, which new node replaces it.
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Provider connection**: A configured connection to one local inference server (address and any authentication), created once and reused by every model-management and chat node that needs it.
|
||||
- **Model**: An inference model known to a provider connection, identified by name, with a current residency status (e.g., unloaded, loading, loaded, and — on servers that support it — sleeping or downloading).
|
||||
- **Chat request/response**: A request for a model's output, optionally carrying a schema describing the required shape of a structured response, and the corresponding validated or free-text result.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: A workflow author can go from no nodes to a working chat response using no more than two nodes (one connection node, one chat node).
|
||||
- **SC-002**: Structured-output workflows never deliver a blank or missing required field to a downstream node — every request either produces fully valid data or a clear error, with zero silent partial results.
|
||||
- **SC-003**: Existing example/reference workflows built against the current Ollama nodes remain reproducible on the new nodes with equivalent output, after a workflow author reconnects the renamed nodes.
|
||||
- **SC-004**: Adding support for a second local inference server (planned as a follow-on feature) requires no visible change to chat or model-management node behavior — only a new connection node is needed.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Workflow authors run their own local inference server (e.g., Ollama) reachable over HTTP from the machine running ComfyUI; this feature does not host, install, or manage that server.
|
||||
- Users with workflows saved against the current Ollama-specific node and socket names will need to manually reconnect them after upgrading. This is an accepted, intentional breaking change (confirmed 2026-07-11), not a defect — see FR-009 for the mitigation (documented replacement mapping), not automatic migration.
|
||||
- Support for a second local inference server (llama.cpp) is planned as a separate, follow-on feature and is out of scope here — this feature only needs to prove the shared design works end-to-end for one real backend (Ollama).
|
||||
- Structured-output schemas remain limited to flat object shapes with typed properties, consistent with what the current Ollama integration already supports — deeper nested schemas are unaffected by (neither improved nor degraded by) this change.
|
||||
- No new observability/tracing capability is introduced for workflow authors as part of this feature, even though the underlying mechanism change makes it feasible to add later.
|
||||
@@ -0,0 +1,267 @@
|
||||
# Tasks: LLM Provider Abstraction
|
||||
|
||||
**Input**: Design documents from `/specs/007-llm-provider-abstraction/`
|
||||
|
||||
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/llm_provider_protocol.md
|
||||
|
||||
**Tests**: First-class (spec carries Acceptance Scenarios) — every implementation task has a paired failing-test task (`-T`/`-I` suffix) per BEACON's test-first discipline.
|
||||
|
||||
**Organization**: Tasks are grouped by user story (spec.md priorities P1/P1/P2/P3) to enable independent implementation and testing of each.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel (different files, no dependencies)
|
||||
- **[Story]**: Which user story this task belongs to (US1–US4)
|
||||
- **-T / -I**: paired test (red) / implementation (green) — a `-I` task is never parallel with its own `-T`
|
||||
|
||||
## Path Conventions
|
||||
|
||||
Single project: `src/comfydv/`, `tests/` at repository root (per plan.md's Project Structure).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup
|
||||
|
||||
- [x] T001 Add `pydantic-ai` and `openai` to `pyproject.toml` dependencies; curate the addition into `requirements.txt` per [ADR-003](../../project-management/ADRs/ADR-003-requirements-txt-authoring-policy.md)
|
||||
- [x] T002 [P] Create `src/comfydv/_llm/__init__.py` (empty package init)
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**⚠️ CRITICAL**: No user story work can begin until this phase is complete.
|
||||
|
||||
- [x] T003 [P] Define `Message`, `ModelStatus`, `ModelInfo` in `src/comfydv/_llm/provider.py` per `data-model.md`
|
||||
- [x] T004 Define the `LLMProvider` `Protocol` in `src/comfydv/_llm/provider.py` per `contracts/llm_provider_protocol.md` (depends on T003)
|
||||
- [x] T005 [P] `LLM_CLIENT` is introduced as part of T007-I (`OllamaClient`'s output type) rather than as a standalone constant — ComfyUI socket types are plain string literals, not declared objects; folded in, not skipped
|
||||
- [x] T006 Scaffold `OllamaProvider.__init__(self, host, headers)` in `src/comfydv/_llm/ollama_provider.py`, porting the existing module-level `_post_json`/`_fetch_models`/`_run_async`/`_TTLLRUCache` helpers from `ollama.py` into it — behavior-preserving port, not a rewrite (depends on T004)
|
||||
|
||||
**Checkpoint**: protocol + provider skeleton exist; user story work can begin.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Connect to a local server and get chat responses (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: A workflow author wires a client node into a generic chat node and gets a text response.
|
||||
|
||||
**Independent Test**: Wire `OllamaClient` → `ChatCompletion`, run against a live server, confirm text output.
|
||||
|
||||
**Superseded 2026-07-11 — see `atomic-cutover-plan.md`.** T007–T010 as
|
||||
written below assumed US1 was independently deliverable; it isn't (see the
|
||||
correction at the bottom of this file). The actual work is now
|
||||
`atomic-cutover-plan.md`'s **T-CUT-04, T-CUT-05, T-CUT-06, T-CUT-08**
|
||||
(`OllamaClient` output-type change, `ChatCompletion` rename+delegation,
|
||||
`__init__.py` registration, and the corresponding `test_ollama.py`
|
||||
rewrite using a `_FakeProvider` double). Original text kept below for
|
||||
history, not as the active task list:
|
||||
|
||||
- [-] T007-T [US1] ~~Write FAILING test: `OllamaClient` node constructs and outputs an `OllamaProvider` via the `LLM_CLIENT` socket, in `tests/test_ollama.py`~~ _Superseded, see Phase 8 T-CUT-08._
|
||||
- [-] T007-I [US1] ~~Update `OllamaClient` in `src/comfydv/ollama.py` to construct and output an `OllamaProvider` via `LLM_CLIENT`~~ _Superseded, see Phase 8 T-CUT-04._
|
||||
- [-] T008-T [US1] ~~Write FAILING test: `OllamaProvider.chat()` returns model text via the existing `/api/chat` aiohttp call~~ _Superseded, see Phase 8 T-CUT-03._
|
||||
- [-] T008-I [US1] ~~Implement `OllamaProvider.chat()` in `src/comfydv/_llm/ollama_provider.py`~~ _Superseded, see Phase 8 T-CUT-02._
|
||||
- [-] T009-T [US1] ~~Write FAILING test: generic `ChatCompletion` node (non-structured) calls `provider.chat()` and surfaces a clear error on an unreachable host~~ _Superseded, see Phase 8 T-CUT-08._
|
||||
- [-] T009-I [US1] ~~Rename `OllamaChatCompletion` → `ChatCompletion`, delegate the non-structured path to `LLMProvider.chat()`, update `NODE_CLASS_MAPPINGS`~~ _Superseded, see Phase 8 T-CUT-05/T-CUT-06._
|
||||
- [-] T010-T [US1] ~~Write FAILING test: two `ChatCompletion` nodes sharing one `OllamaClient` both pick up a host change~~ _Superseded, see Phase 8 T-CUT-08._
|
||||
- [-] T010-I [US1] ~~Verify/adjust that `OllamaClient` → `OllamaProvider` construction happens per node execution~~ _Superseded, folded into Phase 8 T-CUT-04 (construction is already per-call in `create_client()`)._
|
||||
|
||||
**Checkpoint**: superseded — see `atomic-cutover-plan.md`'s checkpoint (T-CUT-10, full suite green).
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Structured, validated output (Priority: P1)
|
||||
|
||||
**Goal**: The chat node's `structured_output` toggle returns validated typed fields via the shared `pydantic-ai` mechanism.
|
||||
|
||||
**Independent Test**: Enable `structured_output` with a schema, run against a model, confirm typed sockets are populated and never blank.
|
||||
|
||||
- [x] T011-T [P] [US2] Write test: `chat_structured()` returns a validated schema instance on success, in `tests/test_llm_chat_structured.py` (witnesses `features/us2_structured_output.feature` scenario "Valid structured response exposes typed fields") — mocked at the `_build_agent` seam after an API-discovery spike into pydantic-ai's exact `Agent`/`OpenAIProvider`/`OpenAIChatModel` constructor and exception surface (not guessable from training data alone — verified live against the installed package); tests and implementation validated together rather than strictly red-first, noted honestly rather than presented as pure TDD
|
||||
- [x] T011-I [US2] Implement the shared `chat_structured()` helper in `src/comfydv/_llm/chat.py` using `pydantic-ai`'s `Agent`/`output_type` through `OpenAIProvider(base_url=<host>/v1)` + `OpenAIChatModel` — makes T011-T pass (depends on T004)
|
||||
- [x] T012-T [US2] Write test: invalid/failed-validation responses trigger automatic retry up to `max_retries` (clamped 0–5), in `tests/test_llm_chat_structured.py` (witnesses `features/us2_structured_output.feature` scenario "Invalid response triggers automatic retry")
|
||||
- [x] T012-I [US2] Implement the bounded retry loop (0–5, matching ADR-006's existing contract) around the `pydantic-ai` call in `src/comfydv/_llm/chat.py`, with the Agent's own internal retries disabled (`retries=0`) so the error contract is comfydv's — makes T012-T pass (depends on T011-I)
|
||||
- [x] T013-T [US2] Write test: exhausted retries raise `RuntimeError` naming the model, attempt count, and a truncated last-response snippet, in `tests/test_llm_chat_structured.py` (witnesses `features/us2_structured_output.feature` scenario "Exhausted retries fail clearly instead of passing through bad data")
|
||||
- [x] T013-I [US2] Implement the exhausted-retry error path in `src/comfydv/_llm/chat.py`, matching ADR-006's existing error message contract (model, attempt count, truncated last response) — makes T013-T pass (depends on T012-I)
|
||||
- [-] T014-T [US2] _Superseded — see `atomic-cutover-plan.md` D5 and T-CUT-08. Original: write FAILING test that `ChatCompletion`'s `structured_output=True` path wires a schema through `chat_structured()` to per-field dynamic ComfyUI output sockets. D5 replaces the originally-planned retry-count-style test with a delegation test against a `_FakeProvider`, since retry behavior is already covered by `tests/test_llm_chat_structured.py`._
|
||||
- [-] T014-I [US2] _Superseded — see `atomic-cutover-plan.md` T-CUT-05. Original: wire `ChatCompletion`'s `structured_output`/`output_schema` inputs to `LLMProvider.chat_structured()`, preserving the dynamic-socket UX from ADR-006 — still the right implementation shape, just executed as part of the coordinated T-CUT-05 rename, not standalone._
|
||||
|
||||
**Checkpoint**: US1 + US2 both independently functional — matches today's Ollama capability, now on the shared mechanism.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Manage model residency (Priority: P2)
|
||||
|
||||
**Goal**: List/load/unload models through generic nodes against any connected provider.
|
||||
|
||||
**Independent Test**: List models via `LLMModelSelector`; load/unload one via `LLMLoadModel`/`LLMUnloadModel`; confirm status changes.
|
||||
|
||||
**Superseded 2026-07-11 — see `atomic-cutover-plan.md`.** Maps to
|
||||
**T-CUT-02** (`OllamaProvider.list_models`/`load_model`/`unload_model`
|
||||
method bodies), **T-CUT-03** (`tests/test_ollama_provider.py`, new file),
|
||||
and **T-CUT-05/T-CUT-06/T-CUT-08** (the node renames + delegation +
|
||||
registration + test rewrite). Original text kept for history:
|
||||
|
||||
- [-] T015-T [US3] ~~Write FAILING test: `OllamaProvider.list_models()` returns `ModelInfo` entries with status normalized into `ModelStatus`~~ _Superseded, see Phase 8 T-CUT-03._
|
||||
- [-] T015-I [US3] ~~Implement `OllamaProvider.list_models()`~~ _Superseded, see Phase 8 T-CUT-02._
|
||||
- [-] T016-T [US3] ~~Write FAILING test: `OllamaProvider.load_model()`/`unload_model()` are idempotent~~ _Superseded, see Phase 8 T-CUT-03._
|
||||
- [-] T016-I [US3] ~~Implement `OllamaProvider.load_model()`/`unload_model()`~~ _Superseded, see Phase 8 T-CUT-02._
|
||||
- [-] T017-T [US3] ~~Write FAILING test: generic `LLMModelSelector` node returns model+status pairs~~ _Superseded, see Phase 8 T-CUT-08._
|
||||
- [-] T017-I [US3] ~~Rename `OllamaModelSelector` → `LLMModelSelector`, delegate to `LLMProvider.list_models()`~~ _Superseded, see Phase 8 T-CUT-05/T-CUT-06._
|
||||
- [-] T018-T [US3] ~~Write FAILING test: generic `LLMLoadModel`/`LLMUnloadModel` nodes call the protocol~~ _Superseded, see Phase 8 T-CUT-08._
|
||||
- [-] T018-I [US3] ~~Rename `OllamaLoadModel`/`OllamaUnloadModel` → `LLMLoadModel`/`LLMUnloadModel`~~ _Superseded, see Phase 8 T-CUT-05/T-CUT-06._
|
||||
|
||||
**Checkpoint**: superseded — see `atomic-cutover-plan.md`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Story 4 — Reconnect an existing workflow after upgrading (Priority: P3)
|
||||
|
||||
**Goal**: A clear old→new node mapping exists, and migrated workflows are output-equivalent.
|
||||
|
||||
**Independent Test**: Follow the mapping to reconnect a pre-upgrade workflow; confirm equivalent output.
|
||||
|
||||
- [-] T019 [US4] ~~Add a migration mapping constant~~ _Superseded, see Phase 8 T-CUT-11._
|
||||
- [-] T020 [US4] ~~Full suite green, SC-003 equivalence~~ _Superseded, see Phase 8 T-CUT-10._
|
||||
- [-] T021 [US4] ~~Update quickstart.md migration section~~ _Superseded, see Phase 8 T-CUT-12._
|
||||
|
||||
**Checkpoint**: all four user stories independently functional; migration path documented.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Atomic Node Cutover (supersedes Phases 3, 5, and T014/T019-T021)
|
||||
|
||||
**Goal**: execute `atomic-cutover-plan.md`'s 12-step sequenced plan as one
|
||||
coordinated change — this is the actual current work; Phases 3/5's `[-]`
|
||||
entries above are historical only.
|
||||
|
||||
**Not TDD-paired** the way earlier phases are — per the correction above,
|
||||
this genuinely can't be decomposed into independent red/green pairs (a
|
||||
class rename fails test *collection* for the whole file at once). Each
|
||||
T-CUT step is still verified incrementally during implementation; the
|
||||
suite only needs to be green as a whole at T-CUT-10, not after every step.
|
||||
|
||||
- [x] T-CUT-01 [P] `ollama.py`: import HTTP/cache infra from `comfydv._llm.ollama_provider` instead of duplicating it; repoint `_load_default_models()`/`/dv/ollama/models` route (plan D1)
|
||||
- [x] T-CUT-02 `ollama_provider.py`: implement `OllamaProvider.list_models()`/`load_model()`/`unload_model()`/`chat()`/`chat_structured()` method bodies (ports existing inline logic; `chat_structured()` delegates to `_llm/chat.py`; `list_models()` also queries `/api/ps` to distinguish loaded/unloaded, a genuinely new capability the old `OllamaModelSelector` never had)
|
||||
- [x] T-CUT-03 `tests/test_ollama_provider.py` (new file): tests for T-CUT-02, mocking at the `ollama_provider` seam (plan D4/D5)
|
||||
- [x] T-CUT-04 `ollama.py`: `OllamaClient.RETURN_TYPES` → `("LLM_CLIENT",)`, `create_client()` returns `OllamaProvider(host, headers)` (plan D2)
|
||||
- [x] T-CUT-05 `ollama.py`: rename the 4 classes, `"OLLAMA_CLIENT"`→`"LLM_CLIENT"` on every consumer, rewrite the 3 delegating method bodies, delete `_client_headers` (plan D6)
|
||||
- [x] T-CUT-06 `src/comfydv/__init__.py`: update imports and `NODE_CLASS_MAPPINGS`/`NODE_DISPLAY_NAME_MAPPINGS`
|
||||
- [x] T-CUT-07 `tests/conftest.py`: repoint `_clear_ollama_caches` and `first_generative_model`'s `_fetch_models` import (plan D8) — `first_generative_model`'s import needed no change (still re-exported from `comfydv.ollama`)
|
||||
- [x] T-CUT-08 `tests/test_ollama.py`: rewrote against a `_FakeProvider` double per plan D4/D5 — 98 unit tests, all passing. Also fixed a real gap the rename surfaced: `comfy-manager-entry.json`'s `nodename` list (and its matching test expectation) still had the old display names — updated both.
|
||||
- [x] T-CUT-09 [P] `contracts/llm_provider_protocol.md`: `timeout_secs` fix (commit `ef2464a`)
|
||||
- [x] T-CUT-10 Full suite green (218 passed, only the pre-existing unrelated Dockerfile-python-version test fails), `ruff check --fix && ruff format` clean, `ty check` clean (confirmed the `create_model`/`RandomChoice` diagnostics pre-date this cutover via `git stash` comparison), `beacon doctor --strict` shows only pre-existing/disclosed items (`tdd-commit-discipline` — already documented as an intentional deviation; `epic-gates` — `llamacpp-integration` correctly has no specs yet)
|
||||
- [x] T-CUT-11 [P] `tasks.md`/`ollama.py`: migration mapping constant (FR-009) — `MIGRATION_MAP` dict, `ollama.py`
|
||||
- [x] T-CUT-12 [P] Ollama was reachable in this environment — ran the real `@pytest.mark.integration` suite (not just a manual walkthrough). 6/8 passed, including the critical ones: unreachable-host error handling, real load/unload against the live server, structured-output retry-then-raise against the live server, temperature-determinism. 2 failures (`test_single_turn_returns_non_empty_response`, `test_multi_turn_receives_context`) — confirmed via direct `curl` to `/api/chat` (bypassing this codebase entirely) that the test model (`lukey03/qwen3.5-9b-abliterated-vision`) itself returns a degenerate empty response server-side; this is the exact pre-existing model unreliability ADR-006 already documented, not a cutover regression.
|
||||
|
||||
**Checkpoint**: T-CUT-10 green = all four user stories functional on the generic nodes; T-CUT-11/12 close out US4.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish & Cross-Cutting Concerns
|
||||
|
||||
_Subsumed by T-CUT-10/T-CUT-12 (Phase 8) — same work, done together with the
|
||||
cutover rather than as a separate pass, since ruff/ty/doctor need to run
|
||||
against the final state anyway:_
|
||||
|
||||
- [x] T022 [P] `ruff check --fix && ruff format` — clean (T-CUT-10)
|
||||
- [x] T023 [P] `ty check` — clean, pre-existing diagnostics confirmed unrelated via `git stash` comparison (T-CUT-10)
|
||||
- [x] T024 Constitution Principle IV confirmed: `src/comfydv/_llm/*.py` import no `comfy`/`server`/`folder_paths` at module scope (verified via grep)
|
||||
- [x] T025 `beacon doctor --strict`: only `tdd-commit-discipline` (documented deviation, see Phase 4's US2 notes) and `epic-gates` (`llamacpp-integration` correctly has no specs yet) — both pre-disclosed, not new findings (T-CUT-10)
|
||||
- [x] T026 Live-server validation done via the real `@pytest.mark.integration` suite rather than a separate manual walkthrough — Ollama was reachable in this environment (T-CUT-12); a literal `quickstart.md` click-through in ComfyUI itself is still worth doing whenever this branch is reviewed in a real ComfyUI install, but the underlying behavior is now proven against a live server
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Phase Dependencies
|
||||
|
||||
- **Setup (Phase 1)**: no dependencies
|
||||
- **Foundational (Phase 2)**: depends on Setup — BLOCKS all user stories
|
||||
- **User Stories (Phase 3–6)**: all depend on Foundational; US1 has no dependency on US2/US3/US4; US2's `ChatCompletion` wiring (T014) depends on US1's node rename (T009-I); US3 is independent of US1/US2 except for sharing `OllamaProvider`'s constructor (T006); US4 depends on the node renames done in US1/US3 (T009-I, T017-I, T018-I) since it documents them
|
||||
- **Polish (Phase 7)**: depends on all four user stories
|
||||
|
||||
### Parallel Opportunities
|
||||
|
||||
- T002 (package init) can run alongside T001 (dependency addition)
|
||||
- T003 and T005 can run in parallel (different files/concerns) within Foundational
|
||||
- T008-T (provider-level test) can run in parallel with T007-T (node-level test) — different files
|
||||
- T011-T, T015-T, T016-T can each start as soon as Foundational is done, in parallel with US1 — different files, no shared dependency beyond T004/T006
|
||||
- T022/T023 (lint/type-check) can run in parallel in Polish
|
||||
|
||||
---
|
||||
|
||||
## Implementation Strategy
|
||||
|
||||
### MVP First
|
||||
|
||||
1. Phase 1 (Setup) → Phase 2 (Foundational) → Phase 3 (US1) → **STOP and validate US1 independently** against a live local server.
|
||||
|
||||
### Incremental Delivery
|
||||
|
||||
1. Setup + Foundational → foundation ready.
|
||||
2. US1 → validate → this alone restores basic chat parity with today's Ollama integration, on the new mechanism.
|
||||
3. US2 → validate → restores structured-output parity (the ADR-006→ADR-007 migration is now complete in behavior).
|
||||
4. US3 → validate → restores model-management parity.
|
||||
5. US4 → validate → migration guidance ships; full regression pass (T020) confirms SC-003.
|
||||
6. Polish.
|
||||
|
||||
Each story adds value without breaking the previous one — this mirrors the epic's own framing: US1+US2 together are the risky "prove the migration is behavior-preserving" core; US3 and US4 round out parity and upgrade experience.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Correction (2026-07-11) — US1/US3 independence claim was wrong
|
||||
|
||||
**Discovered mid-implementation, confirmed by independent product + engineering
|
||||
review (agent-trio deliberation, aligned verdicts):** the "US1 has no
|
||||
dependency on other stories" and "US3 is independent of US1/US2" claims above
|
||||
are **false**. `OllamaClient` is a single shared producer node — every
|
||||
downstream node (`OllamaModelSelector`, `OllamaLoadModel`,
|
||||
`OllamaUnloadModel`, `OllamaChatCompletion`) consumes its output via
|
||||
`f"{client}/api/..."` string interpolation (10 call sites in `ollama.py`).
|
||||
Changing `OllamaClient` to emit an `OllamaProvider` object instead of the
|
||||
current string-like `OllamaClientType` breaks **all four** consumers
|
||||
simultaneously — there is no way to migrate just `ChatCompletion` (US1)
|
||||
while leaving `OllamaModelSelector`/`OllamaLoadModel`/`OllamaUnloadModel`
|
||||
(US3) on the old string-based access pattern. Separately, renaming these
|
||||
classes breaks `tests/test_ollama.py`'s imports atomically (125 references
|
||||
across the file) — a class rename fails test *collection* for the whole
|
||||
file at once, not test-by-test.
|
||||
|
||||
**Rejected fix:** making `OllamaProvider` also subclass `str` (mirroring
|
||||
`OllamaClientType`'s trick) to preserve incremental per-node migration.
|
||||
Both reviewers rejected this — it reintroduces the exact hack ADR-007
|
||||
exists to eliminate into the new clean boundary, and would silently mask an
|
||||
incomplete cutover (un-migrated consumers keep working via the string
|
||||
trick, so T020's regression pass would go green for the wrong reason).
|
||||
|
||||
**Decision:** T007–T010 (US1) and T015–T018 (US3)'s *node-layer* work
|
||||
(everything that touches `OllamaClient`'s output type or renames a node
|
||||
class) must land as **one atomic cutover** — one coordinated change across
|
||||
`ollama.py` and `tests/test_ollama.py`, verified green as a whole, not as
|
||||
separable per-story TDD pairs. This is sized beyond a single 2–4h tracer
|
||||
bullet and is explicitly re-scoped as its own dedicated BUILD session
|
||||
(tracked in GitHub issue — see epic Notes for the link once filed), not
|
||||
attempted in the same session as the Foundational layer (T001–T006, already
|
||||
shipped safely — see git log). The *provider-layer* work that doesn't touch
|
||||
`ollama.py` (e.g. `OllamaProvider.chat()`/`list_models()`/`load_model()`/
|
||||
`unload_model()` method bodies, and the `pydantic-ai`-backed
|
||||
`chat_structured()` helper) remains genuinely independent and safe to build
|
||||
ahead of the cutover — only the ComfyUI node-layer rename is atomic.
|
||||
|
||||
ADR-007's own decision (breaking rename, no deprecated aliases) is
|
||||
**unaffected** — that call was about user-facing blast radius (small,
|
||||
Ollama integration shipped 2026-07-04), which this finding doesn't change.
|
||||
What's re-scoped is delivery sequencing, not the design decision.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Properly specced (2026-07-11) — see `atomic-cutover-plan.md`
|
||||
|
||||
Full line-by-line inventory of every affected reference in `ollama.py` and
|
||||
`tests/test_ollama.py` (1820 lines, read in full), the design decisions it
|
||||
surfaced (cache-singleton duplication, `client == "<string>"` equality
|
||||
breaking, bare-string-client backward compat removal, and — the big one —
|
||||
a test-layer split so the 35 relocated `_post_json` monkeypatches land at
|
||||
the right architectural seam instead of being patched 1:1), and a
|
||||
12-step sequenced task list (T-CUT-01 … T-CUT-12) that supersedes the
|
||||
struck-through tasks above. That file is now the authoritative task list
|
||||
for this remaining work; this file's Phase 3/5/6 entries are kept only for
|
||||
history.
|
||||
+15
-14
@@ -3,15 +3,16 @@ import logging
|
||||
from .circuit_breaker import CircuitBreaker
|
||||
from .format_string import FormatString
|
||||
from .ollama import (
|
||||
ChatCompletion,
|
||||
LLMLoadModel,
|
||||
LLMModelSelector,
|
||||
LLMUnloadModel,
|
||||
OllamaClient,
|
||||
OllamaChatCompletion,
|
||||
OllamaDebugHistory,
|
||||
OllamaHeaderBasicAuth,
|
||||
OllamaHeaderBearerToken,
|
||||
OllamaHeaderCustom,
|
||||
OllamaHistoryLength,
|
||||
OllamaLoadModel,
|
||||
OllamaModelSelector,
|
||||
OllamaOptionExtraBody,
|
||||
OllamaOptionMaxTokens,
|
||||
OllamaOptionRepeatPenalty,
|
||||
@@ -19,7 +20,6 @@ from .ollama import (
|
||||
OllamaOptionTemperature,
|
||||
OllamaOptionTopK,
|
||||
OllamaOptionTopP,
|
||||
OllamaUnloadModel,
|
||||
)
|
||||
from .random_choice import RandomChoice
|
||||
|
||||
@@ -31,12 +31,13 @@ NODE_CLASS_MAPPINGS = {
|
||||
"RandomChoice": RandomChoice,
|
||||
"CircuitBreaker": CircuitBreaker,
|
||||
"FormatString": FormatString,
|
||||
# Ollama nodes
|
||||
# LLM nodes (generic, ADR-007) — see comfydv.ollama.MIGRATION_MAP for
|
||||
# the pre-cutover Ollama-specific names these replace
|
||||
"OllamaClient": OllamaClient,
|
||||
"OllamaModelSelector": OllamaModelSelector,
|
||||
"OllamaLoadModel": OllamaLoadModel,
|
||||
"OllamaUnloadModel": OllamaUnloadModel,
|
||||
"OllamaChatCompletion": OllamaChatCompletion,
|
||||
"LLMModelSelector": LLMModelSelector,
|
||||
"LLMLoadModel": LLMLoadModel,
|
||||
"LLMUnloadModel": LLMUnloadModel,
|
||||
"ChatCompletion": ChatCompletion,
|
||||
"OllamaOptionTemperature": OllamaOptionTemperature,
|
||||
"OllamaOptionSeed": OllamaOptionSeed,
|
||||
"OllamaOptionMaxTokens": OllamaOptionMaxTokens,
|
||||
@@ -56,12 +57,12 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"RandomChoice": "Random Choice",
|
||||
"CircuitBreaker": "Circuit Breaker",
|
||||
"FormatString": "Format String (Python f-strings)",
|
||||
# Ollama nodes
|
||||
# LLM nodes (generic, ADR-007)
|
||||
"OllamaClient": "Ollama Client",
|
||||
"OllamaModelSelector": "Ollama Model Selector",
|
||||
"OllamaLoadModel": "Ollama Load Model",
|
||||
"OllamaUnloadModel": "Ollama Unload Model",
|
||||
"OllamaChatCompletion": "Ollama Chat Completion",
|
||||
"LLMModelSelector": "LLM Model Selector",
|
||||
"LLMLoadModel": "LLM Load Model",
|
||||
"LLMUnloadModel": "LLM Unload Model",
|
||||
"ChatCompletion": "Chat Completion",
|
||||
"OllamaOptionTemperature": "Ollama Option — Temperature",
|
||||
"OllamaOptionSeed": "Ollama Option — Seed",
|
||||
"OllamaOptionMaxTokens": "Ollama Option — Max Tokens",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Internal package: shared LLM provider abstraction (ADR-007).
|
||||
|
||||
Not a ComfyUI node module — nothing here is registered in
|
||||
``NODE_CLASS_MAPPINGS``. ``comfydv.ollama`` (and, in a follow-on epic,
|
||||
``comfydv.llamacpp``) import from here.
|
||||
"""
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Shared chat_structured() helper — pydantic-ai backed structured output.
|
||||
|
||||
Used by every ``LLMProvider`` implementation's ``chat_structured()`` method
|
||||
(ADR-007) so Ollama and llama.cpp share one implementation of tool-calling/
|
||||
structured-output logic instead of each hand-rolling it, since both speak
|
||||
OpenAI-compatible ``/v1/chat/completions``.
|
||||
|
||||
Ports ADR-006's retry/validation contract exactly: bounded retries (0-5,
|
||||
clamped), and a ``RuntimeError`` naming the model, attempt count, and a
|
||||
truncated snippet of the last invalid response on exhausted retries. The
|
||||
Agent's own internal retries are disabled (``retries=0``) — this helper
|
||||
drives its own retry loop so the error contract is comfydv's, not
|
||||
pydantic-ai's internal one.
|
||||
"""
|
||||
|
||||
from typing import cast
|
||||
|
||||
from pydantic import BaseModel, ValidationError
|
||||
from pydantic_ai import Agent
|
||||
from pydantic_ai.exceptions import ModelRetry, UnexpectedModelBehavior
|
||||
from pydantic_ai.messages import (
|
||||
ModelRequest,
|
||||
ModelResponse,
|
||||
SystemPromptPart,
|
||||
TextPart,
|
||||
UserPromptPart,
|
||||
)
|
||||
from pydantic_ai.models.openai import OpenAIChatModel
|
||||
from pydantic_ai.providers.openai import OpenAIProvider
|
||||
from pydantic_ai.settings import ModelSettings
|
||||
|
||||
from comfydv._llm.provider import Message
|
||||
|
||||
_STRUCTURED_OUTPUT_FAILURE_EXCEPTIONS = (
|
||||
UnexpectedModelBehavior,
|
||||
ModelRetry,
|
||||
ValidationError,
|
||||
)
|
||||
|
||||
|
||||
def _build_agent(
|
||||
*,
|
||||
base_url: str,
|
||||
model: str,
|
||||
schema: type[BaseModel],
|
||||
headers: dict | None,
|
||||
timeout_secs: float,
|
||||
) -> Agent:
|
||||
import httpx
|
||||
|
||||
http_client = httpx.AsyncClient(
|
||||
headers=headers or None, timeout=httpx.Timeout(timeout_secs)
|
||||
)
|
||||
provider = OpenAIProvider(
|
||||
base_url=base_url, api_key="not-needed", http_client=http_client
|
||||
)
|
||||
chat_model = OpenAIChatModel(model, provider=provider)
|
||||
return Agent(chat_model, output_type=schema, retries=0)
|
||||
|
||||
|
||||
def _history_to_messages(messages: list[Message]) -> list:
|
||||
"""Convert all but the last message into pydantic-ai's typed history.
|
||||
|
||||
The last message (the current turn) is passed separately as
|
||||
``Agent.run()``'s ``user_prompt`` — see ``chat_structured()``.
|
||||
"""
|
||||
history: list = []
|
||||
for msg in messages[:-1]:
|
||||
if msg.role == "assistant":
|
||||
history.append(ModelResponse(parts=[TextPart(msg.content)]))
|
||||
elif msg.role == "system":
|
||||
history.append(ModelRequest(parts=[SystemPromptPart(msg.content)]))
|
||||
else:
|
||||
history.append(ModelRequest(parts=[UserPromptPart(msg.content)]))
|
||||
return history
|
||||
|
||||
|
||||
async def chat_structured(
|
||||
*,
|
||||
base_url: str,
|
||||
model: str,
|
||||
messages: list[Message],
|
||||
schema: type[BaseModel],
|
||||
headers: dict | None = None,
|
||||
options: dict | None = None,
|
||||
max_retries: int = 2,
|
||||
timeout_secs: float = 300.0,
|
||||
) -> BaseModel:
|
||||
"""Call ``model`` at ``base_url`` (an OpenAI-compatible ``/v1`` root) and
|
||||
return a validated instance of ``schema``.
|
||||
|
||||
``options`` is forwarded verbatim as a top-level ``"options"`` field in
|
||||
the request body via pydantic-ai's ``extra_body`` — the same shape the
|
||||
pre-ADR-007 hand-rolled implementation sent, so provider-native sampling
|
||||
params (Ollama's ``num_predict``/``repeat_penalty``/etc., set via the
|
||||
``OllamaOption*`` nodes) keep working unchanged rather than being
|
||||
lossily remapped onto pydantic-ai's own standardized ``ModelSettings``
|
||||
fields.
|
||||
|
||||
Retries up to ``max_retries`` times (clamped 0-5) on validation failure
|
||||
before raising ``RuntimeError``. Never returns a value that failed
|
||||
validation against ``schema``.
|
||||
"""
|
||||
if not messages or messages[-1].role != "user":
|
||||
raise ValueError(
|
||||
"chat_structured requires the last message to have role='user'"
|
||||
)
|
||||
|
||||
agent = _build_agent(
|
||||
base_url=base_url,
|
||||
model=model,
|
||||
schema=schema,
|
||||
headers=headers,
|
||||
timeout_secs=timeout_secs,
|
||||
)
|
||||
history = _history_to_messages(messages)
|
||||
prompt = messages[-1].content
|
||||
model_settings: ModelSettings | None = (
|
||||
{"extra_body": {"options": options}} if options else None
|
||||
)
|
||||
|
||||
total_attempts = max(0, min(int(max_retries), 5)) + 1
|
||||
last_error: Exception | None = None
|
||||
last_invalid_text = ""
|
||||
for _attempt in range(1, total_attempts + 1):
|
||||
try:
|
||||
result = await agent.run(
|
||||
prompt, message_history=history, model_settings=model_settings
|
||||
)
|
||||
# agent's output_type is the caller's `schema` (a runtime value,
|
||||
# not a static type parameter), so the checker can't narrow
|
||||
# result.output past Agent's default `str` — cast to the
|
||||
# function's declared return type, which schema is a subtype of.
|
||||
return cast(BaseModel, result.output)
|
||||
except _STRUCTURED_OUTPUT_FAILURE_EXCEPTIONS as exc:
|
||||
last_error = exc
|
||||
last_invalid_text = str(exc)
|
||||
|
||||
raise RuntimeError(
|
||||
f"chat_structured: response failed validation against schema after "
|
||||
f"{total_attempts} attempt(s) (model={model!r}). Last error: "
|
||||
f"{last_error}. Last response (truncated): {last_invalid_text[:300]!r}"
|
||||
)
|
||||
@@ -0,0 +1,314 @@
|
||||
"""OllamaProvider — LLMProvider implementation backed by Ollama's REST API.
|
||||
|
||||
Ported from comfydv.ollama's original module-level HTTP/cache helpers
|
||||
(_post_json, _fetch_models, _run_async, _TTLLRUCache) — behavior-preserving,
|
||||
not a rewrite. See ADR-007 and
|
||||
specs/007-llm-provider-abstraction/research.md.
|
||||
|
||||
OllamaProvider implements the LLMProvider Protocol structurally (no explicit
|
||||
inheritance — that's the point of typing.Protocol); conformance is checked
|
||||
by ``ty check``, not the runtime.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from comfydv._llm.provider import Message, ModelInfo, ModelStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Local response cache (ported from comfydv.ollama)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# See comfydv.ollama's original module docstring for why this exists:
|
||||
# OUTPUT_NODE=True chat nodes re-execute every queue run even when inputs
|
||||
# are unchanged; this cache absorbs the redundant round-trips.
|
||||
|
||||
|
||||
class _TTLLRUCache:
|
||||
"""Bounded cache, LRU-evicted, with an optional per-entry TTL."""
|
||||
|
||||
def __init__(self, maxsize: int, ttl_seconds: float | None = None):
|
||||
self.maxsize = maxsize
|
||||
self.ttl_seconds = ttl_seconds
|
||||
self._data: dict = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def get(self, key):
|
||||
with self._lock:
|
||||
entry = self._data.get(key)
|
||||
if entry is None:
|
||||
return None, False
|
||||
expires_at, value = entry
|
||||
if self.ttl_seconds is not None and time.monotonic() > expires_at:
|
||||
del self._data[key]
|
||||
return None, False
|
||||
# Re-insert to mark as most-recently-used (dicts preserve insertion order).
|
||||
del self._data[key]
|
||||
self._data[key] = (expires_at, value)
|
||||
return value, True
|
||||
|
||||
def set(self, key, value):
|
||||
with self._lock:
|
||||
expires_at = (
|
||||
time.monotonic() + self.ttl_seconds
|
||||
if self.ttl_seconds is not None
|
||||
else float("inf")
|
||||
)
|
||||
self._data.pop(key, None)
|
||||
self._data[key] = (expires_at, value)
|
||||
while len(self._data) > self.maxsize:
|
||||
oldest_key = next(iter(self._data))
|
||||
del self._data[oldest_key]
|
||||
|
||||
def clear(self):
|
||||
with self._lock:
|
||||
self._data.clear()
|
||||
|
||||
|
||||
def _cache_key(*parts) -> str:
|
||||
"""Deterministic, hashable key from arbitrary JSON-serializable parts."""
|
||||
return json.dumps(parts, sort_keys=True, default=str)
|
||||
|
||||
|
||||
_MODEL_LIST_CACHE = _TTLLRUCache(maxsize=32, ttl_seconds=20.0)
|
||||
_CHAT_RESPONSE_CACHE = _TTLLRUCache(maxsize=64, ttl_seconds=None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Async infrastructure (ported from comfydv.ollama)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run_async(coro):
|
||||
"""Run an async coroutine synchronously, safe inside a running event loop."""
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
# Called from within a running loop (e.g. ComfyUI's async executor).
|
||||
# Spin up a worker thread with its own loop to avoid "loop already running".
|
||||
import concurrent.futures
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
return pool.submit(asyncio.run, coro).result()
|
||||
except RuntimeError:
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
async def _post_json(
|
||||
url: str,
|
||||
payload: dict,
|
||||
*,
|
||||
timeout: float = 120.0,
|
||||
headers: dict | None = None,
|
||||
) -> dict:
|
||||
"""POST JSON to url, return parsed response dict."""
|
||||
import aiohttp
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers=headers or None,
|
||||
timeout=aiohttp.ClientTimeout(total=timeout),
|
||||
) as resp:
|
||||
if resp.status >= 400:
|
||||
body = await resp.text()
|
||||
raise RuntimeError(
|
||||
f"Ollama returned HTTP {resp.status} for {url}: {body[:300]}"
|
||||
)
|
||||
return await resp.json()
|
||||
except aiohttp.ClientConnectionError as exc:
|
||||
raise RuntimeError(f"Cannot reach Ollama at {url}: {exc}") from exc
|
||||
|
||||
|
||||
async def _get_json(
|
||||
url: str, *, timeout: float = 5.0, headers: dict | None = None
|
||||
) -> dict:
|
||||
"""GET url, return parsed response dict. Raises on connection/HTTP error."""
|
||||
import aiohttp
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
url,
|
||||
headers=headers or None,
|
||||
timeout=aiohttp.ClientTimeout(total=timeout),
|
||||
) as resp:
|
||||
return await resp.json()
|
||||
|
||||
|
||||
async def _fetch_models(host: str, headers: dict | None = None) -> list[str]:
|
||||
"""GET {host}/api/tags — return list of model name strings.
|
||||
|
||||
Used by comfydv.ollama's combo-widget population (_load_default_models,
|
||||
the /dv/ollama/models route) — a narrower, name-only view than
|
||||
OllamaProvider.list_models(), which returns full ModelInfo with status.
|
||||
Cached for _MODEL_LIST_CACHE.ttl_seconds per (host, headers) pair.
|
||||
"""
|
||||
cache_key = _cache_key("models", host, headers or {})
|
||||
cached, hit = _MODEL_LIST_CACHE.get(cache_key)
|
||||
if hit:
|
||||
return cached
|
||||
|
||||
try:
|
||||
data = await _get_json(f"{host}/api/tags", headers=headers)
|
||||
models = [m["name"] for m in data.get("models", [])]
|
||||
except Exception as exc:
|
||||
logger.warning("Could not fetch Ollama models from %s: %s", host, exc)
|
||||
return []
|
||||
|
||||
if models:
|
||||
_MODEL_LIST_CACHE.set(cache_key, models)
|
||||
return models
|
||||
|
||||
|
||||
class OllamaProvider:
|
||||
"""LLMProvider implementation backed by Ollama's REST API.
|
||||
|
||||
Host and headers are captured once at construction — every method
|
||||
reuses them, matching the ADR-005 config-node pattern (one
|
||||
``OllamaClient`` node's output is one ``OllamaProvider`` instance).
|
||||
"""
|
||||
|
||||
def __init__(self, host: str, headers: dict | None = None):
|
||||
self.host = host
|
||||
self.headers = dict(headers) if headers else None
|
||||
|
||||
async def list_models(self) -> list[ModelInfo]:
|
||||
"""Every installed model, with live loaded/unloaded status.
|
||||
|
||||
`/api/tags` lists installed models; `/api/ps` lists currently-loaded
|
||||
ones. Ollama has no `sleeping`/`downloading` concept via this API —
|
||||
never emitted here (ADR-007's documented approximation).
|
||||
"""
|
||||
cache_key = _cache_key("list_models", self.host, self.headers or {})
|
||||
cached, hit = _MODEL_LIST_CACHE.get(cache_key)
|
||||
if hit:
|
||||
return cached
|
||||
|
||||
try:
|
||||
tags = await _get_json(f"{self.host}/api/tags", headers=self.headers)
|
||||
except Exception as exc:
|
||||
logger.warning("Could not fetch Ollama models from %s: %s", self.host, exc)
|
||||
return []
|
||||
|
||||
loaded_names: set[str] = set()
|
||||
try:
|
||||
ps = await _get_json(f"{self.host}/api/ps", headers=self.headers)
|
||||
loaded_names = {m["name"] for m in ps.get("models", [])}
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Could not fetch Ollama running models from %s: %s", self.host, exc
|
||||
)
|
||||
|
||||
models = [
|
||||
ModelInfo(
|
||||
name=m["name"],
|
||||
status=(
|
||||
ModelStatus.LOADED
|
||||
if m["name"] in loaded_names
|
||||
else ModelStatus.UNLOADED
|
||||
),
|
||||
size=m.get("size"),
|
||||
)
|
||||
for m in tags.get("models", [])
|
||||
]
|
||||
if models:
|
||||
_MODEL_LIST_CACHE.set(cache_key, models)
|
||||
return models
|
||||
|
||||
async def load_model(self, model: str) -> None:
|
||||
if not model.strip():
|
||||
raise ValueError("model name cannot be empty")
|
||||
await _post_json(
|
||||
f"{self.host}/api/generate",
|
||||
{"model": model, "keep_alive": -1, "stream": False},
|
||||
timeout=300.0,
|
||||
headers=self.headers,
|
||||
)
|
||||
|
||||
async def unload_model(self, model: str) -> None:
|
||||
if not model.strip():
|
||||
raise ValueError("model name cannot be empty")
|
||||
await _post_json(
|
||||
f"{self.host}/api/generate",
|
||||
{"model": model, "keep_alive": 0, "stream": False},
|
||||
timeout=30.0,
|
||||
headers=self.headers,
|
||||
)
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[Message],
|
||||
options: dict | None = None,
|
||||
timeout_secs: float = 300.0,
|
||||
) -> str:
|
||||
payload_messages = [m.model_dump() for m in messages]
|
||||
payload: dict = {"model": model, "messages": payload_messages, "stream": False}
|
||||
if options:
|
||||
payload["options"] = options
|
||||
|
||||
cache_key = _cache_key(
|
||||
"chat",
|
||||
self.host,
|
||||
self.headers or {},
|
||||
model,
|
||||
payload_messages,
|
||||
options or {},
|
||||
)
|
||||
cached, hit = _CHAT_RESPONSE_CACHE.get(cache_key)
|
||||
if hit:
|
||||
return cached
|
||||
|
||||
result = await _post_json(
|
||||
f"{self.host}/api/chat", payload, timeout=timeout_secs, headers=self.headers
|
||||
)
|
||||
response_text = result.get("message", {}).get("content", "")
|
||||
_CHAT_RESPONSE_CACHE.set(cache_key, response_text)
|
||||
return response_text
|
||||
|
||||
async def chat_structured(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[Message],
|
||||
schema: type[BaseModel],
|
||||
options: dict | None = None,
|
||||
timeout_secs: float = 300.0,
|
||||
max_retries: int = 2,
|
||||
) -> BaseModel:
|
||||
from comfydv._llm.chat import chat_structured as _chat_structured_impl
|
||||
|
||||
payload_messages = [m.model_dump() for m in messages]
|
||||
cache_key = _cache_key(
|
||||
"chat_structured",
|
||||
self.host,
|
||||
self.headers or {},
|
||||
model,
|
||||
payload_messages,
|
||||
options or {},
|
||||
schema.model_json_schema(),
|
||||
)
|
||||
cached, hit = _CHAT_RESPONSE_CACHE.get(cache_key)
|
||||
if hit:
|
||||
return schema.model_validate(cached)
|
||||
|
||||
result = await _chat_structured_impl(
|
||||
base_url=f"{self.host}/v1",
|
||||
model=model,
|
||||
messages=messages,
|
||||
schema=schema,
|
||||
headers=self.headers,
|
||||
options=options,
|
||||
max_retries=max_retries,
|
||||
timeout_secs=timeout_secs,
|
||||
)
|
||||
_CHAT_RESPONSE_CACHE.set(cache_key, result.model_dump())
|
||||
return result
|
||||
@@ -0,0 +1,94 @@
|
||||
"""LLMProvider protocol — the adapter boundary between ComfyUI nodes and
|
||||
specific local inference backends.
|
||||
|
||||
ADR-007: every backend (OllamaProvider now, LlamaCppProvider in a follow-on
|
||||
epic) implements this shape; ComfyUI nodes depend only on the protocol,
|
||||
never on a concrete provider class. See
|
||||
project-management/ADRs/ADR-007-llm-provider-adapter-pattern.md and
|
||||
specs/007-llm-provider-abstraction/contracts/llm_provider_protocol.md.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Literal, Protocol
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ModelStatus(str, Enum):
|
||||
"""Residency status of a model on a provider's server.
|
||||
|
||||
Not every provider emits every value — e.g. Ollama has no distinct
|
||||
signal for SLEEPING or DOWNLOADING via its API and normalizes to the
|
||||
closest applicable status rather than omitting the model (documented
|
||||
approximation, ADR-007).
|
||||
"""
|
||||
|
||||
UNLOADED = "unloaded"
|
||||
LOADING = "loading"
|
||||
LOADED = "loaded"
|
||||
SLEEPING = "sleeping"
|
||||
DOWNLOADING = "downloading"
|
||||
|
||||
|
||||
class ModelInfo(BaseModel):
|
||||
"""One entry returned by ``LLMProvider.list_models()``."""
|
||||
|
||||
name: str
|
||||
status: ModelStatus
|
||||
size: int | None = None
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
"""One turn in a chat request."""
|
||||
|
||||
role: Literal["system", "user", "assistant"]
|
||||
content: str
|
||||
|
||||
|
||||
class LLMProvider(Protocol):
|
||||
"""Adapter boundary every backend implements.
|
||||
|
||||
Connection state (host, auth headers, or equivalent) is captured once
|
||||
at provider-construction time — no method takes connection details as
|
||||
a parameter.
|
||||
"""
|
||||
|
||||
async def list_models(self) -> list[ModelInfo]:
|
||||
"""Every model the server currently knows about, loaded or not."""
|
||||
...
|
||||
|
||||
async def load_model(self, model: str) -> None:
|
||||
"""Load a model into memory. Idempotent — already-loaded is not an error."""
|
||||
...
|
||||
|
||||
async def unload_model(self, model: str) -> None:
|
||||
"""Unload a model from memory. Idempotent — already-unloaded is not an error."""
|
||||
...
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[Message],
|
||||
options: dict | None = None,
|
||||
timeout_secs: float = 300.0,
|
||||
) -> str:
|
||||
"""Free-text chat response."""
|
||||
...
|
||||
|
||||
async def chat_structured(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[Message],
|
||||
schema: type[BaseModel],
|
||||
options: dict | None = None,
|
||||
timeout_secs: float = 300.0,
|
||||
max_retries: int = 2,
|
||||
) -> BaseModel:
|
||||
"""Schema-validated chat response.
|
||||
|
||||
Raises ``RuntimeError`` (naming the model, attempt count, and a
|
||||
truncated snippet of the last invalid response) if every retry is
|
||||
exhausted — never returns a value with a missing/blank required
|
||||
field.
|
||||
"""
|
||||
...
|
||||
+97
-322
@@ -1,92 +1,45 @@
|
||||
"""
|
||||
Ollama model integration nodes for ComfyUI.
|
||||
|
||||
17 nodes: client configuration, auth headers, model discovery, load/unload,
|
||||
chat completion, composable inference options, and history utilities.
|
||||
Client configuration, auth headers, model discovery, load/unload, chat
|
||||
completion, composable inference options, and history utilities.
|
||||
|
||||
ADR-004: aiohttp (already in ComfyUI dep tree) for all Ollama HTTP calls.
|
||||
ADR-005: OllamaClient node is the single source of the host URL (and, per
|
||||
US7, of any auth headers — every downstream node reaches the same server).
|
||||
ADR-006: structured_output uses Ollama's OpenAI-compatible tool-calling
|
||||
endpoint + a dynamically-built pydantic model for validation, not
|
||||
pydantic-ai — still plain aiohttp, no new HTTP client.
|
||||
ADR-007: OllamaClient now emits an OllamaProvider (comfydv._llm), the
|
||||
LLMProvider adapter-pattern boundary shared with future backends. Chat,
|
||||
model listing, and load/unload nodes are generic (ChatCompletion,
|
||||
LLMModelSelector, LLMLoadModel, LLMUnloadModel) and delegate to whichever
|
||||
provider is wired in — see MIGRATION_MAP below for the old Ollama-specific
|
||||
names these replace. Structured output is now pydantic-ai backed
|
||||
(comfydv._llm.chat), superseding ADR-006's hand-rolled tool-calling.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
from pydantic import ValidationError
|
||||
from comfydv._llm.ollama_provider import OllamaProvider, _fetch_models, _run_async
|
||||
from comfydv._llm.provider import Message
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Local response cache
|
||||
# Migration mapping (ADR-007) — old Ollama-specific node/socket names to
|
||||
# their generic replacements, for anyone reconnecting a pre-upgrade
|
||||
# workflow. See FR-009, specs/007-llm-provider-abstraction/spec.md.
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# OllamaChatCompletion is OUTPUT_NODE=True (needed for inline display), which
|
||||
# means ComfyUI re-executes it on every queue run even when none of its
|
||||
# inputs changed — unlike normal nodes, it isn't skipped by ComfyUI's own
|
||||
# input-hash cache. This cache absorbs those redundant round-trips: identical
|
||||
# (client, headers, model, messages, options) reuse the prior response
|
||||
# instead of re-querying Ollama. Model discovery gets the same treatment
|
||||
# (many nodes independently query /api/tags on graph load) but with a short
|
||||
# TTL so a newly-pulled model still surfaces after a refresh.
|
||||
|
||||
|
||||
class _TTLLRUCache:
|
||||
"""Bounded cache, LRU-evicted, with an optional per-entry TTL."""
|
||||
|
||||
def __init__(self, maxsize: int, ttl_seconds: float | None = None):
|
||||
self.maxsize = maxsize
|
||||
self.ttl_seconds = ttl_seconds
|
||||
self._data: dict = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def get(self, key):
|
||||
with self._lock:
|
||||
entry = self._data.get(key)
|
||||
if entry is None:
|
||||
return None, False
|
||||
expires_at, value = entry
|
||||
if self.ttl_seconds is not None and time.monotonic() > expires_at:
|
||||
del self._data[key]
|
||||
return None, False
|
||||
# Re-insert to mark as most-recently-used (dicts preserve insertion order).
|
||||
del self._data[key]
|
||||
self._data[key] = (expires_at, value)
|
||||
return value, True
|
||||
|
||||
def set(self, key, value):
|
||||
with self._lock:
|
||||
expires_at = (
|
||||
time.monotonic() + self.ttl_seconds
|
||||
if self.ttl_seconds is not None
|
||||
else float("inf")
|
||||
)
|
||||
self._data.pop(key, None)
|
||||
self._data[key] = (expires_at, value)
|
||||
while len(self._data) > self.maxsize:
|
||||
oldest_key = next(iter(self._data))
|
||||
del self._data[oldest_key]
|
||||
|
||||
def clear(self):
|
||||
with self._lock:
|
||||
self._data.clear()
|
||||
|
||||
|
||||
def _cache_key(*parts) -> str:
|
||||
"""Deterministic, hashable key from arbitrary JSON-serializable parts."""
|
||||
return json.dumps(parts, sort_keys=True, default=str)
|
||||
|
||||
|
||||
_MODEL_LIST_CACHE = _TTLLRUCache(maxsize=32, ttl_seconds=20.0)
|
||||
_CHAT_RESPONSE_CACHE = _TTLLRUCache(maxsize=64, ttl_seconds=None)
|
||||
MIGRATION_MAP: dict[str, str] = {
|
||||
"OllamaChatCompletion": "ChatCompletion",
|
||||
"OllamaModelSelector": "LLMModelSelector",
|
||||
"OllamaLoadModel": "LLMLoadModel",
|
||||
"OllamaUnloadModel": "LLMUnloadModel",
|
||||
"OLLAMA_CLIENT": "LLM_CLIENT",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -97,10 +50,11 @@ _CHAT_RESPONSE_CACHE = _TTLLRUCache(maxsize=64, ttl_seconds=None)
|
||||
class OllamaClientType(str):
|
||||
"""Typed string carrying the Ollama host URL through the node graph.
|
||||
|
||||
Also carries an optional ``.headers`` dict (US7 — basic auth / bearer
|
||||
tokens for Ollama servers behind a reverse proxy). Since this is a plain
|
||||
``str`` subclass, every existing ``f"{client}/api/..."`` call site keeps
|
||||
working unchanged; only code that wants auth reads ``.headers``.
|
||||
Superseded by ``OllamaProvider`` (comfydv._llm.ollama_provider) as of
|
||||
ADR-007 — ``OllamaClient.create_client()`` no longer constructs this.
|
||||
Left in place (unreferenced) rather than deleted; removing a class
|
||||
nothing references is a separate, lower-risk cleanup outside this
|
||||
cutover's scope.
|
||||
"""
|
||||
|
||||
def __new__(cls, host, headers=None):
|
||||
@@ -109,89 +63,16 @@ class OllamaClientType(str):
|
||||
return obj
|
||||
|
||||
|
||||
def _client_headers(client) -> dict | None:
|
||||
"""Extract auth headers stashed on an OllamaClientType, if any."""
|
||||
headers = getattr(client, "headers", None)
|
||||
return dict(headers) if headers else None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Async infrastructure
|
||||
# Combo-widget model population
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run_async(coro):
|
||||
"""Run an async coroutine synchronously, safe inside a running event loop."""
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
# Called from within a running loop (e.g. ComfyUI's async executor).
|
||||
# Spin up a worker thread with its own loop to avoid "loop already running".
|
||||
import concurrent.futures
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
||||
return pool.submit(asyncio.run, coro).result()
|
||||
except RuntimeError:
|
||||
return asyncio.run(coro)
|
||||
|
||||
|
||||
async def _fetch_models(host: str, headers: dict | None = None) -> list[str]:
|
||||
"""GET {host}/api/tags — return list of model name strings.
|
||||
|
||||
Cached for _MODEL_LIST_CACHE.ttl_seconds per (host, headers) pair — node
|
||||
creation, the Refresh button, and startup all otherwise re-issue this
|
||||
same request in quick succession.
|
||||
"""
|
||||
cache_key = _cache_key("models", host, headers or {})
|
||||
cached, hit = _MODEL_LIST_CACHE.get(cache_key)
|
||||
if hit:
|
||||
return cached
|
||||
|
||||
import aiohttp
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
f"{host}/api/tags",
|
||||
headers=headers or None,
|
||||
timeout=aiohttp.ClientTimeout(total=5),
|
||||
) as resp:
|
||||
data = await resp.json()
|
||||
models = [m["name"] for m in data.get("models", [])]
|
||||
except Exception as exc:
|
||||
logger.warning("Could not fetch Ollama models from %s: %s", host, exc)
|
||||
return []
|
||||
|
||||
if models:
|
||||
_MODEL_LIST_CACHE.set(cache_key, models)
|
||||
return models
|
||||
|
||||
|
||||
async def _post_json(
|
||||
url: str,
|
||||
payload: dict,
|
||||
*,
|
||||
timeout: float = 120.0,
|
||||
headers: dict | None = None,
|
||||
) -> dict:
|
||||
"""POST JSON to url, return parsed response dict."""
|
||||
import aiohttp
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
url,
|
||||
json=payload,
|
||||
headers=headers or None,
|
||||
timeout=aiohttp.ClientTimeout(total=timeout),
|
||||
) as resp:
|
||||
if resp.status >= 400:
|
||||
body = await resp.text()
|
||||
raise RuntimeError(
|
||||
f"Ollama returned HTTP {resp.status} for {url}: {body[:300]}"
|
||||
)
|
||||
return await resp.json()
|
||||
except aiohttp.ClientConnectionError as exc:
|
||||
raise RuntimeError(f"Cannot reach Ollama at {url}: {exc}") from exc
|
||||
#
|
||||
# Narrower than OllamaProvider.list_models() (name-only, no live status) —
|
||||
# used only to populate COMBO dropdowns at node-definition time and by the
|
||||
# JS refresh button, neither of which has a constructed provider instance
|
||||
# to call. _fetch_models/_run_async are the single source of truth,
|
||||
# imported from comfydv._llm.ollama_provider (ADR-007) rather than
|
||||
# duplicated here.
|
||||
|
||||
|
||||
def _load_default_models() -> list[str]:
|
||||
@@ -250,7 +131,7 @@ else:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# US1 — OllamaClient
|
||||
# OllamaClient
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -266,13 +147,13 @@ class OllamaClient:
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("OLLAMA_CLIENT",)
|
||||
RETURN_TYPES = ("LLM_CLIENT",)
|
||||
RETURN_NAMES = ("client",)
|
||||
FUNCTION = "create_client"
|
||||
CATEGORY = "dv/ollama"
|
||||
|
||||
def create_client(self, host: str, headers: dict | None = None):
|
||||
return (OllamaClientType(host, headers),)
|
||||
return (OllamaProvider(host, headers),)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -351,16 +232,24 @@ class OllamaHeaderCustom:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# US2 — OllamaModelSelector
|
||||
# LLMModelSelector (replaces OllamaModelSelector — MIGRATION_MAP)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OllamaModelSelector:
|
||||
class LLMModelSelector:
|
||||
"""Passes a model name through, typed for wiring/validation.
|
||||
|
||||
``client`` is accepted only for typing/wiring — the COMBO dropdown is
|
||||
populated separately (see _load_default_models); this node never calls
|
||||
the provider. Behavior-identical to the pre-ADR-007 OllamaModelSelector,
|
||||
generalized to accept any LLM_CLIENT-typed provider.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"client": ("OLLAMA_CLIENT",),
|
||||
"client": ("LLM_CLIENT",),
|
||||
"model": (_DEFAULT_MODELS, {}),
|
||||
}
|
||||
}
|
||||
@@ -375,16 +264,16 @@ class OllamaModelSelector:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# US3 — OllamaLoadModel / OllamaUnloadModel
|
||||
# LLMLoadModel / LLMUnloadModel (replace OllamaLoadModel/OllamaUnloadModel)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class OllamaLoadModel:
|
||||
class LLMLoadModel:
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"client": ("OLLAMA_CLIENT",),
|
||||
"client": ("LLM_CLIENT",),
|
||||
"model": (_DEFAULT_MODELS, {}),
|
||||
}
|
||||
}
|
||||
@@ -397,22 +286,15 @@ class OllamaLoadModel:
|
||||
def load_model(self, client, model: str):
|
||||
if not model.strip():
|
||||
raise ValueError("model name cannot be empty")
|
||||
_run_async(
|
||||
_post_json(
|
||||
f"{client}/api/generate",
|
||||
{"model": model, "keep_alive": -1, "stream": False},
|
||||
timeout=300.0,
|
||||
headers=_client_headers(client),
|
||||
)
|
||||
)
|
||||
_run_async(client.load_model(model))
|
||||
return (model,)
|
||||
|
||||
|
||||
class OllamaUnloadModel:
|
||||
class LLMUnloadModel:
|
||||
"""Evict a model from VRAM and pass a value through unchanged.
|
||||
|
||||
Wire ``passthrough`` from a downstream node (e.g. the ``response`` output
|
||||
of OllamaChatCompletion) so ComfyUI executes this node *after* that node
|
||||
of ChatCompletion) so ComfyUI executes this node *after* that node
|
||||
completes. The value is returned unchanged so the rest of the workflow can
|
||||
continue using it.
|
||||
"""
|
||||
@@ -421,7 +303,7 @@ class OllamaUnloadModel:
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"client": ("OLLAMA_CLIENT",),
|
||||
"client": ("LLM_CLIENT",),
|
||||
"model": ("STRING", {"forceInput": True}),
|
||||
},
|
||||
"optional": {
|
||||
@@ -437,19 +319,12 @@ class OllamaUnloadModel:
|
||||
def unload_model(self, client, model: str, passthrough: str = ""):
|
||||
if not model.strip():
|
||||
raise ValueError("model name cannot be empty")
|
||||
_run_async(
|
||||
_post_json(
|
||||
f"{client}/api/generate",
|
||||
{"model": model, "keep_alive": 0, "stream": False},
|
||||
timeout=30.0,
|
||||
headers=_client_headers(client),
|
||||
)
|
||||
)
|
||||
_run_async(client.unload_model(model))
|
||||
return (model, passthrough)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# US4 — OllamaChatCompletion
|
||||
# ChatCompletion (replaces OllamaChatCompletion — MIGRATION_MAP)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -466,21 +341,11 @@ def _history_preview(messages: list[dict]) -> str:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Structured output — Ollama's OpenAI-compatible /v1/chat/completions
|
||||
# tool-calling, with a single forced tool matching output_schema
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# ADR-006: forces the model to emit its answer as a single tool call whose
|
||||
# arguments match output_schema, instead of relying on prompt wording —
|
||||
# fixes commentary, code-fence wrapping, and blank output at the source.
|
||||
# Ollama's native /api/chat "format" (JSON-Schema-constrained decoding) was
|
||||
# tried first but proved unreliable against at least one real model in
|
||||
# testing (a modified-tokenizer quantization silently ignored the schema
|
||||
# rather than erroring); tool-calling relies on the model's own trained
|
||||
# function-calling behavior instead of grammar-to-token mapping, and is
|
||||
# still reached over the existing aiohttp-based _post_json — no new HTTP
|
||||
# client. pydantic is used purely to dynamically build a validation model
|
||||
# from the user's schema, not as an HTTP client.
|
||||
# Structured output schema helpers — stay here (pure/local, no network
|
||||
# dependency), shared by ChatCompletion.chat() and the live-preview route.
|
||||
# The actual structured-output *mechanism* (tool-calling, retry, validation)
|
||||
# moved to comfydv._llm.chat / pydantic-ai as of ADR-007, superseding
|
||||
# ADR-006's hand-rolled approach.
|
||||
|
||||
_JSON_SCHEMA_TO_PY_TYPE: dict = {
|
||||
"string": str,
|
||||
@@ -535,7 +400,7 @@ def _build_structured_model(schema: dict):
|
||||
"""Dynamically build a pydantic BaseModel from schema properties/required.
|
||||
|
||||
Rebuilt every call — create_model() on a handful of fields is cheap, and
|
||||
OllamaChatCompletion already re-executes every queue run (OUTPUT_NODE=True).
|
||||
ChatCompletion already re-executes every queue run (OUTPUT_NODE=True).
|
||||
|
||||
Required *string* fields get `min_length=1`: JSON Schema's "required"
|
||||
only checks presence, so a model could satisfy it with `""` — which is
|
||||
@@ -555,7 +420,7 @@ def _build_structured_model(schema: dict):
|
||||
fields[name] = (py_type, Field(..., min_length=1))
|
||||
else:
|
||||
fields[name] = (py_type, ...)
|
||||
return create_model("OllamaStructuredOutput", **fields)
|
||||
return create_model("StructuredOutput", **fields)
|
||||
|
||||
|
||||
def _coerce_structured_value(value, comfy_type: str):
|
||||
@@ -566,42 +431,7 @@ def _coerce_structured_value(value, comfy_type: str):
|
||||
return value
|
||||
|
||||
|
||||
_STRUCTURED_TOOL_NAME = "emit_structured_output"
|
||||
|
||||
|
||||
def _build_tool_call_payload(schema: dict) -> tuple:
|
||||
"""tools/tool_choice extras forcing a single call matching schema."""
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": _STRUCTURED_TOOL_NAME,
|
||||
"description": "Emit the result matching the required schema.",
|
||||
"parameters": schema,
|
||||
},
|
||||
}
|
||||
]
|
||||
tool_choice = {"type": "function", "function": {"name": _STRUCTURED_TOOL_NAME}}
|
||||
return tools, tool_choice
|
||||
|
||||
|
||||
def _extract_tool_call_arguments(result: dict) -> str:
|
||||
"""Pull the forced tool call's arguments JSON string out of an OpenAI-
|
||||
compatible /v1/chat/completions response. Empty string (which will fail
|
||||
pydantic validation and trigger a retry) if the model didn't call the
|
||||
tool at all — this happens on occasion even with tool_choice forcing it.
|
||||
"""
|
||||
choices = result.get("choices") or []
|
||||
if not choices:
|
||||
return ""
|
||||
message = choices[0].get("message", {})
|
||||
tool_calls = message.get("tool_calls") or []
|
||||
if not tool_calls:
|
||||
return ""
|
||||
return tool_calls[0].get("function", {}).get("arguments", "") or ""
|
||||
|
||||
|
||||
class OllamaChatCompletion:
|
||||
class ChatCompletion:
|
||||
OUTPUT_NODE = True
|
||||
|
||||
_BASE_RETURN_TYPES = ("STRING", "OLLAMA_HISTORY", "STRING")
|
||||
@@ -615,9 +445,9 @@ class OllamaChatCompletion:
|
||||
def INPUT_TYPES(s):
|
||||
return {
|
||||
"required": {
|
||||
"client": ("OLLAMA_CLIENT",),
|
||||
# Plain STRING so it can receive a wired value from OllamaLoadModel
|
||||
# (or OllamaModelSelector) without needing a separate model_name socket.
|
||||
"client": ("LLM_CLIENT",),
|
||||
# Plain STRING so it can receive a wired value from LLMLoadModel
|
||||
# (or LLMModelSelector) without needing a separate model_name socket.
|
||||
"model": ("STRING", {"default": ""}),
|
||||
"prompt": ("STRING", {"multiline": True, "default": ""}),
|
||||
},
|
||||
@@ -694,96 +524,41 @@ class OllamaChatCompletion:
|
||||
|
||||
if history is None:
|
||||
history = []
|
||||
messages = list(history)
|
||||
message_dicts = list(history)
|
||||
if system:
|
||||
messages = [{"role": "system", "content": system}] + messages
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
payload: dict = {
|
||||
"model": effective_model,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
}
|
||||
if options:
|
||||
payload["options"] = options
|
||||
if structured_output:
|
||||
# ADR-006: OpenAI-compatible tool-calling, not native /api/chat
|
||||
# "format" — forces a single call whose arguments match schema.
|
||||
assert schema is not None # structured_output implies this was parsed
|
||||
tools, tool_choice = _build_tool_call_payload(schema)
|
||||
payload["tools"] = tools
|
||||
payload["tool_choice"] = tool_choice
|
||||
message_dicts = [{"role": "system", "content": system}] + message_dicts
|
||||
message_dicts.append({"role": "user", "content": prompt})
|
||||
messages = [Message(**m) for m in message_dicts]
|
||||
llm_options = dict(options) if options else None
|
||||
|
||||
headers = _client_headers(client)
|
||||
cache_key = _cache_key(
|
||||
"chat",
|
||||
client,
|
||||
headers or {},
|
||||
effective_model,
|
||||
messages,
|
||||
options or {},
|
||||
schema or {},
|
||||
)
|
||||
cached, hit = _CHAT_RESPONSE_CACHE.get(cache_key)
|
||||
url = (
|
||||
f"{client}/v1/chat/completions"
|
||||
if structured_output
|
||||
else f"{client}/api/chat"
|
||||
)
|
||||
|
||||
parsed = None
|
||||
# Provider owns transport, caching, and — for structured_output — the
|
||||
# tool-calling/retry/validation mechanism (pydantic-ai, ADR-007).
|
||||
# ChatCompletion never branches on which concrete provider it got.
|
||||
if not structured_output:
|
||||
# Unchanged from pre-structured-output behavior.
|
||||
if hit:
|
||||
response_text = cached
|
||||
else:
|
||||
result = _run_async(
|
||||
_post_json(
|
||||
url, payload, timeout=float(timeout_secs), headers=headers
|
||||
)
|
||||
parsed = None
|
||||
response_text = _run_async(
|
||||
client.chat(
|
||||
effective_model,
|
||||
messages,
|
||||
llm_options,
|
||||
timeout_secs=float(timeout_secs),
|
||||
)
|
||||
response_text = result.get("message", {}).get("content", "")
|
||||
_CHAT_RESPONSE_CACHE.set(cache_key, response_text)
|
||||
elif hit:
|
||||
# schema is part of the cache key, so a hit was necessarily
|
||||
# validated against this exact schema when it was written —
|
||||
# re-parsing here is guaranteed-successful deserialization, not
|
||||
# a fallible re-check.
|
||||
assert (
|
||||
pydantic_model is not None
|
||||
) # structured_output implies this was built
|
||||
response_text = cached
|
||||
parsed = pydantic_model.model_validate_json(response_text)
|
||||
)
|
||||
else:
|
||||
assert (
|
||||
pydantic_model is not None
|
||||
) # structured_output implies this was built
|
||||
total_attempts = max(0, min(int(max_retries), 5)) + 1
|
||||
response_text = None
|
||||
last_invalid_text = ""
|
||||
last_error = None
|
||||
for _attempt in range(1, total_attempts + 1):
|
||||
result = _run_async(
|
||||
_post_json(
|
||||
url, payload, timeout=float(timeout_secs), headers=headers
|
||||
)
|
||||
)
|
||||
candidate = _extract_tool_call_arguments(result)
|
||||
try:
|
||||
parsed = pydantic_model.model_validate_json(candidate)
|
||||
response_text = candidate
|
||||
_CHAT_RESPONSE_CACHE.set(cache_key, response_text)
|
||||
break
|
||||
except ValidationError as exc:
|
||||
last_invalid_text = candidate
|
||||
last_error = exc
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f"OllamaChatCompletion: structured_output response failed "
|
||||
f"validation against output_schema after {total_attempts} "
|
||||
f"attempt(s) (model={effective_model!r}). Last error: "
|
||||
f"{last_error}. Last response (truncated): "
|
||||
f"{last_invalid_text[:300]!r}"
|
||||
parsed = _run_async(
|
||||
client.chat_structured(
|
||||
effective_model,
|
||||
messages,
|
||||
pydantic_model,
|
||||
llm_options,
|
||||
timeout_secs=float(timeout_secs),
|
||||
max_retries=max_retries,
|
||||
)
|
||||
)
|
||||
response_text = parsed.model_dump_json()
|
||||
|
||||
updated = list(history)
|
||||
updated.append({"role": "user", "content": prompt})
|
||||
@@ -817,7 +592,7 @@ class OllamaChatCompletion:
|
||||
#
|
||||
# Mirrors FormatString's /update_format_string_node route: as the user edits
|
||||
# structured_output/output_schema, the frontend posts here to recompute
|
||||
# OllamaChatCompletion.RETURN_TYPES/RETURN_NAMES (the same update_outputs()
|
||||
# ChatCompletion.RETURN_TYPES/RETURN_NAMES (the same update_outputs()
|
||||
# path chat() uses at execution time) and get back the current output list,
|
||||
# so dynamic sockets appear on the node immediately — no need to run the
|
||||
# graph first. No network call to Ollama; schema parsing is pure/local.
|
||||
@@ -845,14 +620,14 @@ if "comfy" in sys.modules:
|
||||
schema = None
|
||||
|
||||
if unique_id:
|
||||
OllamaChatCompletion.update_outputs(
|
||||
ChatCompletion.update_outputs(
|
||||
unique_id, structured_output and schema is not None, schema
|
||||
)
|
||||
|
||||
outputs = [
|
||||
{"name": name, "type": otype}
|
||||
for name, otype in zip(
|
||||
OllamaChatCompletion.RETURN_NAMES, OllamaChatCompletion.RETURN_TYPES
|
||||
ChatCompletion.RETURN_NAMES, ChatCompletion.RETURN_TYPES
|
||||
)
|
||||
]
|
||||
return web.json_response({"outputs": outputs})
|
||||
|
||||
+15
-14
@@ -68,30 +68,31 @@ def pytest_configure(config):
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_ollama_caches():
|
||||
"""Reset comfydv.ollama's module-level LRU caches and OllamaChatCompletion's
|
||||
dynamic RETURN_TYPES/RETURN_NAMES around every test.
|
||||
"""Reset the shared LLM provider caches and ChatCompletion's dynamic
|
||||
RETURN_TYPES/RETURN_NAMES around every test.
|
||||
|
||||
Several tests reuse identical client/model/prompt inputs across cases
|
||||
with different monkeypatched responses — without this, a later test would
|
||||
silently get an earlier test's cached result instead of exercising its
|
||||
own fake. RETURN_TYPES/RETURN_NAMES are class-level mutable state (set by
|
||||
OllamaChatCompletion.update_outputs for structured_output mode) shared
|
||||
across every test in the module — without resetting them, a structured-
|
||||
output test would leak its dynamic outputs into unrelated tests that
|
||||
assert the fixed 3-tuple.
|
||||
ChatCompletion.update_outputs for structured_output mode) shared across
|
||||
every test in the module — without resetting them, a structured-output
|
||||
test would leak its dynamic outputs into unrelated tests that assert the
|
||||
fixed 3-tuple.
|
||||
|
||||
Caches live in comfydv._llm.ollama_provider (ADR-007's single source of
|
||||
truth) — comfydv.ollama's combo-widget helpers (_fetch_models) share the
|
||||
same cache instance, not a separate copy.
|
||||
"""
|
||||
from comfydv.ollama import (
|
||||
_CHAT_RESPONSE_CACHE,
|
||||
_MODEL_LIST_CACHE,
|
||||
OllamaChatCompletion,
|
||||
)
|
||||
from comfydv._llm.ollama_provider import _CHAT_RESPONSE_CACHE, _MODEL_LIST_CACHE
|
||||
from comfydv.ollama import ChatCompletion
|
||||
|
||||
def _reset():
|
||||
_MODEL_LIST_CACHE.clear()
|
||||
_CHAT_RESPONSE_CACHE.clear()
|
||||
OllamaChatCompletion.RETURN_TYPES = OllamaChatCompletion._BASE_RETURN_TYPES
|
||||
OllamaChatCompletion.RETURN_NAMES = OllamaChatCompletion._BASE_RETURN_NAMES
|
||||
OllamaChatCompletion.node_configs.clear()
|
||||
ChatCompletion.RETURN_TYPES = ChatCompletion._BASE_RETURN_TYPES
|
||||
ChatCompletion.RETURN_NAMES = ChatCompletion._BASE_RETURN_NAMES
|
||||
ChatCompletion.node_configs.clear()
|
||||
|
||||
_reset()
|
||||
yield
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
Tests for comfydv._llm.chat.chat_structured — shared pydantic-ai backed
|
||||
structured output, used by every LLMProvider implementation (ADR-007).
|
||||
|
||||
Mocks at the comfydv._llm.chat._build_agent seam (returns a fake agent
|
||||
exposing an async .run()), mirroring test_ollama.py's existing convention
|
||||
of monkeypatching the module-level HTTP seam rather than the network itself.
|
||||
Uses _run_async (same helper comfydv.ollama uses) to drive the coroutine
|
||||
synchronously, matching this project's existing test style rather than
|
||||
introducing a pytest-asyncio dependency.
|
||||
|
||||
BDD coverage:
|
||||
../specs/007-llm-provider-abstraction/features/us2_structured_output.feature
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
import comfydv._llm.chat as chat_mod
|
||||
from comfydv._llm.ollama_provider import _run_async
|
||||
from comfydv._llm.provider import Message
|
||||
|
||||
|
||||
class _Widget(BaseModel):
|
||||
name: str
|
||||
count: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeResult:
|
||||
output: object
|
||||
|
||||
|
||||
class _FakeAgent:
|
||||
"""Stand-in for pydantic_ai.Agent — .run() is scripted per test."""
|
||||
|
||||
def __init__(self, responses):
|
||||
self._responses = list(responses)
|
||||
self.calls = []
|
||||
|
||||
async def run(self, prompt, *, message_history=None, model_settings=None):
|
||||
self.calls.append((prompt, message_history, model_settings))
|
||||
outcome = self._responses.pop(0)
|
||||
if isinstance(outcome, Exception):
|
||||
raise outcome
|
||||
return _FakeResult(output=outcome)
|
||||
|
||||
|
||||
def _messages(*, system=None, history=None, prompt="hi"):
|
||||
msgs = []
|
||||
if system:
|
||||
msgs.append(Message(role="system", content=system))
|
||||
for role, content in history or []:
|
||||
msgs.append(Message(role=role, content=content))
|
||||
msgs.append(Message(role="user", content=prompt))
|
||||
return msgs
|
||||
|
||||
|
||||
def test_chat_structured_returns_validated_output(monkeypatch):
|
||||
fake = _FakeAgent([_Widget(name="a", count=1)])
|
||||
monkeypatch.setattr(chat_mod, "_build_agent", lambda **kw: fake)
|
||||
|
||||
result = _run_async(
|
||||
chat_mod.chat_structured(
|
||||
base_url="http://localhost:11434/v1",
|
||||
model="llama3",
|
||||
messages=_messages(prompt="describe a widget"),
|
||||
schema=_Widget,
|
||||
)
|
||||
)
|
||||
|
||||
assert result == _Widget(name="a", count=1)
|
||||
assert fake.calls[0][0] == "describe a widget"
|
||||
|
||||
|
||||
def test_chat_structured_retries_on_validation_failure(monkeypatch):
|
||||
bad = ValidationError.from_exception_data("Widget", [])
|
||||
fake = _FakeAgent([bad, _Widget(name="b", count=2)])
|
||||
monkeypatch.setattr(chat_mod, "_build_agent", lambda **kw: fake)
|
||||
|
||||
result = _run_async(
|
||||
chat_mod.chat_structured(
|
||||
base_url="http://localhost:11434/v1",
|
||||
model="llama3",
|
||||
messages=_messages(),
|
||||
schema=_Widget,
|
||||
max_retries=2,
|
||||
)
|
||||
)
|
||||
|
||||
assert result == _Widget(name="b", count=2)
|
||||
assert len(fake.calls) == 2
|
||||
|
||||
|
||||
def test_chat_structured_exhausted_retries_raises_runtime_error(monkeypatch):
|
||||
bad = ValidationError.from_exception_data("Widget", [])
|
||||
fake = _FakeAgent([bad, bad, bad]) # max_retries=2 -> 3 total attempts
|
||||
monkeypatch.setattr(chat_mod, "_build_agent", lambda **kw: fake)
|
||||
|
||||
with pytest.raises(RuntimeError) as exc_info:
|
||||
_run_async(
|
||||
chat_mod.chat_structured(
|
||||
base_url="http://localhost:11434/v1",
|
||||
model="llama3",
|
||||
messages=_messages(),
|
||||
schema=_Widget,
|
||||
max_retries=2,
|
||||
)
|
||||
)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "llama3" in message
|
||||
assert "3 attempt(s)" in message
|
||||
assert len(fake.calls) == 3
|
||||
|
||||
|
||||
def test_chat_structured_max_retries_clamped_to_five(monkeypatch):
|
||||
bad = ValidationError.from_exception_data("Widget", [])
|
||||
fake = _FakeAgent([bad] * 6)
|
||||
monkeypatch.setattr(chat_mod, "_build_agent", lambda **kw: fake)
|
||||
|
||||
with pytest.raises(RuntimeError, match=r"6 attempt\(s\)"):
|
||||
_run_async(
|
||||
chat_mod.chat_structured(
|
||||
base_url="http://localhost:11434/v1",
|
||||
model="llama3",
|
||||
messages=_messages(),
|
||||
schema=_Widget,
|
||||
max_retries=999, # clamped to 5 -> 6 total attempts
|
||||
)
|
||||
)
|
||||
assert len(fake.calls) == 6
|
||||
|
||||
|
||||
def test_chat_structured_forwards_options_as_extra_body(monkeypatch):
|
||||
"""Regression guard: options (Ollama-native sampling params set via the
|
||||
OllamaOption* nodes — temperature, seed, num_predict, repeat_penalty,
|
||||
etc.) must reach the request, not be silently dropped in structured
|
||||
mode. Forwarded verbatim via pydantic-ai's model_settings.extra_body,
|
||||
matching the pre-ADR-007 payload shape exactly (no lossy remapping onto
|
||||
ModelSettings' own standardized field names)."""
|
||||
fake = _FakeAgent([_Widget(name="a", count=1)])
|
||||
monkeypatch.setattr(chat_mod, "_build_agent", lambda **kw: fake)
|
||||
|
||||
_run_async(
|
||||
chat_mod.chat_structured(
|
||||
base_url="http://localhost:11434/v1",
|
||||
model="llama3",
|
||||
messages=_messages(),
|
||||
schema=_Widget,
|
||||
options={"temperature": 0.0, "seed": 42, "num_predict": 128},
|
||||
)
|
||||
)
|
||||
|
||||
assert fake.calls[0][2] == {
|
||||
"extra_body": {"options": {"temperature": 0.0, "seed": 42, "num_predict": 128}}
|
||||
}
|
||||
|
||||
|
||||
def test_chat_structured_no_options_means_no_model_settings(monkeypatch):
|
||||
fake = _FakeAgent([_Widget(name="a", count=1)])
|
||||
monkeypatch.setattr(chat_mod, "_build_agent", lambda **kw: fake)
|
||||
|
||||
_run_async(
|
||||
chat_mod.chat_structured(
|
||||
base_url="http://localhost:11434/v1",
|
||||
model="llama3",
|
||||
messages=_messages(),
|
||||
schema=_Widget,
|
||||
)
|
||||
)
|
||||
|
||||
assert fake.calls[0][2] is None
|
||||
|
||||
|
||||
def test_chat_structured_requires_last_message_user_role():
|
||||
with pytest.raises(ValueError, match="role='user'"):
|
||||
_run_async(
|
||||
chat_mod.chat_structured(
|
||||
base_url="http://localhost:11434/v1",
|
||||
model="llama3",
|
||||
messages=[Message(role="system", content="only a system message")],
|
||||
schema=_Widget,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_history_to_messages_preserves_order_and_roles():
|
||||
from pydantic_ai.messages import ModelRequest, ModelResponse
|
||||
|
||||
msgs = _messages(
|
||||
system="be terse",
|
||||
history=[("user", "first"), ("assistant", "reply")],
|
||||
prompt="second",
|
||||
)
|
||||
history = chat_mod._history_to_messages(msgs)
|
||||
|
||||
# system, user(first), assistant(reply) — "second" is excluded (it's the
|
||||
# current turn, passed separately as Agent.run()'s user_prompt).
|
||||
assert len(history) == 3
|
||||
assert isinstance(history[0], ModelRequest) # system
|
||||
assert isinstance(history[1], ModelRequest) # user
|
||||
assert isinstance(history[2], ModelResponse) # assistant
|
||||
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
Tests for comfydv._llm — shared LLMProvider protocol and OllamaProvider.
|
||||
|
||||
Test layers:
|
||||
Unit (no marker) — pure Python, no live services
|
||||
Integration (-m integration) — requires live Ollama at localhost:11434
|
||||
|
||||
BDD coverage:
|
||||
../specs/007-llm-provider-abstraction/features/us1_connect_and_chat.feature
|
||||
../specs/007-llm-provider-abstraction/features/us2_structured_output.feature
|
||||
../specs/007-llm-provider-abstraction/features/us3_model_lifecycle.feature
|
||||
"""
|
||||
|
||||
from comfydv._llm.ollama_provider import OllamaProvider
|
||||
from comfydv._llm.provider import Message, ModelInfo, ModelStatus
|
||||
|
||||
|
||||
def test_ollama_provider_captures_connection_state():
|
||||
provider = OllamaProvider("http://localhost:11434", headers={"X-Test": "1"})
|
||||
assert provider.host == "http://localhost:11434"
|
||||
assert provider.headers == {"X-Test": "1"}
|
||||
|
||||
|
||||
def test_ollama_provider_headers_default_to_none():
|
||||
provider = OllamaProvider("http://localhost:11434")
|
||||
assert provider.headers is None
|
||||
|
||||
|
||||
def test_model_status_values():
|
||||
assert ModelStatus.LOADED == "loaded"
|
||||
assert ModelStatus.SLEEPING == "sleeping"
|
||||
assert ModelStatus.DOWNLOADING == "downloading"
|
||||
|
||||
|
||||
def test_model_info_optional_size():
|
||||
info = ModelInfo(name="llama3", status=ModelStatus.UNLOADED)
|
||||
assert info.size is None
|
||||
|
||||
|
||||
def test_message_roles():
|
||||
Message(role="system", content="be terse")
|
||||
Message(role="user", content="hi")
|
||||
Message(role="assistant", content="hello")
|
||||
+306
-1123
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,328 @@
|
||||
"""
|
||||
Tests for comfydv._llm.ollama_provider.OllamaProvider — the Ollama-specific
|
||||
LLMProvider implementation. Mocks at the module's own _post_json/_get_json
|
||||
seam, mirroring test_ollama.py's established convention.
|
||||
|
||||
Node-contract/delegation tests (does ChatCompletion call client.chat(...)
|
||||
correctly) live in tests/test_ollama.py against a _FakeProvider double —
|
||||
this file only tests OllamaProvider's actual Ollama-wire-protocol behavior.
|
||||
|
||||
BDD coverage:
|
||||
../specs/007-llm-provider-abstraction/features/us1_connect_and_chat.feature
|
||||
../specs/007-llm-provider-abstraction/features/us3_model_lifecycle.feature
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
import comfydv._llm.ollama_provider as provider_mod
|
||||
from comfydv._llm.ollama_provider import OllamaProvider, _run_async
|
||||
from comfydv._llm.provider import Message, ModelStatus
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_provider_caches():
|
||||
provider_mod._MODEL_LIST_CACHE.clear()
|
||||
provider_mod._CHAT_RESPONSE_CACHE.clear()
|
||||
yield
|
||||
provider_mod._MODEL_LIST_CACHE.clear()
|
||||
provider_mod._CHAT_RESPONSE_CACHE.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_models_marks_running_models_loaded(monkeypatch):
|
||||
async def fake_get(url, *, timeout=5.0, headers=None):
|
||||
if url.endswith("/api/tags"):
|
||||
return {
|
||||
"models": [
|
||||
{"name": "a:latest", "size": 100},
|
||||
{"name": "b:latest", "size": 200},
|
||||
]
|
||||
}
|
||||
assert url.endswith("/api/ps")
|
||||
return {"models": [{"name": "a:latest"}]}
|
||||
|
||||
monkeypatch.setattr(provider_mod, "_get_json", fake_get)
|
||||
|
||||
models = _run_async(OllamaProvider("http://localhost:11434").list_models())
|
||||
|
||||
by_name = {m.name: m for m in models}
|
||||
assert by_name["a:latest"].status == ModelStatus.LOADED
|
||||
assert by_name["a:latest"].size == 100
|
||||
assert by_name["b:latest"].status == ModelStatus.UNLOADED
|
||||
assert by_name["b:latest"].size == 200
|
||||
|
||||
|
||||
def test_list_models_never_emits_sleeping_or_downloading(monkeypatch):
|
||||
async def fake_get(url, *, timeout=5.0, headers=None):
|
||||
if url.endswith("/api/tags"):
|
||||
return {"models": [{"name": "a:latest"}]}
|
||||
return {"models": []}
|
||||
|
||||
monkeypatch.setattr(provider_mod, "_get_json", fake_get)
|
||||
models = _run_async(OllamaProvider("http://localhost:11434").list_models())
|
||||
|
||||
assert all(m.status in (ModelStatus.LOADED, ModelStatus.UNLOADED) for m in models)
|
||||
|
||||
|
||||
def test_list_models_unreachable_returns_empty(monkeypatch):
|
||||
async def fake_get(url, *, timeout=5.0, headers=None):
|
||||
raise ConnectionError("no route to host")
|
||||
|
||||
monkeypatch.setattr(provider_mod, "_get_json", fake_get)
|
||||
models = _run_async(OllamaProvider("http://localhost:19999").list_models())
|
||||
|
||||
assert models == []
|
||||
|
||||
|
||||
def test_list_models_cached_second_call(monkeypatch):
|
||||
calls = {"n": 0}
|
||||
|
||||
async def fake_get(url, *, timeout=5.0, headers=None):
|
||||
calls["n"] += 1
|
||||
if url.endswith("/api/tags"):
|
||||
return {"models": [{"name": "a:latest"}]}
|
||||
return {"models": []}
|
||||
|
||||
monkeypatch.setattr(provider_mod, "_get_json", fake_get)
|
||||
provider = OllamaProvider("http://localhost:11434")
|
||||
_run_async(provider.list_models())
|
||||
calls_after_first = calls["n"]
|
||||
_run_async(provider.list_models())
|
||||
|
||||
assert calls["n"] == calls_after_first # second call served from cache
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_model / unload_model
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_load_model_uses_api_generate_keep_alive_negative_one(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_post(url, payload, *, timeout=120.0, headers=None):
|
||||
captured["url"] = url
|
||||
captured["payload"] = payload
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(provider_mod, "_post_json", fake_post)
|
||||
_run_async(OllamaProvider("http://localhost:11434").load_model("llama3"))
|
||||
|
||||
assert captured["url"].endswith("/api/generate")
|
||||
assert captured["payload"]["keep_alive"] == -1
|
||||
assert isinstance(captured["payload"]["keep_alive"], int)
|
||||
|
||||
|
||||
def test_unload_model_uses_api_generate_keep_alive_zero(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_post(url, payload, *, timeout=120.0, headers=None):
|
||||
captured["url"] = url
|
||||
captured["payload"] = payload
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(provider_mod, "_post_json", fake_post)
|
||||
_run_async(OllamaProvider("http://localhost:11434").unload_model("llama3"))
|
||||
|
||||
assert captured["url"].endswith("/api/generate")
|
||||
assert captured["payload"]["keep_alive"] == 0
|
||||
assert isinstance(captured["payload"]["keep_alive"], int)
|
||||
|
||||
|
||||
def test_load_model_empty_raises_before_network(monkeypatch):
|
||||
def fail_post(*a, **k):
|
||||
raise AssertionError("must not call _post_json for an empty model name")
|
||||
|
||||
monkeypatch.setattr(provider_mod, "_post_json", fail_post)
|
||||
with pytest.raises(ValueError, match="cannot be empty"):
|
||||
_run_async(OllamaProvider("http://localhost:11434").load_model(""))
|
||||
|
||||
|
||||
def test_unload_model_empty_raises_before_network(monkeypatch):
|
||||
def fail_post(*a, **k):
|
||||
raise AssertionError("must not call _post_json for an empty model name")
|
||||
|
||||
monkeypatch.setattr(provider_mod, "_post_json", fail_post)
|
||||
with pytest.raises(ValueError, match="cannot be empty"):
|
||||
_run_async(OllamaProvider("http://localhost:11434").unload_model(" "))
|
||||
|
||||
|
||||
def test_load_model_forwards_headers(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_post(url, payload, *, timeout=120.0, headers=None):
|
||||
captured["headers"] = headers
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(provider_mod, "_post_json", fake_post)
|
||||
provider = OllamaProvider(
|
||||
"http://localhost:11434", headers={"Authorization": "Bearer x"}
|
||||
)
|
||||
_run_async(provider.load_model("llama3"))
|
||||
|
||||
assert captured["headers"] == {"Authorization": "Bearer x"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# chat
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_chat_uses_api_chat_and_returns_content(monkeypatch):
|
||||
async def fake_post(url, payload, *, timeout=120.0, headers=None):
|
||||
assert url.endswith("/api/chat")
|
||||
return {"message": {"content": "hello there"}}
|
||||
|
||||
monkeypatch.setattr(provider_mod, "_post_json", fake_post)
|
||||
result = _run_async(
|
||||
OllamaProvider("http://localhost:11434").chat(
|
||||
"llama3", [Message(role="user", content="hi")]
|
||||
)
|
||||
)
|
||||
|
||||
assert result == "hello there"
|
||||
|
||||
|
||||
def test_chat_second_identical_call_is_cached(monkeypatch):
|
||||
calls = {"n": 0}
|
||||
|
||||
async def fake_post(url, payload, *, timeout=120.0, headers=None):
|
||||
calls["n"] += 1
|
||||
return {"message": {"content": "cached response"}}
|
||||
|
||||
monkeypatch.setattr(provider_mod, "_post_json", fake_post)
|
||||
provider = OllamaProvider("http://localhost:11434")
|
||||
messages = [Message(role="user", content="hi")]
|
||||
|
||||
r1 = _run_async(provider.chat("llama3", messages))
|
||||
r2 = _run_async(provider.chat("llama3", messages))
|
||||
|
||||
assert r1 == r2 == "cached response"
|
||||
assert calls["n"] == 1
|
||||
|
||||
|
||||
def test_chat_different_client_headers_not_cached(monkeypatch):
|
||||
calls = {"n": 0}
|
||||
|
||||
async def fake_post(url, payload, *, timeout=120.0, headers=None):
|
||||
calls["n"] += 1
|
||||
return {"message": {"content": f"response for {headers}"}}
|
||||
|
||||
monkeypatch.setattr(provider_mod, "_post_json", fake_post)
|
||||
messages = [Message(role="user", content="hi")]
|
||||
|
||||
_run_async(OllamaProvider("http://localhost:11434").chat("llama3", messages))
|
||||
_run_async(
|
||||
OllamaProvider("http://localhost:11434", headers={"X": "1"}).chat(
|
||||
"llama3", messages
|
||||
)
|
||||
)
|
||||
|
||||
assert calls["n"] == 2
|
||||
|
||||
|
||||
def test_chat_timeout_forwarded(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
async def fake_post(url, payload, *, timeout=120.0, headers=None):
|
||||
captured["timeout"] = timeout
|
||||
return {"message": {"content": "ok"}}
|
||||
|
||||
monkeypatch.setattr(provider_mod, "_post_json", fake_post)
|
||||
_run_async(
|
||||
OllamaProvider("http://localhost:11434").chat(
|
||||
"llama3", [Message(role="user", content="hi")], timeout_secs=600.0
|
||||
)
|
||||
)
|
||||
|
||||
assert captured["timeout"] == 600.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# chat_structured
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_chat_structured_builds_v1_base_url_and_delegates(monkeypatch):
|
||||
from pydantic import BaseModel
|
||||
|
||||
class Widget(BaseModel):
|
||||
name: str
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_chat_structured(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return Widget(name="x")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"comfydv._llm.chat.chat_structured",
|
||||
fake_chat_structured,
|
||||
)
|
||||
|
||||
result = _run_async(
|
||||
OllamaProvider("http://localhost:11434").chat_structured(
|
||||
"llama3", [Message(role="user", content="hi")], Widget
|
||||
)
|
||||
)
|
||||
|
||||
assert result == Widget(name="x")
|
||||
assert captured["base_url"] == "http://localhost:11434/v1"
|
||||
assert captured["model"] == "llama3"
|
||||
|
||||
|
||||
def test_chat_structured_forwards_options(monkeypatch):
|
||||
"""Regression guard: options must reach the shared chat_structured()
|
||||
helper, not just the cache key — see specs/007-llm-provider-abstraction
|
||||
beacon-reviewer finding."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
class Widget(BaseModel):
|
||||
name: str
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_chat_structured(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return Widget(name="x")
|
||||
|
||||
monkeypatch.setattr("comfydv._llm.chat.chat_structured", fake_chat_structured)
|
||||
|
||||
_run_async(
|
||||
OllamaProvider("http://localhost:11434").chat_structured(
|
||||
"llama3",
|
||||
[Message(role="user", content="hi")],
|
||||
Widget,
|
||||
options={"temperature": 0.0, "seed": 42},
|
||||
)
|
||||
)
|
||||
|
||||
assert captured["options"] == {"temperature": 0.0, "seed": 42}
|
||||
|
||||
|
||||
def test_chat_structured_caches_after_successful_validation(monkeypatch):
|
||||
from pydantic import BaseModel
|
||||
|
||||
class Widget(BaseModel):
|
||||
name: str
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
async def fake_chat_structured(**kwargs):
|
||||
calls["n"] += 1
|
||||
return Widget(name="cached")
|
||||
|
||||
monkeypatch.setattr("comfydv._llm.chat.chat_structured", fake_chat_structured)
|
||||
|
||||
provider = OllamaProvider("http://localhost:11434")
|
||||
messages = [Message(role="user", content="hi")]
|
||||
|
||||
r1 = _run_async(provider.chat_structured("llama3", messages, Widget))
|
||||
r2 = _run_async(provider.chat_structured("llama3", messages, Widget))
|
||||
|
||||
assert r1 == r2 == Widget(name="cached")
|
||||
assert calls["n"] == 1
|
||||
@@ -52,7 +52,7 @@ class TestRequirementsTxt:
|
||||
def test_requirements_txt_packages_in_pyproject_dependencies(self):
|
||||
req_lines = _requirements_lines()
|
||||
runtime_deps = [
|
||||
d.split(">=")[0].split("==")[0].split("!=")[0].lower()
|
||||
d.split(">=")[0].split("==")[0].split("!=")[0].split("[")[0].lower()
|
||||
for d in _pyproject_runtime_deps()
|
||||
]
|
||||
for line in req_lines:
|
||||
@@ -88,10 +88,10 @@ EXPECTED_NODENAMES = {
|
||||
"Circuit Breaker",
|
||||
# Ollama integration nodes (spec 006)
|
||||
"Ollama Client",
|
||||
"Ollama Model Selector",
|
||||
"Ollama Load Model",
|
||||
"Ollama Unload Model",
|
||||
"Ollama Chat Completion",
|
||||
"LLM Model Selector",
|
||||
"LLM Load Model",
|
||||
"LLM Unload Model",
|
||||
"Chat Completion",
|
||||
"Ollama Option — Temperature",
|
||||
"Ollama Option — Seed",
|
||||
"Ollama Option — Max Tokens",
|
||||
|
||||
@@ -139,6 +139,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.14.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "25.4.0"
|
||||
@@ -255,6 +268,7 @@ dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-ai-slim", extra = ["openai"] },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
@@ -282,6 +296,7 @@ requires-dist = [
|
||||
{ name = "aiohttp", specifier = ">=3.9.0" },
|
||||
{ name = "jinja2", specifier = ">=3.1.6" },
|
||||
{ name = "pydantic", specifier = ">=2.0" },
|
||||
{ name = "pydantic-ai-slim", extras = ["openai"], specifier = ">=2.9.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
@@ -396,6 +411,15 @@ toml = [
|
||||
{ name = "tomli", marker = "python_full_version <= '3.11'" },
|
||||
]
|
||||
|
||||
[[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 = "filelock"
|
||||
version = "3.20.0"
|
||||
@@ -519,6 +543,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/02/a6b21098b1d5d6249b7c5ab69dde30108a71e4e819d4a9778f1de1d5b70d/fsspec-2025.10.0-py3-none-any.whl", hash = "sha256:7c7712353ae7d875407f97715f0e1ffcc21e33d5b24556cb1e090ae9409ec61d", size = 200966, upload-time = "2025-10-30T14:58:42.53Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "genai-prices"
|
||||
version = "0.0.71"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx2" },
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/e4/5072862613fba039da2b7c981a8649c6c6bbcb2863bd8bc81617c09ce5ee/genai_prices-0.0.71.tar.gz", hash = "sha256:de4db34ec38404f9ef383cb1ab29e204d16ccf27071af0b16d5747ee7affe36b", size = 82105, upload-time = "2026-07-10T00:38:30.491Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/98/c06c1318f6834a26268a2d4280e4183f60c5ea92152aea841feff29826ff/genai_prices-0.0.71-py3-none-any.whl", hash = "sha256:1d13111563af2b1ce43ccfacf77b7ac3216ad704c644408a56e11b181fe0d128", size = 84586, upload-time = "2026-07-10T00:38:29.252Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ghp-import"
|
||||
version = "2.1.0"
|
||||
@@ -620,6 +657,80 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/11/46f2e6370ecf8e9e3ab01ad422fb9ea5b90579bdc4b1a8527aa745b20758/griffe-1.6.3-py3-none-any.whl", hash = "sha256:7a0c559f10d8a9016f4d0b4ceaacc087e31e2370cb1aa9a59006a30d5a279fb3", size = 128922, upload-time = "2025-03-26T12:47:48.954Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "griffelib"
|
||||
version = "2.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/33/e4/8d187ea29c2e30b3a09505c567513077d6117861bde1fbd997a167f262ec/griffelib-2.1.0.tar.gz", hash = "sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813", size = 216234, upload-time = "2026-06-19T12:05:42.278Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore2"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "h11" },
|
||||
{ name = "truststore" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/34/18f1c596e677962f040284246f393b10a1f8ce440b3a7e69c637d0f1c7ad/httpcore2-2.3.0.tar.gz", hash = "sha256:07327e251560960eea8e969d92d4c6a325feb13cca39e25340731336c3baf924", size = 64300, upload-time = "2026-06-01T13:15:02.998Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/dd/3357218c69360d1cecc196c230c9a1d5c9afd5dba362056e23e60a5e64e5/httpcore2-2.3.0-py3-none-any.whl", hash = "sha256:477e9e334f74e5240dcac002e890580f36a57d40ff0fb14cc9655731d23b8415", size = 80024, upload-time = "2026-06-01T13:15:00.001Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx2"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "httpcore2" },
|
||||
{ name = "idna" },
|
||||
{ name = "truststore" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/9a/cca0b9145f13d8ae34b885ae28d403a1469a433abc78e0f94f4ce94e650b/httpx2-2.3.0.tar.gz", hash = "sha256:227e7c41d95a76d4077a52640564132777215fc3394e07b66a3116c33d668fa9", size = 81115, upload-time = "2026-06-01T13:15:04.324Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/87/ce/ae2911859847f9ba1d6b23027e53481cbeb50b93234f355a968d300ca2cb/httpx2-2.3.0-py3-none-any.whl", hash = "sha256:6f393663bdf6dbe7fe90118e3eb5b2bd024a675cae0390ac08cec9198812d8b7", size = 74538, upload-time = "2026-06-01T13:15:01.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.10"
|
||||
@@ -671,6 +782,101 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jiter"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c", size = 176431, upload-time = "2026-06-29T13:05:13.657Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/3f/fae6cc967d120ec89e31c5418a51176d8278b3087fbb384a9176754f353c/jiter-0.16.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:67fddeda1688f0cce2d2ae83ccf8a80f79936f2d2997d6cc2261f82fdb54a4d3", size = 309289, upload-time = "2026-06-29T13:02:52.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/e3/97c6c3562c077f6247d6e6ce5c82562500b6316c0d928e97e106b7a1321a/jiter-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c90c0f63df322be920eda6ce622e3083d8906ba267f8220fe7873213b8b4430e", size = 315181, upload-time = "2026-06-29T13:02:53.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/89/d8d073f8aa2667e46c6c0873f86fe4a512bba4293cc730f626a076211a62/jiter-0.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64c0203212098470032aabcde9356fc168f377aade3e43def61dfe17e92f2037", size = 340939, upload-time = "2026-06-29T13:02:55.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/c9/db4fda3ed73fb864139305e935e5b8b38a5a24692a5a9dd356c22f1b9c8d/jiter-0.16.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12288303c9844e61e1651d02a9a6f6633e47d39f897d6991d1427161ce6b746e", size = 364932, upload-time = "2026-06-29T13:02:57.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/74/52b5e86241057f52ddd7c9a580f90effb51f9d06239f6fc612279b91a838/jiter-0.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cf109d010b4b05a105afb3d43be36a21322d345ad3111e13d15f680afef0e5b", size = 461132, upload-time = "2026-06-29T13:02:58.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/87/544a700f7447c1f31c5d7833821a4daa5683165c2d5a094fbf5b5800c3dc/jiter-0.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62c1b7fe1f77925acf5af68b6140b8810fa87dfd4dc0a9c8568ec2fa2a10429c", size = 374857, upload-time = "2026-06-29T13:03:00.455Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/cd/0fcc3f7d39183674d5bfa9ec640faaeb506c60be7c8f94625dfba366e37c/jiter-0.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8597d23c87f59294f83bcb6229b9ed1fccee13dbba967b46930d2f1759466fee", size = 347053, upload-time = "2026-06-29T13:03:02.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/ae/c7e64e7932ad597fa395b61440b249ada6366716e25c6e08dd2afbd021e6/jiter-0.16.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:3126a5dbad56401989ac769aca0cb56005bfb3e2366eea0ca99d1a91c3c1ee03", size = 356153, upload-time = "2026-06-29T13:03:03.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/1c/1c719044f14da814e1a060191ab19b96f3e99207bc5b4bfc6d6be34b3f80/jiter-0.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c4b4717bdb35ae456f831a6b08d01880fff399887a6bbc526a583a406e484eea", size = 393956, upload-time = "2026-06-29T13:03:05.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/dc/7b2f303a2847207e265503853a2d964a55354cffd62a5f2936c155486798/jiter-0.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:adff21bc78edfe086c15eb495b900306076de378dc2337c132401fc39bd79c91", size = 521081, upload-time = "2026-06-29T13:03:06.886Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/5f/501cf6e1e09caeb420195179ffc6f62aca603f1220ec53fd80d0d70b3e56/jiter-0.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dab907db06fc593645e73109acf4581ba5b548897d28b9348dc41ddc8343b2d3", size = 552085, upload-time = "2026-06-29T13:03:08.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/54/aa5be86520113b79455c3877f3d1f07a348098df4083ba3688e9537e52dd/jiter-0.16.0-cp311-cp311-win32.whl", hash = "sha256:560b2cf3fb03240cd34f27409a238547488708f05b7c3924f571a60422251ec7", size = 206755, upload-time = "2026-06-29T13:03:09.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/ec/2feb893eb330bd69b413866f4d5daada33c3962f1c6f270c91ca2d87fdf9/jiter-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:e431cfc9caf44c1d5459ff77d4e64cbf85fddb6a35dad836a15c6a9ec23087c1", size = 199155, upload-time = "2026-06-29T13:03:10.979Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/9c/ca040d94415048a3666fc237774df8151c96f8d2b661cbe3b184acc95876/jiter-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:2a8e9e39cf083016137aa5cadafe3188adc2ba6ba1fbf1e5d18889ad3e9ad056", size = 194403, upload-time = "2026-06-29T13:03:12.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/2b/52ace16ed031354f0539749a49e4bf33797d82bea5137910835fa4b09793/jiter-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:67c3bc1760f8c99d805dcab4e644027142a53b1d5d861f18780ebdbd5d40b72a", size = 306943, upload-time = "2026-06-29T13:03:14.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/2e/34957c2c1b661c252ba9bcc60ae0bddc27e0f7202c6073326a13c5390eec/jiter-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5af7780e4a26bd7d0d989592bf9ef12ebf806b74ab709223ecca37c749872ea9", size = 307779, upload-time = "2026-06-29T13:03:15.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/6c/59bd309cab4460c54cf1079f3eb7fe7af6a4c895c5c957a53378693bad2b/jiter-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5bf78d0e05e45cfdd66558893938d59afe3d1b1a824a202039b20e607d25a72", size = 335826, upload-time = "2026-06-29T13:03:17.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/8c/f5ef7b65f0df47afa16596969defb281ebb86e96df346d62be6fd853d620/jiter-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e", size = 362573, upload-time = "2026-06-29T13:03:18.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/0b/ace4354da061ee38844a0c27dc2c21eecd27aea119e8da324bea987522d0/jiter-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a23f0e4f957e1be65752d2dfac9a5a06b1917af8dc85deb639c3b9d02e31290", size = 457979, upload-time = "2026-06-29T13:03:20.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/40/c0253d3772eb9dcd8e6606ee9b2d53ec8e5b814589c47f140aa585f21eaa/jiter-0.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c22a488f7b9218e245a0025a9ba6b100e2e54700831cf4cf16833a27fba3ad01", size = 372302, upload-time = "2026-06-29T13:03:21.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/d2/4839422241aa12860ce597b20068727094ba0bc480723c74924ca5bad483/jiter-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46add52f4ad47a08bfb1219f3e673da972191489a33016edefdb5ea55bfa8c48", size = 343805, upload-time = "2026-06-29T13:03:23.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/59/e196888a05befdda7dbe299b722d56f2f6eec65402bc34c0a3306d595feb/jiter-0.16.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9c8a956fd72c2cf1e730d01ea080341f13aa0a97a4a33b51abebe725b7ae9ca9", size = 351107, upload-time = "2026-06-29T13:03:24.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/74/4cd9e0fca65232136400354b630fbfcd2de634e22ccbb96567725981b548/jiter-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:561926e0573ffe4a32498420a76d64b16c513e1ab413b9d28158a8764ac701e5", size = 388441, upload-time = "2026-06-29T13:03:26.266Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/8c/554691e48bc711299c0a293dd8a6179e24b2d66a54dc295421fcf64569c0/jiter-0.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:44d019fa8cdaf89bf29c71b39e3712143fdd0ac76725c6ef954f9957a5ea8730", size = 516354, upload-time = "2026-06-29T13:03:28.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/cb/01e9d69dc2cc6759d4f91e230b34489c4fdb2518992650633f9e20bece89/jiter-0.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0df91907609837f33341b8e6fe73b95991fdaa57caf1a0fbd343dffe826f386f", size = 547880, upload-time = "2026-06-29T13:03:29.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/70/2953195f1c6ad00f49fa67e13df7e60acb3dd4f387101bc15abccddd905e/jiter-0.16.0-cp312-cp312-win32.whl", hash = "sha256:51d7b836acb0108d7c77df1742332cac2a1fa04a74d6dacec46e7091f0e91274", size = 203473, upload-time = "2026-06-29T13:03:31.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/05/2909a8b10699a4d560f8c502b6b2c5f3991b682b1922c1eedda242b225bd/jiter-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:1878349266f8ee36ecb1375cc5ba2f115f35fd9f0a1a4119e725e379126647f7", size = 196905, upload-time = "2026-06-29T13:03:32.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/a9/6b82bb1c8d7790d602489b967b982a909e5d092875a6c2ade96444c8dfc5/jiter-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:2ed5738ae4af18271a51a528b8811b0cbfa4a1858de9d83359e4169855d6a331", size = 190618, upload-time = "2026-06-29T13:03:34.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/c0/555fc60473d30d66894ba825e63615e3be7524fac23858356afa7a38906c/jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195", size = 306203, upload-time = "2026-06-29T13:03:36.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/2b/c3eaf16f5d7c9bad66ea32f40a95bd169b29a91217fcc7f081375157e99c/jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09", size = 306489, upload-time = "2026-06-29T13:03:37.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/3f/02fdfc6705cad96127d883af5c34e4867f554f29ec7705ec1a46156400a9/jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536", size = 335453, upload-time = "2026-06-29T13:03:39.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/a6/e4bda5920d4b0d7c5dfb7174ce4a6b2e4d3e11c9162c452ef0eab4cdbdbd/jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450", size = 361625, upload-time = "2026-06-29T13:03:40.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/97/4e6b59b2c6e55cbb3e183595f81ad65dcfb21c915fee5e19e335df21bc55/jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e", size = 456958, upload-time = "2026-06-29T13:03:42.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/e0/97e9557686d2f94f4b93786eccb7eed28e9228ad132ea8237f44727314a7/jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8", size = 372017, upload-time = "2026-06-29T13:03:43.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/94/db768b6938e0df35c86beeba3dfbbb025c9ee5c19e1aa271f2396e50864d/jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026", size = 343320, upload-time = "2026-06-29T13:03:45.226Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/d6/5a59d938244a30735fe62d9433fd325f9021ea29d89780ea4596ea93bc89/jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053", size = 350520, upload-time = "2026-06-29T13:03:46.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/f8/c4a857f49c9af125f6bbcac7e3eee7f7978ed89682833062e2dbf62576b1/jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e", size = 387550, upload-time = "2026-06-29T13:03:48.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/d6/5fbc2f7d6b67b754caa61a993a2e626e815dec47ffc2f9e35f01adfebec7/jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb", size = 515424, upload-time = "2026-06-29T13:03:49.881Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/54/284f0164b64a5fed915fea6ba7e9ba9b3d8d37c67d59cf2e3bb99d45cdfe/jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84", size = 546981, upload-time = "2026-06-29T13:03:51.363Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/c5/2a467585a576594384e1d2c43e1224deaafc085f24e243529cf98beef8e1/jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e", size = 202853, upload-time = "2026-06-29T13:03:53.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/6a/de61d04b9eec69c71719968d2f716532a3bc121170c44a39e14979c6be81/jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd", size = 196160, upload-time = "2026-06-29T13:03:54.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a", size = 189862, upload-time = "2026-06-29T13:03:55.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/89/bc4f1b57d5da938fd344a466396541e586d161320d70bffd929aaafcd8f4/jiter-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee", size = 308239, upload-time = "2026-06-29T13:03:57.205Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/7a/c415453e5213001bf3b411ff65dec3d303b0e76a4a2cfea9768cd4960994/jiter-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2", size = 308928, upload-time = "2026-06-29T13:03:58.643Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/fc/1f4fb7ebf9a724c7741994f4aae18fba1e2f3133df14521a79194952c34a/jiter-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883", size = 336998, upload-time = "2026-06-29T13:04:00.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/8d/72cadaac05ccfa7cc3a0a2232862e6c72443ca40cf300ba8b57f9f18b69b/jiter-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898", size = 362112, upload-time = "2026-06-29T13:04:01.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/4a/c4b0d5f651fda90a24ffce9f8d56cde462a2e09d31ae3de3c68cef34c04e/jiter-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5", size = 459807, upload-time = "2026-06-29T13:04:03.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/58/ef77879ea9aa56b50824edc5a445e226422c7a8d211f3fd2a56bcb9493cf/jiter-0.16.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567", size = 373181, upload-time = "2026-06-29T13:04:04.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/2e/ffbc3f254e4d8a66da3062c624a7df4b7c2b2cf9e1fe43cf394b3e104041/jiter-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69", size = 344927, upload-time = "2026-06-29T13:04:06.067Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/f6/0be5dc6d64a89f80aa8fec984f94dedb2973e251edcae55841d60786d578/jiter-0.16.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29", size = 352754, upload-time = "2026-06-29T13:04:07.477Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/6e/7d31243b3b91cd261dd19e9d3557fc3251a80883d3d8049c86174e7ab7af/jiter-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93", size = 390553, upload-time = "2026-06-29T13:04:08.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/33/51ae371fde3c88897520f62b4d5f8b27ad7103e2bb10812ff52195609853/jiter-0.16.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a", size = 516900, upload-time = "2026-06-29T13:04:10.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/45/6449b3d123ea439ba79507c657288f461d55049e7bcbdc2cf8eb8210f491/jiter-0.16.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00", size = 548754, upload-time = "2026-06-29T13:04:12.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/e7/fd2fb11ae3e2649333da3aa170d04d7b3000bbdc3b270f6513382fdf4e04/jiter-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe", size = 122381, upload-time = "2026-06-29T13:04:13.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/80/f0b147a62c315a164ed2168908286ca302310824c218d3aae52b06c0c9a9/jiter-0.16.0-cp314-cp314-win32.whl", hash = "sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106", size = 204578, upload-time = "2026-06-29T13:04:14.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/e6/4758a14304b4523a6f5adb2419340086aa3593bd4327c2b25b5948a90548/jiter-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8", size = 198154, upload-time = "2026-06-29T13:04:16.272Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/be/41fa54a2e7ea41d6c99f1dc5b1f0fd4cb474680304b5d268dd518e81da3a/jiter-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585", size = 191458, upload-time = "2026-06-29T13:04:17.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/6b/59127338b86d9fe4d99418f5a15118bea778103ee0fe9d9dd7e0af174e95/jiter-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207", size = 316739, upload-time = "2026-06-29T13:04:19.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/95/49461034d5388196d3dabf98748935f017b7785d8f3f5349f834bcc4ed0d/jiter-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818", size = 340911, upload-time = "2026-06-29T13:04:21.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/97/a4369f2fb82cb3dda13b98622f31249b2e014b223fe64ee534413ad72294/jiter-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3", size = 361747, upload-time = "2026-06-29T13:04:22.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/51/49b6ed456261646e1906016a6760367a28aacd3c24805e4e5fe64116c1db/jiter-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284", size = 460225, upload-time = "2026-06-29T13:04:24.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/b5/5689aff4f66c5b60be63106e591dbfcba2190df97d2c9c7cf052361ddb98/jiter-0.16.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929", size = 373169, upload-time = "2026-06-29T13:04:25.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/96/3ae1b85ee0d6d6cab254fb7f8da018272b932bbf2d69b07e98aa2a96c746/jiter-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620", size = 350332, upload-time = "2026-06-29T13:04:27.302Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/32/c99d7bafd78986556c95bf60ce84c6cc98786eac56066c12d7f828bb6747/jiter-0.16.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af", size = 353377, upload-time = "2026-06-29T13:04:28.731Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/4b/f99a8e571287c3dec766bcc18528bbe8e8fb5365522ab5e6d64c93e87066/jiter-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e", size = 387746, upload-time = "2026-06-29T13:04:30.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/69/c78a5b3f71040e34eb5917df26fb7ae9a2174cad1ccbf277512507c53a6e/jiter-0.16.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077", size = 517292, upload-time = "2026-06-29T13:04:31.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/f7/095b38eda4c70d03651c403f29a5590f16d12ddc5d544aac9f9cddf72277/jiter-0.16.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734", size = 549259, upload-time = "2026-06-29T13:04:33.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/c5/6a0207d90e5f656d95af98ebd0934f382d37674416f215aeda2ff8063e51/jiter-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf", size = 206523, upload-time = "2026-06-29T13:04:35.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/31/c757d5f30a8980fd945ce7b98be10be9e4ff59c7c42f5fd86804c2e87db8/jiter-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db", size = 200366, upload-time = "2026-06-29T13:04:36.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/a2/d88de6d313d734a544a7901353ad5db67cb38dcfcd91713b7979dafc345d/jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce", size = 190516, upload-time = "2026-06-29T13:04:38.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/d3/8e278946d43eeca2585b4dd0834a887cd71136329b837f3a16ed86a8b4b0/jiter-0.16.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:850ccb1d7eedb4200f4014b1c0e8a577de114fc3cd88faad646dcc9bc4bb12ad", size = 304518, upload-time = "2026-06-29T13:05:00.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/43/28d4ef495028bf0506a413d4db3f4eb3e7288a382e0f065f306a17bbeb5e/jiter-0.16.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:e34e97bda77eb63242a410243c071e28ac7e0d8c0948c5ee658498690a4b2f2f", size = 310207, upload-time = "2026-06-29T13:05:02.123Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/ca/c366b1012da1d640de975d9683acd44e4d150d9068845d0ca2610435253f/jiter-0.16.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7dc85ea77d4abbae8bad0d3538678aedee75bceec4e2f6c8dfb1c74772e5aa5", size = 342771, upload-time = "2026-06-29T13:05:03.55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/52/50cc4056fc1ae02e7154704e7ecc89df0afb8300222cfe8a52d3f67e4730/jiter-0.16.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17ca7fae79f6d99cd9a042b75f917eaada7b895cfc7dd2ee3a16089dcaec7a85", size = 346468, upload-time = "2026-06-29T13:05:05.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/ab/664fd8c4be028b2bedd3d2ff08769c4ede23d0dbc87a77c62384a0515b5d/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:f17d61a28b4b3e0e3e2ba98490c70501403b4d196f78732439160e7fd3678127", size = 303106, upload-time = "2026-06-29T13:05:07.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/07/421f1d5b65493a76e16027b848aba6a7d28073ae75944fa4289cc914d39f/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:96e38eea538c8ddf853a35727c7be0741c76c13f04148ac5c116222f50ece3b3", size = 304658, upload-time = "2026-06-29T13:05:08.708Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/db/bba1155f01a01c3c37a89425d571da751bbedf5c54247b831a04cb971798/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d284fb8d94d5855d60c44fefcab4bf966f1da6fada73992b01f6f0c9bc0c6702", size = 339719, upload-time = "2026-06-29T13:05:10.41Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2", size = 343885, upload-time = "2026-06-29T13:05:12.087Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "logfire-api"
|
||||
version = "4.37.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/03/04/471b916249fe7e22056818ca734af46418cd3ff9b9b920c1829c3627b4d2/logfire_api-4.37.0.tar.gz", hash = "sha256:0f62debd6ed593d51307277bd6d5636b57bda07935b5604b96db10fe64441af4", size = 88906, upload-time = "2026-06-12T20:47:08.163Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/2f/23e5b8fa22f75f73965c72e5c29e6fb8715263457394601e254fe26fbe31/logfire_api-4.37.0-py3-none-any.whl", hash = "sha256:1d756f8ba23aa56d438e0ba2c0f529a00fcac975b8785c561b058267f9465088", size = 138710, upload-time = "2026-06-12T20:47:05.526Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markdown"
|
||||
version = "3.7"
|
||||
@@ -1261,6 +1467,37 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openai"
|
||||
version = "2.45.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/78/60/d4219875289b11d2c2f7da93c36283da224a2e55865ed865ab64e0ce9217/openai-2.45.0.tar.gz", hash = "sha256:10d34ca9c5643bce775852fddbfc172505cb1d4de1ccd101696c3ecff358765d", size = 1109653, upload-time = "2026-07-09T18:02:44.091Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/b0/2291689e3ec4723fbf5bbf3b54afcd7b160f9ddc98ca7aedfd0132af5677/openai-2.45.0-py3-none-any.whl", hash = "sha256:5df105f5f8c9b711fcb9d06d2d3888cebc82506db216484c14a4e53cdf651777", size = 1629470, upload-time = "2026-07-09T18:02:42.21Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-api"
|
||||
version = "1.43.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ae/cc/e4c9584181f86494df0f6bdec1a4f3280c50db44704dc2a407e994fc87bb/opentelemetry_api-1.43.0.tar.gz", hash = "sha256:107d0d03857ea8fc7c5fcbbbd83f800c281f0d560553d61c1d675fccfd1761c1", size = 73476, upload-time = "2026-06-24T15:19:55.323Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/17/83/6dba32b85f31868400440dc7ad2ca1eab94cbbf3a7b0459ed39f8311a9e2/opentelemetry_api-1.43.0-py3-none-any.whl", hash = "sha256:20acf45e9b21851926835292e4045d290acade1edd2ff3de86d2f069687ba1fd", size = 61912, upload-time = "2026-06-24T15:19:35.434Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "24.2"
|
||||
@@ -1526,6 +1763,30 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-ai-slim"
|
||||
version = "2.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "genai-prices" },
|
||||
{ name = "griffelib" },
|
||||
{ name = "httpx" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-graph" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/4a/4461ff8c0f05864512ba1c6f3788e01afdf8f8aab73a460dd5b43942fd8b/pydantic_ai_slim-2.9.0.tar.gz", hash = "sha256:11b812833eb75cfcc31c86b6e2db75e28cade6bb746844e28a40eb51b04a09d3", size = 788767, upload-time = "2026-07-11T03:19:29.155Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/80/a9/bea5cb793b4d6ea4d9763189f09c83334ca9d5dea6e4f2a0aa51bc714029/pydantic_ai_slim-2.9.0-py3-none-any.whl", hash = "sha256:560a860cb67cf7732b16fb40e1d9205d75c02934e75e41db7ccb104ebc9ea266", size = 965375, upload-time = "2026-07-11T03:19:21.493Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
openai = [
|
||||
{ name = "openai" },
|
||||
{ name = "tiktoken" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.46.4"
|
||||
@@ -1628,6 +1889,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-graph"
|
||||
version = "2.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "logfire-api" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/23/fb107ccbcba5f96e0afed576def01b5c846ffd665ff9c885dbe77d5a551d/pydantic_graph-2.9.0.tar.gz", hash = "sha256:a6d146cfd97b086e4334afc6717b2d078a3f102631cfe05e421fda2ca5a7046f", size = 43907, upload-time = "2026-07-11T03:19:31.616Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/d0/5070abd3b43f3e6f54019fe9bc225abab9dda922d40dff794f9872e0d507/pydantic_graph-2.9.0-py3-none-any.whl", hash = "sha256:0ee8ad7b69d628037f3194d506b6c7099e416f25e477f9590da110cdaf48c649", size = 51646, upload-time = "2026-07-11T03:19:24.877Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyee"
|
||||
version = "13.0.1"
|
||||
@@ -1760,6 +2036,110 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/66/bbb1dd374f5c870f59c5bb1db0e18cbe7fa739415a24cbd95b2d1f5ae0c4/pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069", size = 3911, upload-time = "2020-11-12T02:38:24.638Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "2026.7.10"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7b/37/451aaddbf50922f34d744ad5ca919ae1fcfac112123885d9728f52a484b3/regex-2026.7.10.tar.gz", hash = "sha256:1050fedf0a8a92e843971120c2f57c3a99bea86c0dfa1d63a9fac053fe54b135", size = 416282, upload-time = "2026-07-10T19:49:46.267Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/16/bfd13770be1acd1c05506b93fc6be15c759d6417595d1ba334d355efbf26/regex-2026.7.10-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:66d2c35587cd601c95965d5c0415058ba5cfd6ffbab7624ce198bd967102b341", size = 494639, upload-time = "2026-07-10T19:46:52.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/b4/0086215709f0f705661f13ba81516287538886ef0d589c545c12b0484669/regex-2026.7.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28a0973eeffff4292f5a7ee498ab65d5e94ee8cc9cea364239251eb4a260a0f1", size = 295920, upload-time = "2026-07-10T19:46:53.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/9e/8e07d0eea46d2cf36bf4d3794634bb0a820f016d31bc349dfef008d96b02/regex-2026.7.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8331484450b3894298bef8abecce532171ff6ac60b71f999eed10f2c01941a8a", size = 290673, upload-time = "2026-07-10T19:46:54.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/56/d83c446de21c70ff49d2f1b2ff2196ac79a4ac6373d2cfe496011a250600/regex-2026.7.10-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0639b2488b775a0109f55a5a2172deebdedb4b6c5ab0d48c90b43cbf5de58d17", size = 792378, upload-time = "2026-07-10T19:46:56.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/0e/d265e0cc6da47aea97e90eb896be2d2e8f92d16add13bac04fa46a0fd972/regex-2026.7.10-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be4223af640d0aa04c05db81d5d96ada3ead9c09187d892fd37f4f97829480be", size = 861790, upload-time = "2026-07-10T19:46:57.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/a5/62655f6208d1170a3e9188d6a45d4af0a5ae3b9da8b87d474818ac5ff016/regex-2026.7.10-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d3c75d57a00109255e60bc9c623b6ececaf7905eaab845c79f036670ed4750a2", size = 906530, upload-time = "2026-07-10T19:46:59.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/b7/d65aa2e9ffb18677cd0afbcf5990da8519a4e50778deb1bca49f043c5174/regex-2026.7.10-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:724ee9379568658ec06362cf24325c5315cc5a67f61dfe585bfeff58300a355b", size = 799912, upload-time = "2026-07-10T19:47:00.534Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/19/3a5ce23ea2eb1fe36306aef49c79746ce297e4b434aeb981b525c661413a/regex-2026.7.10-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:732c19e5828eb287d01edb83b2eb87f283ba8e5fc3441c732709d3e8cbd14aaa", size = 773675, upload-time = "2026-07-10T19:47:01.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/76/3c0eaa426700dd2ba14f2335f2b700a4e1484202254192ae440b83b8352a/regex-2026.7.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:982d07727c809b42a3968785354f11c3728414e4e90af0754345b431b2c32561", size = 781711, upload-time = "2026-07-10T19:47:03.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/a8/a5a3fad84f9a7f897619f0f8e0a2c64946e9709044a186a8f869fb5c332f/regex-2026.7.10-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4574feca202f8c470bf678aed8b5d89df04aaf8dc677f3b83d92825051301c0f", size = 854539, upload-time = "2026-07-10T19:47:04.999Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/c7/47e9b8c8ee77723b9eda74f517b6b25d2f555cf276c063a9eeea35bd86d5/regex-2026.7.10-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:80151ca5bfc6c4524186b3e08b499e97319b2001fc265ed2d4fc12c0d5692cdf", size = 763378, upload-time = "2026-07-10T19:47:06.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/09/e27e42d9d42edf71205c7e6f5b2902bc874ea03c557c80da03b8ed16c9bf/regex-2026.7.10-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bb52e10e453b5493afe1f7702a2973bc10f4dd8901c0f2ed869ffaa3f8319296", size = 844663, upload-time = "2026-07-10T19:47:08.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/b5/2423acb98362184ad9c8eebabafa15188d6a177daab919add8f2120fc6cd/regex-2026.7.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e37aba1994d73b4944053ab65a15f313bd5c28c885dd7f0d494a11749d89db6e", size = 789236, upload-time = "2026-07-10T19:47:10.303Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/ed/b387e84c8a3d6aa115dfb56865437a3fbaf28f4a6fb3b76cc6cce38ced70/regex-2026.7.10-cp311-cp311-win32.whl", hash = "sha256:6cbedeb5112f59dbd169385459b9943310bdd241c6966c19c5f6e2295055c93a", size = 266774, upload-time = "2026-07-10T19:47:11.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/64/f30a163a65ed1f07ad12c53af00a6bd2a7251a5329fba5a08adc6f9e81a3/regex-2026.7.10-cp311-cp311-win_amd64.whl", hash = "sha256:b1963ec5ba4d52788fb0eac6aca6eb8040e8e318c7e47ebbdfc09440c802919c", size = 277959, upload-time = "2026-07-10T19:47:13.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/de/61c8174171134cebb834ca9f8fe2ff8f49d8a3dd43453b48b537d0fbb49b/regex-2026.7.10-cp311-cp311-win_arm64.whl", hash = "sha256:3750c42d47712e362158a04d0fd80131f73a55e8c715b2885442a0ff6f9fc3fc", size = 276918, upload-time = "2026-07-10T19:47:14.693Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/9c/2503d4ccf3452dc323f8baa3cf3ee10406037d52735c76cfced81423f183/regex-2026.7.10-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7252b48b0c60100095088fbeb281fca9a4fcf678a4e04b1c520c3f8613c952c4", size = 497114, upload-time = "2026-07-10T19:47:16.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/eb/04534f4263a4f658cd20a511e9d6124350044f2214eb24fee2db96acf318/regex-2026.7.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:da6ef4cb8d457aab0482b50120136ae94238aaa421863eaa7d599759742c72d6", size = 297422, upload-time = "2026-07-10T19:47:17.794Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/2d/35809de392ab66ba439b58c3187ae3b8b53c883233f284b59961e5725c99/regex-2026.7.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fe7ff456c22725c9d9017f7a2a7df2b51af6df77314176760b22e2d05278e181", size = 292110, upload-time = "2026-07-10T19:47:19.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/1e/5ce0fbe9aab071893ce2b7df020d0f561f7b411ec334124302468d587884/regex-2026.7.10-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3463a5f26be513a49e4d497debcf1b252a2db7b92c77d89621aa90b83d2dd38", size = 796800, upload-time = "2026-07-10T19:47:20.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/67/c1ccbada395c10e334763b583e1039b1660b142303ebb941d4269130b22f/regex-2026.7.10-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:948dfc62683a6947b9b486c4598d8f6e3ecc542478b6767b87d52be68aeb55c6", size = 865509, upload-time = "2026-07-10T19:47:22.135Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/06/f0b31afc16c1208f945b66290eb2a9936ab8becdfb23bbcedb91cc5f9d9b/regex-2026.7.10-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c2cbd385d82f63bb35edb60b09b08abad3619bd0a4a492ae59e55afaf98e1b9d", size = 912395, upload-time = "2026-07-10T19:47:24.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/1c/8687de3a6c3220f4f872a9bf4bcd8dc249f2a96e7dddfa93de8bd4d16399/regex-2026.7.10-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f6222cafe00e072bb2b8f14142cd969637411fbc4dd3b1d73a90a3b817fa046f", size = 801308, upload-time = "2026-07-10T19:47:25.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/e3/60a40ec02a2315d826414a125640aceb6f30450574c530c8f352110ece0e/regex-2026.7.10-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:65ee5d1ac3cd541325f5ac92625b1c1505f4d171520dd931bda7952895c5321a", size = 777120, upload-time = "2026-07-10T19:47:27.158Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/9a/ec579b4f840ac59bc7c192b56e66abd4cbf385615300d59f7c94bf6863ae/regex-2026.7.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aa34473fbcc108fea403074f3f45091461b18b2047d136f16ffaa4c65ad46a68", size = 785164, upload-time = "2026-07-10T19:47:28.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/1c/60d88afd5f98d4b0fb1f8b8969270628140dc01c7ff93a939f2aa83f31a6/regex-2026.7.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d028d189d8f38d7ff292f22187c0df37f2317f554d2ed9a2908ada330af57c0", size = 860161, upload-time = "2026-07-10T19:47:30.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/40/08ae3ba45fe79e48c9a888a3389a7ee7e2d8c580d2d996da5ece02dfdcb9/regex-2026.7.10-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:396ea70e4ea1f19571940add3bad9fd3eb6a19dc610d0d01f692bc1ba0c10cb4", size = 765829, upload-time = "2026-07-10T19:47:32.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/e6/e613c6755d19aca9d977cdc3418a1991ffc8f386779752dd8fdfa888ea89/regex-2026.7.10-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ebbf0d83ed5271991d666e54bb6c90ac2c55fb2ef3a88740c6af85dc85de2402", size = 852170, upload-time = "2026-07-10T19:47:33.567Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/33/89072f2060e6b844b4916d5bc40ef01e973640c703025707869264ec75ab/regex-2026.7.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:58a4571b2a093f6f6ee4fd281faa8ebf645abcf575f758173ea2605c7a1e1ecb", size = 789550, upload-time = "2026-07-10T19:47:35.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/3c/4bc8be9a155035e63780ccac1da101f36194946fdc3f6fce90c7179fc6df/regex-2026.7.10-cp312-cp312-win32.whl", hash = "sha256:eac1207936555aa691ce32df1432b478f2729d54e6d93a1f4db9215bcd8eb47d", size = 267151, upload-time = "2026-07-10T19:47:37.047Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/73/9f5aade65bb98cc6e99c336e45a49a658300720c16721f3e687f8d754fec/regex-2026.7.10-cp312-cp312-win_amd64.whl", hash = "sha256:ecae626449d00db8c08f8f1fc00047a32d6d7eb5402b3976f5c3fda2b80a7a4f", size = 277751, upload-time = "2026-07-10T19:47:38.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/6f/d069dd12872ea1d50e17319d342f89e2072cae4b62f4245009a1108c74d8/regex-2026.7.10-cp312-cp312-win_arm64.whl", hash = "sha256:87794549a3f5c1c2bdfba2380c1bf87b931e375f4133d929da44f95e396bf5fe", size = 277063, upload-time = "2026-07-10T19:47:40.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/88/0c977b9f3ba9b08645516eca236388c340f56f7a87054d41a187a04e134c/regex-2026.7.10-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:4db009b4fc533d79af3e841d6c8538730423f82ea8508e353a3713725de7901c", size = 496868, upload-time = "2026-07-10T19:47:41.675Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/51/600882cd5d9a3cf083fd66a4064f5b7f243ba2a7de2437d42823e286edaf/regex-2026.7.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b96341cb29a3faa5db05aff29c77d141d827414f145330e5d8846892119351c1", size = 297306, upload-time = "2026-07-10T19:47:43.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/6f/48a912054ffcb756e374207bb8f4430c5c3e0ffa9627b3c7b6661844b30a/regex-2026.7.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:14d27f6bd04beb01f6a25a1153d73e58c290fd45d92ba56af1bb44199fd1010d", size = 291950, upload-time = "2026-07-10T19:47:45.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/c8/8e1c3c86ebcee7effccbd1f7fc54fe3af22aa0e9204503e2baea4a6ff001/regex-2026.7.10-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e6b6a11bf898cca3ce7bfaa17b646901107f3975677fbd5097f36e5eb5641983", size = 796817, upload-time = "2026-07-10T19:47:48.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/39/3e49d9ff0e0737eb8180a00569b47aabb59b84611f48392eba4d998d91a0/regex-2026.7.10-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:234f8e0d65cf1df9becadae98648f74030ee85a8f12edcb5eb0f60a22a602197", size = 865513, upload-time = "2026-07-10T19:47:49.855Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/57/6511ad809bb3122c65bbeeffa5b750652bb03d273d29f3acb0754109b183/regex-2026.7.10-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:91b916d495db3e1b473c7c8e68733beec4dce8e487442db61764fff94f59740e", size = 912391, upload-time = "2026-07-10T19:47:51.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/29/a1b0c109c9e878cb04b931bfe4c54332d692b93c322e127b5ae9f25b0d9e/regex-2026.7.10-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f0d4ccf70b1d13711242de0ba78967db5c35d12ac408378c70e06295c3f6644", size = 801338, upload-time = "2026-07-10T19:47:53.38Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/be/171c3dad4d77000e1befeff2883ca88734696dfd97b2951e5e074f32e4dd/regex-2026.7.10-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c622f4c638a725c39abcb2e680b1bd592663c83b672a4ed350a17f806d75618e", size = 777149, upload-time = "2026-07-10T19:47:54.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/61/41ab0de0e4574da1071c151f67d1eb9db3d92c43e31d64d2e6863c3d89bf/regex-2026.7.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:41a47c2b28d9421e2509a4583a22510dc31d83212fcf38e1508a7013140f71a8", size = 785216, upload-time = "2026-07-10T19:47:56.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/28/372859ea693736f07cf7023247c7eca8f221d9c6df8697ff9f93371cca08/regex-2026.7.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:13fba679fe035037e9d5286620f88bbfd105df4d5fcd975942edd282ab986775", size = 860229, upload-time = "2026-07-10T19:47:58.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/b1/e1d32cd944b599534ae655d35e8640d0ec790c0fa12e1fb29bf434d50f55/regex-2026.7.10-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8e26a075fa9945b9e44a3d02cc83d776c3b76bb1ff4b133bbfa620d5650131da", size = 765797, upload-time = "2026-07-10T19:48:00.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/62/79a2cd9556a3329351e370929743ef4f0ccc0aaff6b3dc414ae5fa4a1302/regex-2026.7.10-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d0834c84ae8750ae1c4cede59b0afd4d2f775be958e11b18a3eea24ed9d0d9f1", size = 852130, upload-time = "2026-07-10T19:48:01.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/58/76fec29898cf5d359ab63face50f9d4f7135cc2eca3477139227b1d09952/regex-2026.7.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64722a5031aeace7f6c8d5ea9a9b22d9368af0d6e8fa532585da8158549ea963", size = 789644, upload-time = "2026-07-10T19:48:03.748Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/06/3c7cec7817bda293e13c8f88aed227bbcf8b37e5990936ff6442a8fdf11a/regex-2026.7.10-cp313-cp313-win32.whl", hash = "sha256:74ae61d8573ecd51b5eeee7be2218e4c56e99c14fa8fcf97cf7519611d4be92e", size = 267130, upload-time = "2026-07-10T19:48:05.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/6c/e2a6f9a6a905f923cfc912298a5949737e9504b1ca24f29eda8d04d05ece/regex-2026.7.10-cp313-cp313-win_amd64.whl", hash = "sha256:5e792367e5f9b4ffb8cad93f1beaa91837056b94da98aa5c65a0db0c1b474927", size = 277722, upload-time = "2026-07-10T19:48:07.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/a6/9d8935aaa940c388496aa1a0c82669cc4b5d06291c2712d595e3f0cf16d3/regex-2026.7.10-cp313-cp313-win_arm64.whl", hash = "sha256:82ab8330e7e2e416c2d42fcec67f02c242393b8681014750d4b70b3f158e1f08", size = 277059, upload-time = "2026-07-10T19:48:08.977Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/e9/26decfd3e85c09e42ff7b0d23a6f51085ca4c268db15f084928ca33459c6/regex-2026.7.10-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:2b93eafd92c4128bab2f93500e8912cc9ecb3d3765f6685b902c6820d0909b6b", size = 501508, upload-time = "2026-07-10T19:48:10.668Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/a5/5b167cebde101945690219bf34361481c9f07e858a4f46d9996b80ec1490/regex-2026.7.10-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3f03b92fb6ec739df042e45b06423fc717ecf0063e07ffe2897f7b2d5735e1e8", size = 299705, upload-time = "2026-07-10T19:48:12.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/20/7909be4b9f449f8c282c14b6762d59aa722aeaeebe7ee4f9bb623eeaa5e0/regex-2026.7.10-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:bb5aab464a0c5e03a97abad5bdf54517061ebbf72340d576e99ff661a42575cc", size = 294605, upload-time = "2026-07-10T19:48:14.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/88/e52550185d6fda68f549b01239698697de47320fd599f5e880b1986b7673/regex-2026.7.10-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fadb07dbe36a541283ff454b1a268afd54b077d917043f2e1e5615372cb5f200", size = 811747, upload-time = "2026-07-10T19:48:16.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/98/16c255c909714de1ee04da6ae30f3ee04170f300cdc0dcf57a314ee4816a/regex-2026.7.10-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:21150500b970b12202879dfd82e7fd809d8e853140fff84d08e57a90cf1e154e", size = 871203, upload-time = "2026-07-10T19:48:18.12Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/32/423ed27c9bae2092a453e853da2b6628a658d08bb5a6117db8d591183d85/regex-2026.7.10-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a68b637451d64ba30ed8ae125c973fa834cc2d37dfa7f154c2b479015d477ba8", size = 917334, upload-time = "2026-07-10T19:48:19.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/87/74dac8efb500db31cb000fda6bae2be45fc2fbf1fa9412f445fbb8acbe37/regex-2026.7.10-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e23458d8903e33e7d27196d7a311523dc4e2f4137a5f34e4dbd30c8d37ff33e", size = 816379, upload-time = "2026-07-10T19:48:21.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/9f/1859403654e3e030b288f06d49233c6a4f889d62b84c4ef3f3a28653173d/regex-2026.7.10-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae27622c094558e519abf3242cf4272db961d12c5c9a9ffb7a1b44b2627d5c6", size = 785563, upload-time = "2026-07-10T19:48:23.643Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/d8/35d30d6bdf1ef6a5430e8982607b3a6db4df1ddedbe001e43435585d88ba/regex-2026.7.10-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ee877b6d78f9dff1da94fef51ae8cf9cce0967e043fdcc864c40b85cf293c192", size = 801415, upload-time = "2026-07-10T19:48:25.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/22/630f31f5ea4826167b2b064d9cac2093a5b3222af380aa432cfe1a5dabcd/regex-2026.7.10-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:2c66a8a1969cfd506d1e203c0005fd0fc3fe6efc83c945606566b6f9611d4851", size = 866560, upload-time = "2026-07-10T19:48:27.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/14/f5914a6d9c5bc63b9bed8c9a1169fb0be35dbe05cdc460e17d953031a366/regex-2026.7.10-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:2bc350e1c5fa250f30ab0c3e38e5cfdffcd82cb8af224df69955cab4e3003812", size = 772877, upload-time = "2026-07-10T19:48:29.563Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/0f/7c13999eef3e4186f7c79d4950fa56f041bf4de107682fb82c80db605ff9/regex-2026.7.10-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:53f54993b462f3f91fea0f2076b46deb6619a5f45d70dbd1f543f789d8b900ef", size = 856648, upload-time = "2026-07-10T19:48:31.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/71/a48e43909b6450fb48fa94e783bef2d9a37179258bc32ef2283955df7be7/regex-2026.7.10-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cfcec18f7da682c4e2d82112829ce906569cb8d69fa6c26f3a50dfbed5ceb682", size = 803520, upload-time = "2026-07-10T19:48:33.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/b8/f037d1bf2c133cb24ceb6e7d81d08417080390eddab6ddfd701aa7091874/regex-2026.7.10-cp313-cp313t-win32.whl", hash = "sha256:a2d6d30be35ddd70ce0f8ee259a4c25f24d6d689a45a5ac440f03e6bcc5a21d1", size = 269168, upload-time = "2026-07-10T19:48:35.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/9c/eaac34f8452a838956e7e89852ad049678cdc1af5d14f72d3b3b658b1ea5/regex-2026.7.10-cp313-cp313t-win_amd64.whl", hash = "sha256:c57b6ad3f7a1bdd101b2966f29dc161adf49727b1e8d3e1e89db2eda8a75c344", size = 280004, upload-time = "2026-07-10T19:48:37.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/a9/e22e997587bc1d588b0b2cd0572027d39dd3a006216e40bbf0361688c51c/regex-2026.7.10-cp313-cp313t-win_arm64.whl", hash = "sha256:3d8ef9df02c8083c7b4b855e3cb87c8e0ebbcfea088d98c7a886aaefdf88d837", size = 279308, upload-time = "2026-07-10T19:48:38.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/4a/a7fa3ada9bd2d2ce20d56dfceec6b2a51afeed9bf3d8286355ceec5f0628/regex-2026.7.10-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:39f81d1fdf594446495f2f4edd8e62d8eda0f7a802c77ac596dc8448ad4cc5ca", size = 497087, upload-time = "2026-07-10T19:48:40.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/7e/ca0b1a87192e5828dbc16f16ae6caca9b67f25bf729a3348468a5ff52755/regex-2026.7.10-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:441edc66a54063f8269d1494fc8474d06605e71e8a918f4bcfd079ebda4ce042", size = 297307, upload-time = "2026-07-10T19:48:42.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/6c/fb40bb34275d3cd4d7a376d5fb2ea1f0f4a96fd884fa83c0c4ae869001bf/regex-2026.7.10-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cfeb11990f59e59a0df26c648f0adfcbf27be77241250636f5769eb08db662be", size = 292163, upload-time = "2026-07-10T19:48:43.929Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/0b/34cbea16c8fea9a18475a7e8f5837c70af451e738bfeb4eb5b029b7dc07a/regex-2026.7.10-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:460176b2db044a292baaee6891106566739657877af89a251cded228689015a6", size = 797064, upload-time = "2026-07-10T19:48:45.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/77/f6805d97f15f5a710bdfd56a768f3468c978239daf9e1b15efd8935e1967/regex-2026.7.10-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9dc55698737aca028848bde418d6c51d74f2a5fd44872d3c8b56b626729adb89", size = 866155, upload-time = "2026-07-10T19:48:47.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/e3/a2a905807bba3bcd90d6ebbb67d27af2adf7d41708175cbc6b956a0c75f1/regex-2026.7.10-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d3e10779f60c000213a5b53f518824bd07b3dc119333b26d70c6be1c27b5c794", size = 911596, upload-time = "2026-07-10T19:48:49.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/ca/a3126888b2c6f33c7e29144fedf85f6d5a52a400024fa045ad8fc0550ef1/regex-2026.7.10-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:38a5926601aaccf379512746b86eb0ac1d29121f6c776dac6ac5b31077432f2c", size = 800713, upload-time = "2026-07-10T19:48:51.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/19/9d252fd969f726c8b56b4bacf910811cc70495a110907b3a7ccb96cd9cad/regex-2026.7.10-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a72ecf5bfd3fc8d57927f7e3ded2487e144472f39010c3acaec3f6f3ff53f361", size = 777286, upload-time = "2026-07-10T19:48:53.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/7a/5f1bf433fa446ecb3aab87bb402603dc9e171ef8052c1bb8690bb4e255a3/regex-2026.7.10-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d50714405845c1010c871098558cfe5718fe39d2a2fab5f95c8863caeb7a82b3", size = 785826, upload-time = "2026-07-10T19:48:55.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/ca/69f3a7281d86f1b592338007f3e535cc219d771448e2b61c0b56e4f9d05b/regex-2026.7.10-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ec1c44cf9bd22079aac37a07cb49a29ced9050ab5bddf24e50aba298f1e34d90", size = 860957, upload-time = "2026-07-10T19:48:57.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/11/487ff55c8d515ec9dd60d7ba3c129eeaa9e527358ed9e8a054a9e9430f81/regex-2026.7.10-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9e9aaef25a40d1f1e1bbb1d0eb0190c4a64a7a1750f7eb67b8399bed6f4fd2a6", size = 765959, upload-time = "2026-07-10T19:49:00.27Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e1/fa034e6fa8896a09bd0d5e19c81fdc024411ab37980950a0401dccee8f6d/regex-2026.7.10-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e54e088dc64dd2766014e7cfe5f8bc45399400fd486816e494f93e3f0f55da06", size = 851447, upload-time = "2026-07-10T19:49:02.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/a5/b9427ed53b0e14c540dc436d56aaf57a19fb9183c6e7abd66f4b4368fbad/regex-2026.7.10-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:834271b1ff2cfa1f67fcd65a48bf11d11e9ab837e21bf79ce554efb648599ae8", size = 789418, upload-time = "2026-07-10T19:49:03.949Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/52/aab92420c8aa845c7bcbe68dc65023d4a9e9ea785abf0beb2198f0de5ba1/regex-2026.7.10-cp314-cp314-win32.whl", hash = "sha256:f988a1cec68058f71a38471813fba9e87dffe855582682e8a10e40ece12567a2", size = 272538, upload-time = "2026-07-10T19:49:05.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/16/5c7050e0ef7dd8889441924ff0a2c33b7f0587c0ccb0953fe7ca997d673b/regex-2026.7.10-cp314-cp314-win_amd64.whl", hash = "sha256:2129e4a5e86f26926982d883dff815056f2e98220fdf630e59f961b578a26c43", size = 280796, upload-time = "2026-07-10T19:49:07.593Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/1a/4f6099d2ba271502fdb97e697bae2ed0213c0d87f2273fe7d21e2e401d12/regex-2026.7.10-cp314-cp314-win_arm64.whl", hash = "sha256:9cd5b6805396157b4cf993a6940cbb8663161f29b4df2458c1c9991f099299c5", size = 281017, upload-time = "2026-07-10T19:49:09.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/02/4061fc71f64703e0df61e782c2894c3fbc089d277767eff6e16099581c73/regex-2026.7.10-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:103e8f3acc3dcede88c0331c8612766bdcfc47c9250c5477f0e10e0550b9da49", size = 501467, upload-time = "2026-07-10T19:49:11.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/a5/8d42b2f3fd672908a05582effd0f88438bf9bb4e8e02d69a62c723e23601/regex-2026.7.10-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:538ddb143f5ca085e372def17ef3ed9d74b50ad7fc431bd85dc50a9af1a7076f", size = 299700, upload-time = "2026-07-10T19:49:14.067Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/70/36fa4b46f73d268c0dbe77c40e62da2cd4833ee206d3b2e438c2034e1f36/regex-2026.7.10-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e3448e86b05ce87d4eb50f9c680860830f3b32493660b39f43957d6263e2eba", size = 294590, upload-time = "2026-07-10T19:49:15.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/a7/b6db1823f3a233c2a46f854fdc986f4fd424a84ed557b7751f2998efb266/regex-2026.7.10-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5eab9d3f981c423afd1a61db055cfe83553c3f6455949e334db04722469dd0a2", size = 811925, upload-time = "2026-07-10T19:49:17.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/7d/f8bee4c210c42c7e8b952bb9fb7099dd7fb2f4bd0f33d0d65a8ab08aafc0/regex-2026.7.10-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:177f930af3ad72e1045f8877540e0c43a38f7d328cf05f31963d0bd5f7ecf067", size = 871257, upload-time = "2026-07-10T19:49:19.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/78/22adf72e614ba0216b996e9aaef5712c23699e360ea127bb3d5ee1a7666f/regex-2026.7.10-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd3b6d97beb39afb412f2c79522b9e099463c31f4c49ab8347c5a2ca3531c478", size = 917551, upload-time = "2026-07-10T19:49:22.069Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/f7/ebc15a39e81e6b58da5f913b91fc293a25c6700d353c14d5cd25fc85712a/regex-2026.7.10-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8679f0652a183d93da646fcec8da8228db0be40d1595da37e6d74c2dc8c4713c", size = 816436, upload-time = "2026-07-10T19:49:24.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/33/20bc2bdd57f7e0fcc51be37e4c4d1bca7f0b4af8dc0a148c23220e689da8/regex-2026.7.10-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:494b19a5805438aeb582de99f9d97603d8fd48e6f4cc74d0088bb292b4da3b70", size = 785935, upload-time = "2026-07-10T19:49:26.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/51/87ff99c849b56309c40214a72b54b0eef320d0516a8a516970cc8be1b725/regex-2026.7.10-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0911e34151a5429d0325dae538ba9851ec0b62426bdfd613060cda8f1c36ec7f", size = 801494, upload-time = "2026-07-10T19:49:28.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/11/fde67d49083fef489b7e0f841e2e5736516795b166c9867f05956c1e494b/regex-2026.7.10-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b862572b7a5f5ed47d2ba5921e63bf8d9e3b682f859d8f11e0e5ca46f7e82173", size = 866549, upload-time = "2026-07-10T19:49:30.592Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/b5/31a156c36acf10181d88f55a66c688d5454a344e53ccc03d49f4a48a2297/regex-2026.7.10-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:3f361215e000d68a4aff375106637b83c80be36091d83ee5107ad3b32bd73f48", size = 773089, upload-time = "2026-07-10T19:49:32.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/bb/734e978c904726664df47ae36ce5eca5065de5141185ae46efec063476a2/regex-2026.7.10-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4533af6099543db32ef26abc2b2f824781d4eebb309ab9296150fd1a0c7eb07d", size = 856710, upload-time = "2026-07-10T19:49:35.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/e5/dc35cea074dbdcb9776c4b0542a3bc326ff08454af0768ef35f3fc66e7fa/regex-2026.7.10-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:668ab85105361d0200e3545bec198a1acfc6b0aeb5fff8897647a826e5a171be", size = 803621, upload-time = "2026-07-10T19:49:37.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/b2/124564af46bc0b592785610b3985315610af0a07f4cf21fa36e06c2398dd/regex-2026.7.10-cp314-cp314t-win32.whl", hash = "sha256:dd7715817a187edd7e2a2390908757f7ba42148e59cad755fb8ee1160c628eca", size = 274558, upload-time = "2026-07-10T19:49:39.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/9c/cd813ce9f3404c0443915175c1e339c5afd8fcda04310102eaf233015eef/regex-2026.7.10-cp314-cp314t-win_amd64.whl", hash = "sha256:78712d4954234df5ca24fdadb65a2ab034213f0cdfde376c272f9fc5e09866bb", size = 283687, upload-time = "2026-07-10T19:49:41.872Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/d3/3dae6a6ce46144940e64425e32b8573a393a009aeaf75fa6752a35399056/regex-2026.7.10-cp314-cp314t-win_arm64.whl", hash = "sha256:749b92640e1970e881fdf22a411d74bf9d049b154f4ef7232eeb9a90dd8be7f3", size = 283377, upload-time = "2026-07-10T19:49:43.985Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.32.3"
|
||||
@@ -1802,6 +2182,15 @@ 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 = "sympy"
|
||||
version = "1.14.0"
|
||||
@@ -1814,6 +2203,60 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiktoken"
|
||||
version = "0.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "regex" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/e5/5f3cb2159769d0f4324c0e9e87f9de3c4b1cd45848a96b2eb3566ad5ca77/tiktoken-0.13.0.tar.gz", hash = "sha256:c9435714c3a84c2319499de9a300c0e604449dd0799ff246458b3bb6a7f433c1", size = 38986, upload-time = "2026-05-15T04:51:27.153Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/4c/1bc81f4cd53e827c4ee67ca951b5935724716049452d8dfa09b8b82372bb/tiktoken-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7bfe1849caa65d1e1d9871817170ec497bbb7984e182012e1bdce72f66608cdb", size = 1036353, upload-time = "2026-05-15T04:50:21.757Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/91/10b9c7076bc02c246c853201fdbbe300a4b8c5ed7b84c25f7403f4e32655/tiktoken-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:91c180fe255bd5a86d8316210d2833a1d4d33d026cd86a67812f4773743c8d26", size = 984644, upload-time = "2026-05-15T04:50:23.256Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/e4/fceae98015fab47fcd49b8bd7f46145bcd187a47e0add1e5378ed67ef980/tiktoken-0.13.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:059c8ecf554eb5b41e6e054ba467b871b03277d267dee7244380aca4359747d4", size = 1119261, upload-time = "2026-05-15T04:50:24.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/39/fe42ad00de01a8c4a49ad8649a2c8a316835a9cad5961b11d21eac0020a5/tiktoken-0.13.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:36217497eaffc158607a3b26f065300db2aefd43b115263f3b9688ce38146173", size = 1138253, upload-time = "2026-05-15T04:50:25.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/c4/ccee1ecccca107e9a16efcecdeeb964c325305038554d466ece65b42338f/tiktoken-0.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:303f7d91b4fce3baddbcde05c139091d4caa5026ac7214c1dc7ff7a71ee429ff", size = 1185747, upload-time = "2026-05-15T04:50:27.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/03/cd0cba295522b91eb55c6b2704f1df895f8226cfe60ab10d4d51d0cc9e69/tiktoken-0.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5d48843bee149630eb735a99e1f4a85b47308d21868ea63163f6e87768d3cfed", size = 1241265, upload-time = "2026-05-15T04:50:28.815Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/25/a10efd564402d82c2ff50d12057353ace447aa8007deceaa48641f63d35c/tiktoken-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:fc1c44cd37b43fc46bae593129164f4f281e82ea116b57a85aa81bda57eafc94", size = 876509, upload-time = "2026-05-15T04:50:30.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/8e/144bde4e01df66b34bb865557c7cd754ed08b036217ebd79c9db5e9048a9/tiktoken-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:32ac870a806cfb260a02d0cb70426aef02e038297f8ad50df5040bb5af360791", size = 1034888, upload-time = "2026-05-15T04:50:31.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/18/d4ac9d20956cdebca04841316660ed584c2fecdc2b81722a28bc7ad3b1e4/tiktoken-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4d9980f11429ed2d737c463bb1fb78cf330caa026adf002f714aced7849a687b", size = 982970, upload-time = "2026-05-15T04:50:32.961Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/ed/6bb8d05b9f731f749fee5c6f5ca63e981143c826a5985877330507bd13b7/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3f277ebea5edd7b8bf03c6f9431e1d67d517530115572b2dc1d465326e8f88c7", size = 1115741, upload-time = "2026-05-15T04:50:34.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/de/2ca96b07a82d972b74fe4b46de055b79c904e45c7eab699354a0bfa697dc/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:a116178fa7e1b4065bff05214360373a65cac22f965be7b3f73d00a0dbfe7649", size = 1136523, upload-time = "2026-05-15T04:50:35.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/dc/9dafec002c2d4424378563cf4cf5c7fb93631d2a55013c8b87554ee4012c/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c397ddda233208345b01bd30f2fca79ff730e55731d0108a603f9bc57f6af3b", size = 1181954, upload-time = "2026-05-15T04:50:36.99Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/d0/1f8578c45b2f24759b46f0b50d31878c63c73e6bf0f2227e10ec5c5408dc/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:95097e4f89b06403976e498abf61a0ee73a7497e73fb599cb211d8197a054d91", size = 1240069, upload-time = "2026-05-15T04:50:38.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/90/28d7f154888610aa9237e541986beb62b479df29d193a5a0617dbb1514d0/tiktoken-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:8f2d16e7a7c783ad81f36e457d046d1f1c8af70b22aec8a13238efe531977c41", size = 874748, upload-time = "2026-05-15T04:50:39.587Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/83/b096c859c2a47c11731bf2f5885f4028b809dfe2396582883eed9cae372f/tiktoken-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5df5d1507bd245f1ccad4a074698240021239e455eb0bb4ced4e3d7181872154", size = 1034228, upload-time = "2026-05-15T04:50:40.988Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/61/c68e123b6d753e3fc2751e9b18e732c9d8bf1e1926762e736eee935d931c/tiktoken-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fe806a50664e83a6ffd56cbd1e4f5dcc6cd32a3e7538f70dc38b1a271384545", size = 982978, upload-time = "2026-05-15T04:50:42.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/8b/96cc178cc584e65d363134500f297790b06cd48cdeb1e8fcf7bbe60f4715/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:125bc05005e747f993a83dc67934249932d6e4209854452cd4c0b1d53fba3ba2", size = 1116355, upload-time = "2026-05-15T04:50:43.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/f5/bab735d2c72ea55404b295d02d092644eb5f7cc6205e34d35eb9abfb9ab2/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5e6358911cab4adee6712da27d65573496a4f68cf8a2b5fca6a4ad10fc5748cf", size = 1135772, upload-time = "2026-05-15T04:50:44.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/b9/6de04ebdf904edfaad87788011b3735087a0c9ea671b9027e1e4e965e8c8/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:975cbd78d085d75d26b59660e262736dcaed1e35f8f142cd6291025c01d25486", size = 1182415, upload-time = "2026-05-15T04:50:46.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/9c/470a05f3b1caf038f44880e334d47ab674e0c80d514c66b375d14d5afa10/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ab9bc99fa020a4c283424590ecd7f3afd70c1c281cb3fa3192a6c3af9f9615", size = 1239879, upload-time = "2026-05-15T04:50:48.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/a6/c1936d16055436cb32e6c6128d68629622e00f4768562f55653752d34768/tiktoken-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:6b1615f0ff71953d19729ceb18865429c185b0a23c5353f1bbca34a394bf60f7", size = 874829, upload-time = "2026-05-15T04:50:49.202Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/07/acb5992c3772b5a36284f742cfb7a5895aa4471d1848ac31464ad50d7fdf/tiktoken-0.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6eb4a5bfbc6426938026b1a334e898ac53541360d62d8c689870160cc80abd67", size = 1033600, upload-time = "2026-05-15T04:50:50.4Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/e9/742e9aec30f59b9f161f7ff7cd072e02ea836c9e1c0854a8076dfcd40d5c/tiktoken-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:43cee3e5400573b2046fbf092cc7a5bc30164f9e4c95ce20714da929df48737a", size = 982516, upload-time = "2026-05-15T04:50:52.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/74/ca1541b053e7648254d2e4b42a253e1bb4359f2c91a0a8d49228c794e1a0/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:7de52e3f566d19b3b11bd37eea552c6c305ad74081f736882bd44d148ed4c48d", size = 1115518, upload-time = "2026-05-15T04:50:53.543Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/e3/93825eaf5a4a504795b787e5d5dea07fbeb3dabf97aa7b450be8bde59c89/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:51384448aa508e4df84c0f7c1dc3211c7f7b8096325660ee5fc82f3e11b381ce", size = 1136867, upload-time = "2026-05-15T04:50:55.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/46/002b68de6827091d5ae90b048f326e8aad8d953520950e5ce1508879414f/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e28157350f7ebf35008dd8e9e0fdb621f976e4230c881099c85e8cf07eaa50e2", size = 1181826, upload-time = "2026-05-15T04:50:56.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/c6/d393e3185a276505182f7abd93fe714f3c444a2be9180798fa052347504e/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:165cf1820ea4a354985c2490a5205d4cc74661c934aca79dd0368232fff94e0f", size = 1239489, upload-time = "2026-05-15T04:50:57.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/4d/bc07d1f1635d4897a202acc0ae11c2886eaa7325c359ba4741b47bf8e225/tiktoken-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6c43a675ca14f6f2749ba7f12075d37456015a24b859f2517b9beb4ef30807ec", size = 873820, upload-time = "2026-05-15T04:50:59.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/93/0dd6adca026a616c3a92974566b43381eea4b475ce1f36c062b8271a9ac5/tiktoken-0.13.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaaaef47c2406277181d2086484c317bf7fc433e2d5d03ff94f56b0dcec87471", size = 1034977, upload-time = "2026-05-15T04:51:00.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/77/5ec6e6bc5b30bed6d93f7f2162d8f6b32437b3ba27cb527cfe004f6109c9/tiktoken-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ca8b310bd93b3772cb1b7922d915446864860f562bdfe4825c63a0aed3fb28cd", size = 983635, upload-time = "2026-05-15T04:51:02.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/b0/c8ae9aff00d625c50659b4513e707a0462c4bf5d4d6cc1b802103225c02e/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:32e0c12305105002c047b3bb1070b0dd9a73b0cb3b2856a8972b810e7a4f5881", size = 1116036, upload-time = "2026-05-15T04:51:04.082Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/ac/6a5dddd1d0a6018ecb389bd0353e6b4a515eb4d2286611bd0ace1937b9e1/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5ba5fd62507a932d1241346179e3b39bc7bf7408f03c272652d93b3bedf5db24", size = 1135544, upload-time = "2026-05-15T04:51:05.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/b8/585032b4384b2f7dcdaddcb52865c83a701a420d09e3c2b4a2be1c450c57/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d108bc2d470fc53c8ecd24f2c0fd2b5f98c33e87cdb6aa2e9b8c5dced703d273", size = 1182217, upload-time = "2026-05-15T04:51:06.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/b6/993ff1ded3958215fd341a847b8e5ffeb5de473f435296870d314fc91ac4/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cb99cb5127449f58d0a2d5f5ccfb390d8dbdfd919c221246caaee29d8725ed51", size = 1239404, upload-time = "2026-05-15T04:51:07.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/3d/fef7e06e3b33e7538db0ced734cf9fe23b6832d2ac4990c119c377aec55e/tiktoken-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:115c4f26ffa11caac8b54eea35c2ad38c612c20a48d35dd15d70a02ac6f51f58", size = 918686, upload-time = "2026-05-15T04:51:08.925Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/82/a7fc44582bc32ab00de988a2299bf77c077f59068b233109e34b7d6ca7e6/tiktoken-0.13.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:472527e9132952f2fbf77cd290658bacf003d4d5a3fabc18e5fbd407cbae4d9b", size = 1034454, upload-time = "2026-05-15T04:51:10.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/d0/24d8a890c14f432a05cea669c17bebeaa99f96a7c79523b590f564246411/tiktoken-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e2f67d27c9626cdd25fe33d9313c5cdb3d8d82da646b68d6eb8e7e9c20e6448", size = 982976, upload-time = "2026-05-15T04:51:11.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/b7/2ab43f62788a9266187a9bfc1d3af99ad83e5eaa25fbef168a69cd5ad14f/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2b920b35805cd64585a37c3dc7ce65fba4d2d36016be01e1d7942482ca29093a", size = 1115526, upload-time = "2026-05-15T04:51:12.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/39/1494321ed323ce7a14d88e3cd6cb9058625977df1c6961ddc492bd10a9f3/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:493af3aa28a4aaf2e3d2600a2ee717252c9bf5ab38fff94eb5a02db5ab77e5ad", size = 1136466, upload-time = "2026-05-15T04:51:13.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/d9/dfd086aa2d918c563a140720e0ce296cada1634efd2783d5cf51e05f984e/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6644c9c2b5cf3916f5a3641d7d12fdb3f006a7b3d9ff6acdaec44e29ab1ff91e", size = 1181863, upload-time = "2026-05-15T04:51:15.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/68/a18b4f307086954fdae32714cb4f85562e34f9d34ab206e61f1816aa6018/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cb65b60b9408563676d874a3a4ee573370066f0dc4e29d84e82e989c6517424", size = 1239218, upload-time = "2026-05-15T04:51:16.103Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/5b/f2aa703a4fc5d2dff73460a7d46cc2f3f44aa0f3dd8eeb20d2a0ecf68862/tiktoken-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:85b78cc3a2c3d48723ca751fa981f1fedccd54194ca0471b957364353a898b07", size = 918110, upload-time = "2026-05-15T04:51:17.237Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.3.0"
|
||||
@@ -1955,6 +2398,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/47/6f/9fba8abc468c904570699eceeb51588f9622172b8fffa4ab11bcf15598c2/torchvision-0.24.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2efb617667950814fc8bb9437e5893861b3616e214285be33cbc364a3f42c599", size = 4358490, upload-time = "2025-10-15T15:51:43.884Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tqdm"
|
||||
version = "4.68.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ae/5f/57ff8b434839e70dab45601284ea413e947a63799891b7553e5960a793a8/tqdm-4.68.4.tar.gz", hash = "sha256:19829c9673638f2a0b8617da4cdcb927e831cd88bcfcb6e78d42a4d1af131520", size = 792418, upload-time = "2026-07-07T09:58:18.369Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/22/2a/5e5e750890ada51017d18d0d4c30da696e5b5bd3180947729927628fc3cb/tqdm-4.68.4-py3-none-any.whl", hash = "sha256:5168118b2368f48c561afda8020fd79195b1bdb0bdf8086b88442c267a315dc2", size = 676612, upload-time = "2026-07-07T09:58:16.256Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "triton"
|
||||
version = "3.5.0"
|
||||
@@ -1968,6 +2423,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/b7/1dec8433ac604c061173d0589d99217fe7bf90a70bdc375e745d044b8aad/triton-3.5.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:317fe477ea8fd4524a6a8c499fb0a36984a56d0b75bf9c9cb6133a1c56d5a6e7", size = 170580176, upload-time = "2025-10-13T16:38:31.14Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "truststore"
|
||||
version = "0.10.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
|
||||
Reference in New Issue
Block a user